@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-tasks/REQUIREMENT.md +2 -2
- package/claude/skills/claude-tasks/SKILL.md +4 -4
- package/claude/skills/setup-indexes/REQUIREMENT.md +3 -0
- package/claude/skills/setup-indexes/SKILL.md +8 -0
- package/claude/skills/setup-smoke/REQUIREMENT.md +38 -0
- package/claude/skills/setup-smoke/SKILL.md +53 -0
- package/claude/skills/setup-verify/REQUIREMENT.md +4 -4
- package/claude/skills/setup-verify/SKILL.md +3 -3
- package/docs/agents/commands.md +83 -79
- package/docs/agents/tasks.md +26 -0
- package/docs/target-projects.md +2 -0
- package/docs/workflow/ai-workflow.md +11 -10
- package/governance/rules/ui/440-surface-capture.md +4 -2
- package/package.json +1 -1
- package/src/claude/cases/setup.ts +5 -0
- package/src/commands/migrate.ts +136 -0
- package/src/commands/tasks.ts +84 -0
- package/src/migrate/scratch-evidence.ts +319 -0
- package/src/tasks/label.ts +110 -0
- package/tooling/astro/reference.md +6 -0
- package/tooling/web/configs/e2e/screenshot.ts +28 -1
- package/tooling/web/manifest.toml +1 -0
- package/tooling/web/reference.md +3 -2
|
@@ -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:
|
|
205
|
-
| `canon:claude-
|
|
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
|
-
-
|
|
34
|
-
-
|
|
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
|
@@ -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.',
|
package/src/commands/migrate.ts
CHANGED
|
@@ -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')
|
package/src/commands/tasks.ts
CHANGED
|
@@ -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
|
+
}
|