@erclx/canon 4.84.0 → 4.86.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/auto-ship/SKILL.md +1 -1
- package/claude/skills/docs-fold/SKILL.md +1 -1
- package/claude/skills/draft-and-pick/REQUIREMENT.md +2 -1
- package/claude/skills/draft-and-pick/SKILL.md +3 -2
- package/claude/skills/draft-and-pick/references/live-arms.md +19 -0
- package/claude/skills/git-ship/SKILL.md +2 -2
- package/claude/skills/memory-capture/SKILL.md +1 -1
- package/claude/skills/memory-review/REQUIREMENT.md +1 -1
- package/claude/skills/memory-review/SKILL.md +25 -22
- package/claude/skills/memory-review/references/receipt-format.md +2 -2
- package/claude/skills/role-worker/SKILL.md +2 -1
- package/claude/skills/ux-walkthrough/SKILL.md +2 -2
- package/claude/skills/ux-walkthrough/references/candidate-pages.md +1 -16
- package/docs/agents/commands.md +4 -1
- package/docs/agents/records.md +6 -6
- package/docs/workflow/ai-workflow.md +2 -2
- package/governance/rules/claude/563-ready.md +11 -0
- package/governance/rules/core/045-memory.md +1 -1
- package/package.json +1 -1
- package/src/commands/migrate.ts +142 -0
- package/src/commands/records.ts +12 -6
- package/src/design/tokens.ts +7 -4
- package/src/migrate/record-layout.ts +346 -0
- package/src/migrate/record-tree.ts +1 -1
- package/src/migrate/scratch-evidence.ts +3 -3
- package/src/record-root.ts +2 -0
- package/src/records/backup.ts +132 -37
- package/src/records/size.ts +16 -12
- package/standards/index.md +1 -0
- package/standards/memory.md +1 -1
- package/standards/plan.md +2 -0
- package/standards/ready.md +105 -0
- package/standards/skill.md +1 -1
- package/tooling/claude/seeds/.claude/hooks/memory-index.sh +10 -0
package/src/commands/migrate.ts
CHANGED
|
@@ -30,6 +30,13 @@ import {
|
|
|
30
30
|
planSurfaceRootsMove,
|
|
31
31
|
type SurfaceRootsPlan,
|
|
32
32
|
} from '@/migrate/surface-roots'
|
|
33
|
+
import {
|
|
34
|
+
applyRecordLayout,
|
|
35
|
+
planRecordLayout,
|
|
36
|
+
readRecordLayoutCorpus,
|
|
37
|
+
type RecordLayoutPlan,
|
|
38
|
+
walkRecordLayoutCorpus,
|
|
39
|
+
} from '@/migrate/record-layout'
|
|
33
40
|
import {
|
|
34
41
|
applyScratchEvidence,
|
|
35
42
|
planScratchEvidence,
|
|
@@ -746,6 +753,102 @@ function toScratchEvidenceRecord(
|
|
|
746
753
|
}
|
|
747
754
|
}
|
|
748
755
|
|
|
756
|
+
interface RecordLayoutOptions {
|
|
757
|
+
readonly json?: boolean
|
|
758
|
+
readonly write?: boolean
|
|
759
|
+
readonly root?: string
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Moves review receipts from `.canon/review/memory/` to `.canon/memory/review/`
|
|
764
|
+
* and retired entries from `.canon/tmp/memory-archive/` to
|
|
765
|
+
* `.canon/memory/archive/`, and repoints the citations that name either.
|
|
766
|
+
*/
|
|
767
|
+
async function runRecordLayout(opts: RecordLayoutOptions): Promise<number> {
|
|
768
|
+
const root = opts.root ?? process.cwd()
|
|
769
|
+
|
|
770
|
+
const files = await walkRecordLayoutCorpus(root)
|
|
771
|
+
const sources = await readRecordLayoutCorpus(files)
|
|
772
|
+
const plan = planRecordLayout(root, sources)
|
|
773
|
+
|
|
774
|
+
if (opts.json) {
|
|
775
|
+
process.stdout.write(
|
|
776
|
+
`${JSON.stringify(toRecordLayoutRecord(plan, opts.write))}\n`,
|
|
777
|
+
)
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
reportRecordLayout(plan)
|
|
781
|
+
|
|
782
|
+
if (plan.collisions.length > 0) {
|
|
783
|
+
logError(
|
|
784
|
+
`${plural(plan.collisions.length, 'destination')} already occupied. Neither side moved.`,
|
|
785
|
+
)
|
|
786
|
+
for (const collision of plan.collisions) logError(` ${collision}`)
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (plan.moves.length === 0 && plan.entries.length === 0) {
|
|
790
|
+
return plan.collisions.length > 0 ? 1 : 0
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (!opts.write) {
|
|
794
|
+
logWarn('Nothing was written. Pass --write to apply this plan.')
|
|
795
|
+
return 2
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const applied = await applyRecordLayout(plan)
|
|
799
|
+
logStep(
|
|
800
|
+
`Moved ${plural(applied.moved, 'folder')} and rewrote ${plural(applied.written, 'file')}.`,
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
if (applied.failed.length > 0) {
|
|
804
|
+
logError(`Could not write ${plural(applied.failed.length, 'file')}.`)
|
|
805
|
+
for (const path of applied.failed) logError(` ${path}`)
|
|
806
|
+
return 1
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
return plan.collisions.length > 0 ? 1 : 0
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function reportRecordLayout(plan: RecordLayoutPlan): void {
|
|
813
|
+
logInfo(`${plural(plan.moves.length, 'folder')} to move.`)
|
|
814
|
+
for (const move of plan.moves) {
|
|
815
|
+
logInfo(` ${move.from} -> ${move.to}`)
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
logInfo(
|
|
819
|
+
`${plural(plan.entries.length, 'file')} to change, ${plural(plan.rewritten, 'citation')} to rewrite.`,
|
|
820
|
+
)
|
|
821
|
+
for (const entry of plan.entries) {
|
|
822
|
+
logInfo(` ${entry.path}: ${plural(entry.rewritten, 'citation')}`)
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if (plan.strays.length > 0) {
|
|
826
|
+
logWarn(
|
|
827
|
+
`${plural(plan.strays.length, 'stray receipt')} at the flat review/ root. Not moved, move by hand into memory/review/.`,
|
|
828
|
+
)
|
|
829
|
+
for (const stray of plan.strays) logWarn(` ${stray}`)
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function toRecordLayoutRecord(
|
|
834
|
+
plan: RecordLayoutPlan,
|
|
835
|
+
wrote: boolean | undefined,
|
|
836
|
+
): unknown {
|
|
837
|
+
return {
|
|
838
|
+
ok: true,
|
|
839
|
+
wrote: wrote === true,
|
|
840
|
+
moves: plan.moves.map((move) => ({ from: move.from, to: move.to })),
|
|
841
|
+
collisions: plan.collisions,
|
|
842
|
+
files: plan.entries.length,
|
|
843
|
+
rewritten: plan.rewritten,
|
|
844
|
+
strays: plan.strays,
|
|
845
|
+
paths: plan.entries.map((entry) => ({
|
|
846
|
+
path: entry.path,
|
|
847
|
+
rewritten: entry.rewritten,
|
|
848
|
+
})),
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
749
852
|
interface RuleLayoutOptions {
|
|
750
853
|
readonly json?: boolean
|
|
751
854
|
readonly write?: boolean
|
|
@@ -1031,6 +1134,45 @@ export function register(program: Command): void {
|
|
|
1031
1134
|
process.exitCode = await runScratchEvidence(opts)
|
|
1032
1135
|
})
|
|
1033
1136
|
|
|
1137
|
+
migrate
|
|
1138
|
+
.command('record-layout')
|
|
1139
|
+
.description('Fold memory review receipts and archive under memory/')
|
|
1140
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
1141
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
1142
|
+
.option('--write', 'Apply the plan rather than reporting it')
|
|
1143
|
+
.option(
|
|
1144
|
+
'--root <path>',
|
|
1145
|
+
'Project root, defaulting to the working directory',
|
|
1146
|
+
)
|
|
1147
|
+
.addHelpText(
|
|
1148
|
+
'after',
|
|
1149
|
+
[
|
|
1150
|
+
'',
|
|
1151
|
+
'Moves review receipts from .canon/review/memory/ to',
|
|
1152
|
+
'.canon/memory/review/, and retired entries from',
|
|
1153
|
+
'.canon/tmp/memory-archive/ to .canon/memory/archive/, backed for',
|
|
1154
|
+
'the first time, and repoints every citation that names either,',
|
|
1155
|
+
'live or archived.',
|
|
1156
|
+
'',
|
|
1157
|
+
'A receipt sitting at the flat review/ root, the shape memory-review',
|
|
1158
|
+
'wrote before review/memory/ existed, is reported rather than moved.',
|
|
1159
|
+
'',
|
|
1160
|
+
'Exit codes:',
|
|
1161
|
+
' 0 nothing to move, or --write applied the whole plan',
|
|
1162
|
+
' 1 a write failed, or an unresolved collision remains',
|
|
1163
|
+
' 2 a plan exists and --write was not passed',
|
|
1164
|
+
'',
|
|
1165
|
+
'Examples:',
|
|
1166
|
+
' canon migrate record-layout',
|
|
1167
|
+
' canon migrate record-layout --write',
|
|
1168
|
+
' canon migrate record-layout --json',
|
|
1169
|
+
'',
|
|
1170
|
+
].join('\n'),
|
|
1171
|
+
)
|
|
1172
|
+
.action(async (opts: RecordLayoutOptions) => {
|
|
1173
|
+
process.exitCode = await runRecordLayout(opts)
|
|
1174
|
+
})
|
|
1175
|
+
|
|
1034
1176
|
migrate
|
|
1035
1177
|
.command('rename')
|
|
1036
1178
|
.description('Rewrite every unprotected aitk token to canon')
|
package/src/commands/records.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
|
-
import {
|
|
4
|
+
import { pullRecords, pushRecords } from '@/records/backup'
|
|
5
5
|
import { migrateRecord } from '@/records/migrate'
|
|
6
6
|
import {
|
|
7
7
|
type ClaimOutcome,
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
type FolderSize,
|
|
15
15
|
formatBytes,
|
|
16
16
|
GROWTH_WINDOWS,
|
|
17
|
-
|
|
17
|
+
sizedFolders,
|
|
18
18
|
type SizeOutcome,
|
|
19
19
|
sizeRecords,
|
|
20
20
|
} from '@/records/size'
|
|
@@ -207,8 +207,9 @@ export function register(program: Command): void {
|
|
|
207
207
|
'after',
|
|
208
208
|
[
|
|
209
209
|
'',
|
|
210
|
-
'Folders read
|
|
211
|
-
|
|
210
|
+
'Folders read, at whichever record root the project carries:',
|
|
211
|
+
' every folder canon records push carries, plus the scratch folder',
|
|
212
|
+
' (deletable without loss, so a backup skips it, but a reading does not)',
|
|
212
213
|
'',
|
|
213
214
|
'Exit codes:',
|
|
214
215
|
' 0 the reading completed',
|
|
@@ -274,8 +275,10 @@ function backupHelp(verb: 'push' | 'pull'): string {
|
|
|
274
275
|
|
|
275
276
|
return [
|
|
276
277
|
'',
|
|
277
|
-
'Backed folders
|
|
278
|
-
|
|
278
|
+
'Backed folders, at whichever record root the project carries:',
|
|
279
|
+
' every top-level entry, less tmp, ordinal-locks, and .records.git',
|
|
280
|
+
' (a legacy .claude root instead uses a fixed list; run this with --json',
|
|
281
|
+
' to see what actually resolved, including any folder seen for the first time)',
|
|
279
282
|
'',
|
|
280
283
|
'Exit codes:',
|
|
281
284
|
' 0 the records remote and this machine agree',
|
|
@@ -404,6 +407,9 @@ async function runPush(opts: BackupCommandOptions): Promise<number> {
|
|
|
404
407
|
logInfo(
|
|
405
408
|
`${outcome.folders.length} folder(s), ${outcome.changed} path(s) changed`,
|
|
406
409
|
)
|
|
410
|
+
if (outcome.firstSeen.length > 0) {
|
|
411
|
+
logWarn(`first seen: ${outcome.firstSeen.join(', ')}`)
|
|
412
|
+
}
|
|
407
413
|
logStep(outcome.pushed ? 'Pushed' : 'Nothing to push')
|
|
408
414
|
logInfo(
|
|
409
415
|
outcome.commit
|
package/src/design/tokens.ts
CHANGED
|
@@ -104,7 +104,7 @@ export const TOKENS: DesignTokens = {
|
|
|
104
104
|
].join('\n'),
|
|
105
105
|
|
|
106
106
|
colorNote: [
|
|
107
|
-
'Every role clears WCAG AA at 4.5:1 against each ground it declares, asserted in `src/design/contrast.test.ts`.
|
|
107
|
+
'Every role clears WCAG AA at 4.5:1 against each ground it declares, asserted in `src/design/contrast.test.ts`.',
|
|
108
108
|
'',
|
|
109
109
|
'Warning and error hold ANSI codes because that is what `scripts/lib/ui.sh` writes and no rendered surface implements an equivalent. Giving either a hex value would invent a mapping no file has, so they carry no contrast reading either.',
|
|
110
110
|
'',
|
|
@@ -232,7 +232,7 @@ export const TOKENS: DesignTokens = {
|
|
|
232
232
|
typographyNote: [
|
|
233
233
|
'One family covers every role but `page-display`, which is the landing page hero and takes the proportional sibling of the same superfamily. The size scale runs from 11.5 to 52 pixels, and six values map onto a role. Five further values are adjustments inside a single component and get no role here, since a scale with five invented steps reads as a system the surfaces do not implement. They are 11.5, 12.5, 13, 14, and 15 pixels.',
|
|
234
234
|
'',
|
|
235
|
-
'The 52 pixel step sits above the 34 the rest of the scale tops out at, and it is the one size no other surface reaches
|
|
235
|
+
'The 52 pixel step sits above the 34 the rest of the scale tops out at, and it is the one size no other surface reaches, since a hero headline set at the display cap reads as an opening rather than as a section heading.',
|
|
236
236
|
'',
|
|
237
237
|
'A tagged cell is one no rendering surface exercises yet, which is a declaration the system has not tested rather than one it has.',
|
|
238
238
|
'',
|
|
@@ -353,8 +353,11 @@ export const TOKENS: DesignTokens = {
|
|
|
353
353
|
motion:
|
|
354
354
|
'Motion is not used. No transition, animation, or keyframe declaration appears on any rendered surface, and the capture pipeline screenshots a static frame.',
|
|
355
355
|
|
|
356
|
-
iconography:
|
|
357
|
-
|
|
356
|
+
iconography: [
|
|
357
|
+
'No icon library is installed. `assets/brand/mark.svg` is the one authored icon, embedded inline in the hero topbar, and the surfaces otherwise draw literal glyph characters: `│ ├ ✓ ! ✗ + - ◆ ◇ ❯` for the terminal framing.',
|
|
358
|
+
'',
|
|
359
|
+
'The same mark ships as a favicon on every rendered surface, as three independently-maintained copies that track different accents by design rather than by drift, colored to fit the chrome each renders on: the dark accent (`#e0724b`) for a dark-chrome surface and the light accent (`#a4471c`) for a light-chrome one. Unifying the three or repairing the one that looks drifted would break the fit each was chosen for. `canon/context/design.md` carries which file holds each copy.',
|
|
360
|
+
].join('\n'),
|
|
358
361
|
}
|
|
359
362
|
|
|
360
363
|
/** A role's value, or `undefined` where the record declares no such role. */
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The memory pen's review receipts and retired entries, folded under the pen
|
|
3
|
+
* itself rather than sitting in three separate record folders.
|
|
4
|
+
*
|
|
5
|
+
* `.canon/review/memory/` and `.canon/tmp/memory-archive/` move to
|
|
6
|
+
* `.canon/memory/review/` and `.canon/memory/archive/`, the second one backed
|
|
7
|
+
* for the first time: `canon records push` carries every top-level `.canon/`
|
|
8
|
+
* entry except `EXCLUDED_ENTRIES`, and `tmp/` is one of the three names that
|
|
9
|
+
* set excludes. `canon/ARCHITECTURE.md`'s "A durable record is named for what
|
|
10
|
+
* it is, not for how long it lives" already names the cost this closes: two
|
|
11
|
+
* surfaces both named for memory archived to two different places.
|
|
12
|
+
*
|
|
13
|
+
* The move and the citation repoint follow `scratch-evidence.ts`'s shape: a
|
|
14
|
+
* dry run by default, `--write` to apply, a collision refusal, the
|
|
15
|
+
* `canon-keep-record-root` marker honored, and archives swept on purpose,
|
|
16
|
+
* since an archived receipt still cites the row it retired.
|
|
17
|
+
*
|
|
18
|
+
* `moves` is a data table rather than one function per pair, so a later
|
|
19
|
+
* intake batch in the same folder-layout group appends an entry instead of
|
|
20
|
+
* restructuring the module.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
24
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
25
|
+
import { dirname, join } from 'node:path'
|
|
26
|
+
import { presentFolders } from '@/records/backup'
|
|
27
|
+
import { recordDir, SCRATCH, spell, type RecordRoot } from '@/record-root'
|
|
28
|
+
|
|
29
|
+
const CANON: RecordRoot = '.canon'
|
|
30
|
+
const CLAUDE: RecordRoot = '.claude'
|
|
31
|
+
|
|
32
|
+
export interface RecordLayoutMove {
|
|
33
|
+
readonly from: readonly string[]
|
|
34
|
+
readonly to: readonly string[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Every folder this migration moves, as a record-relative path on each side.
|
|
39
|
+
*
|
|
40
|
+
* Derived from the plan's own mapping: the review folder's memory receipts
|
|
41
|
+
* fold into the pen at `memory/review/`, and the scratch archive folds in at
|
|
42
|
+
* `memory/archive/`. A later batch in the same intake group appends here
|
|
43
|
+
* rather than adding a second table.
|
|
44
|
+
*/
|
|
45
|
+
export const RECORD_LAYOUT_MOVES: readonly RecordLayoutMove[] = [
|
|
46
|
+
{ from: ['review', 'memory'], to: ['memory', 'review'] },
|
|
47
|
+
{ from: [SCRATCH, 'memory-archive'], to: ['memory', 'archive'] },
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
/** Where a move's source sits today. */
|
|
51
|
+
export function sourcePath(root: string, move: RecordLayoutMove): string {
|
|
52
|
+
const [folder, ...rest] = move.from
|
|
53
|
+
return recordDir(root, folder, ...rest)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Where it lands after, always under whichever root `memory/` resolves at. */
|
|
57
|
+
export function destinationPath(root: string, move: RecordLayoutMove): string {
|
|
58
|
+
const [folder, ...rest] = move.to
|
|
59
|
+
return recordDir(root, folder, ...rest)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function escape(value: string): string {
|
|
63
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The prefixes a citation of a move's source is spelled with: absolute at
|
|
68
|
+
* either record root, spelled the way each root spells the leading segment,
|
|
69
|
+
* and relative one and two directories up, in both spellings, since a
|
|
70
|
+
* relative citation carries no root of its own to read the spelling from.
|
|
71
|
+
*/
|
|
72
|
+
function oldPrefixes(from: readonly string[]): readonly string[] {
|
|
73
|
+
const [head, ...rest] = from
|
|
74
|
+
const canonSuffix = [spell(CANON, head), ...rest].join('/')
|
|
75
|
+
const claudeSuffix = [spell(CLAUDE, head), ...rest].join('/')
|
|
76
|
+
|
|
77
|
+
const prefixes = new Set<string>([
|
|
78
|
+
`.canon/${canonSuffix}/`,
|
|
79
|
+
`.claude/${claudeSuffix}/`,
|
|
80
|
+
])
|
|
81
|
+
for (const suffix of [canonSuffix, claudeSuffix]) {
|
|
82
|
+
prefixes.add(`../${suffix}/`)
|
|
83
|
+
prefixes.add(`../../${suffix}/`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return [...prefixes]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function citationPattern(move: RecordLayoutMove): RegExp {
|
|
90
|
+
const alternation = oldPrefixes(move.from).map(escape).join('|')
|
|
91
|
+
return new RegExp(`(?:${alternation})`, 'g')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface Rewrite {
|
|
95
|
+
readonly pattern: RegExp
|
|
96
|
+
readonly destination: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** One rewrite per move, paired with its citation pattern. */
|
|
100
|
+
function buildRewrites(moves: readonly RecordLayoutMove[]): readonly Rewrite[] {
|
|
101
|
+
return moves.map((move) => ({
|
|
102
|
+
pattern: citationPattern(move),
|
|
103
|
+
destination: `.canon/${move.to.join('/')}/`,
|
|
104
|
+
}))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Marks a line naming a moved path on purpose, the same marker
|
|
109
|
+
* `scratch-evidence.ts` and `records.ts` read: on the line itself or on the
|
|
110
|
+
* nearest non-blank line above it.
|
|
111
|
+
*/
|
|
112
|
+
const KEEP_MARKER = 'canon-keep-record-root'
|
|
113
|
+
|
|
114
|
+
function isKept(lines: readonly string[], index: number): boolean {
|
|
115
|
+
if (lines[index]?.includes(KEEP_MARKER)) return true
|
|
116
|
+
|
|
117
|
+
let above = index - 1
|
|
118
|
+
while (above >= 0 && lines[above]?.trim() === '') above -= 1
|
|
119
|
+
|
|
120
|
+
return above >= 0 && (lines[above]?.includes(KEEP_MARKER) ?? false)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function rewriteLine(line: string, rewrites: readonly Rewrite[]): string {
|
|
124
|
+
return rewrites.reduce(
|
|
125
|
+
(current, { pattern, destination }) =>
|
|
126
|
+
current.replace(pattern, destination),
|
|
127
|
+
line,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface RewriteOutcome {
|
|
132
|
+
readonly text: string
|
|
133
|
+
readonly count: number
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Rewrites every unmarked citation a move covers into its destination,
|
|
138
|
+
* counting each as it goes. A file naming no such path returns
|
|
139
|
+
* byte-identical with a count of zero, and a marked line is returned
|
|
140
|
+
* unchanged and uncounted.
|
|
141
|
+
*/
|
|
142
|
+
function applyRewrites(
|
|
143
|
+
text: string,
|
|
144
|
+
rewrites: readonly Rewrite[],
|
|
145
|
+
): RewriteOutcome {
|
|
146
|
+
const lines = text.split('\n')
|
|
147
|
+
let count = 0
|
|
148
|
+
|
|
149
|
+
const rewritten = lines.map((line, index) => {
|
|
150
|
+
if (isKept(lines, index)) return line
|
|
151
|
+
|
|
152
|
+
for (const { pattern } of rewrites) {
|
|
153
|
+
count += [...line.matchAll(pattern)].length
|
|
154
|
+
}
|
|
155
|
+
return rewriteLine(line, rewrites)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
return { text: rewritten.join('\n'), count }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The files under every present backed folder at `root`, archives included. */
|
|
162
|
+
export async function walkRecordLayoutCorpus(root: string): Promise<string[]> {
|
|
163
|
+
const files: string[] = []
|
|
164
|
+
|
|
165
|
+
for (const folder of presentFolders(root)) {
|
|
166
|
+
const dir = recordDir(root, folder)
|
|
167
|
+
if (!existsSync(dir)) continue
|
|
168
|
+
|
|
169
|
+
const glob = new Bun.Glob('**/*')
|
|
170
|
+
for await (const path of glob.scan({
|
|
171
|
+
cwd: dir,
|
|
172
|
+
onlyFiles: true,
|
|
173
|
+
dot: true,
|
|
174
|
+
})) {
|
|
175
|
+
files.push(join(dir, path))
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return files.sort()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface RecordLayoutSource {
|
|
183
|
+
readonly path: string
|
|
184
|
+
readonly text: string
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Reads every file the walk found, skipping one that carries a NUL byte. */
|
|
188
|
+
export async function readRecordLayoutCorpus(
|
|
189
|
+
paths: readonly string[],
|
|
190
|
+
): Promise<RecordLayoutSource[]> {
|
|
191
|
+
const sources: RecordLayoutSource[] = []
|
|
192
|
+
|
|
193
|
+
for (const path of paths) {
|
|
194
|
+
const bytes = await readFile(path).catch(() => undefined)
|
|
195
|
+
if (bytes === undefined || bytes.includes(0)) continue
|
|
196
|
+
|
|
197
|
+
sources.push({ path, text: bytes.toString('utf8') })
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return sources
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface FolderMove {
|
|
204
|
+
readonly move: RecordLayoutMove
|
|
205
|
+
readonly from: string
|
|
206
|
+
readonly to: string
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface CitationEntry {
|
|
210
|
+
readonly path: string
|
|
211
|
+
readonly text: string
|
|
212
|
+
readonly rewritten: number
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface RecordLayoutPlan {
|
|
216
|
+
readonly moves: readonly FolderMove[]
|
|
217
|
+
readonly collisions: readonly string[]
|
|
218
|
+
readonly entries: readonly CitationEntry[]
|
|
219
|
+
readonly rewritten: number
|
|
220
|
+
readonly strays: readonly string[]
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const STRAY_RECEIPT_PATTERN = /^memory-review-.*\.md$/
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* A receipt sitting at the flat `review/` root, the shape `memory-review`
|
|
227
|
+
* wrote before `.canon/review/memory/` existed. Neither mapped move covers
|
|
228
|
+
* it, since it sits one level above the folder either move reads, so it is
|
|
229
|
+
* reported rather than moved: widening the table for one stray file trades a
|
|
230
|
+
* data-shaped mapping for a special case.
|
|
231
|
+
*/
|
|
232
|
+
export function strayReceipts(root: string): string[] {
|
|
233
|
+
const dir = recordDir(root, 'review')
|
|
234
|
+
const entries = existsSync(dir)
|
|
235
|
+
? readdirSync(dir, { withFileTypes: true })
|
|
236
|
+
: []
|
|
237
|
+
|
|
238
|
+
return entries
|
|
239
|
+
.filter((entry) => entry.isFile() && STRAY_RECEIPT_PATTERN.test(entry.name))
|
|
240
|
+
.map((entry) => join(dir, entry.name))
|
|
241
|
+
.sort()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Every mapped move found on disk, with its destination, refusing a move
|
|
246
|
+
* whose destination is already occupied rather than merging into it.
|
|
247
|
+
*/
|
|
248
|
+
export function planFolderMoves(root: string): {
|
|
249
|
+
moves: FolderMove[]
|
|
250
|
+
collisions: string[]
|
|
251
|
+
} {
|
|
252
|
+
const moves: FolderMove[] = []
|
|
253
|
+
const collisions: string[] = []
|
|
254
|
+
|
|
255
|
+
for (const move of RECORD_LAYOUT_MOVES) {
|
|
256
|
+
const from = sourcePath(root, move)
|
|
257
|
+
if (!existsSync(from)) continue
|
|
258
|
+
|
|
259
|
+
const to = destinationPath(root, move)
|
|
260
|
+
if (existsSync(to)) {
|
|
261
|
+
collisions.push(to)
|
|
262
|
+
continue
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
moves.push({ move, from, to })
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return { moves, collisions }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* What the migration would do, without doing it. Pure over the sources it is
|
|
273
|
+
* handed, so a caller reports and applies from the same value. A file whose
|
|
274
|
+
* text does not change is dropped.
|
|
275
|
+
*/
|
|
276
|
+
export function planRecordLayout(
|
|
277
|
+
root: string,
|
|
278
|
+
sources: readonly RecordLayoutSource[],
|
|
279
|
+
): RecordLayoutPlan {
|
|
280
|
+
const { moves, collisions } = planFolderMoves(root)
|
|
281
|
+
const collidedDestinations = new Set(collisions)
|
|
282
|
+
const covered = RECORD_LAYOUT_MOVES.filter(
|
|
283
|
+
(move) => !collidedDestinations.has(destinationPath(root, move)),
|
|
284
|
+
)
|
|
285
|
+
const rewrites = buildRewrites(covered)
|
|
286
|
+
const entries: CitationEntry[] = []
|
|
287
|
+
|
|
288
|
+
for (const source of sources) {
|
|
289
|
+
const { text, count } = applyRewrites(source.text, rewrites)
|
|
290
|
+
if (count === 0) continue
|
|
291
|
+
|
|
292
|
+
entries.push({ path: source.path, text, rewritten: count })
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
moves,
|
|
297
|
+
collisions,
|
|
298
|
+
entries,
|
|
299
|
+
rewritten: entries.reduce((sum, entry) => sum + entry.rewritten, 0),
|
|
300
|
+
strays: strayReceipts(root),
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export interface RecordLayoutResult {
|
|
305
|
+
readonly moved: number
|
|
306
|
+
readonly written: number
|
|
307
|
+
readonly failed: readonly string[]
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Writes the plan: every citation rewrite first, then every folder move.
|
|
312
|
+
*
|
|
313
|
+
* A citation can sit inside a file the plan is about to move, since the walk
|
|
314
|
+
* carries archives on purpose and a receipt can cite the row it retired.
|
|
315
|
+
* Rewriting first is what keeps that write landing on a path that still
|
|
316
|
+
* exists: renaming the folder first would send `writeFile` at the pre-move
|
|
317
|
+
* path into a directory `rename` already cleared.
|
|
318
|
+
*/
|
|
319
|
+
export async function applyRecordLayout(
|
|
320
|
+
plan: RecordLayoutPlan,
|
|
321
|
+
): Promise<RecordLayoutResult> {
|
|
322
|
+
let moved = 0
|
|
323
|
+
let written = 0
|
|
324
|
+
const failed: string[] = []
|
|
325
|
+
|
|
326
|
+
for (const entry of plan.entries) {
|
|
327
|
+
const done = await writeFile(entry.path, entry.text)
|
|
328
|
+
.then(() => true)
|
|
329
|
+
.catch(() => false)
|
|
330
|
+
|
|
331
|
+
if (done) written += 1
|
|
332
|
+
else failed.push(entry.path)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
for (const move of plan.moves) {
|
|
336
|
+
await mkdir(dirname(move.to), { recursive: true })
|
|
337
|
+
const done = await rename(move.from, move.to)
|
|
338
|
+
.then(() => true)
|
|
339
|
+
.catch(() => false)
|
|
340
|
+
|
|
341
|
+
if (done) moved += 1
|
|
342
|
+
else failed.push(move.from)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return { moved, written, failed }
|
|
346
|
+
}
|
|
@@ -60,7 +60,7 @@ export const OBJECT_STORE = '.records.git'
|
|
|
60
60
|
* `archive` is the substantive one: an archived plan or a retired memory entry
|
|
61
61
|
* describes work that closed, and a path inside that sentence is history rather
|
|
62
62
|
* than a pointer. It is pruned at any depth because the archives do not all sit
|
|
63
|
-
* at the same one, `review/
|
|
63
|
+
* at the same one, `memory/review/archive/` being two levels down.
|
|
64
64
|
*/
|
|
65
65
|
export const PRUNED_SEGMENTS: readonly string[] = [
|
|
66
66
|
'archive',
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
import { existsSync } from 'node:fs'
|
|
27
27
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
28
28
|
import { dirname, join } from 'node:path'
|
|
29
|
-
import {
|
|
29
|
+
import { presentFolders } from '@/records/backup'
|
|
30
30
|
import { recordDir, SCRATCH } from '@/record-root'
|
|
31
31
|
|
|
32
32
|
/**
|
|
@@ -168,13 +168,13 @@ function applyRewrites(
|
|
|
168
168
|
return { text: rewritten.join('\n'), count }
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
/** The files under every `
|
|
171
|
+
/** The files under every present backed folder at `root`, archives included. */
|
|
172
172
|
export async function walkScratchEvidenceCorpus(
|
|
173
173
|
root: string,
|
|
174
174
|
): Promise<string[]> {
|
|
175
175
|
const files: string[] = []
|
|
176
176
|
|
|
177
|
-
for (const folder of
|
|
177
|
+
for (const folder of presentFolders(root)) {
|
|
178
178
|
const dir = recordDir(root, folder)
|
|
179
179
|
if (!existsSync(dir)) continue
|
|
180
180
|
|