@erclx/canon 4.6.1 → 4.7.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/canon-feedback-file/SKILL.md +3 -3
- package/claude/skills/canon-feedback-triage/SKILL.md +1 -1
- package/claude/skills/canon-rollout/SKILL.md +1 -1
- package/claude/skills/canon-screencast/SKILL.md +5 -5
- package/claude/skills/canon-slides-draft/SKILL.md +1 -1
- package/claude/skills/claude-address-review/SKILL.md +4 -4
- package/claude/skills/claude-autoship/SKILL.md +17 -17
- package/claude/skills/claude-design-extract/SKILL.md +2 -2
- package/claude/skills/claude-diagram/SKILL.md +13 -13
- package/claude/skills/claude-docs/SKILL.md +23 -23
- package/claude/skills/claude-feature/SKILL.md +6 -6
- package/claude/skills/claude-groundwork/SKILL.md +16 -16
- package/claude/skills/claude-intake/SKILL.md +8 -8
- package/claude/skills/claude-intake-answer/SKILL.md +1 -1
- package/claude/skills/claude-markdown-propose/REQUIREMENT.md +2 -2
- package/claude/skills/claude-markdown-propose/SKILL.md +8 -8
- package/claude/skills/claude-markdown-propose/references/format.md +3 -3
- package/claude/skills/claude-memory-capture/SKILL.md +13 -13
- package/claude/skills/claude-memory-review/SKILL.md +28 -28
- package/claude/skills/claude-memory-review/references/receipt-format.md +1 -1
- package/claude/skills/claude-orchestrate/SKILL.md +9 -9
- package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +1 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-parked.md +1 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-poll.md +1 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-resume.md +1 -1
- package/claude/skills/claude-orchestrate/scripts/poll.sh +1 -1
- package/claude/skills/claude-planner/REQUIREMENT.md +1 -1
- package/claude/skills/claude-planner/SKILL.md +6 -6
- package/claude/skills/claude-pr-review/SKILL.md +5 -5
- package/claude/skills/claude-review/SKILL.md +4 -4
- package/claude/skills/claude-seed-sync/SKILL.md +2 -2
- package/claude/skills/claude-tasks/SKILL.md +16 -16
- package/claude/skills/claude-teach/SKILL.md +9 -9
- package/claude/skills/claude-ui-test/SKILL.md +3 -3
- package/claude/skills/claude-ux-audit/SKILL.md +5 -5
- package/claude/skills/claude-ux-measure/SKILL.md +3 -3
- package/claude/skills/claude-worker/SKILL.md +2 -2
- package/claude/skills/claude-worktree/SKILL.md +3 -3
- package/claude/skills/git-issue/SKILL.md +4 -4
- package/claude/skills/git-pr/SKILL.md +6 -6
- package/claude/skills/git-ship/SKILL.md +3 -3
- package/claude/skills/git-split/REQUIREMENT.md +1 -1
- package/claude/skills/git-split/SKILL.md +20 -20
- package/claude/skills/migration-superseded/SKILL.md +4 -4
- package/claude/skills/session-map/SKILL.md +2 -2
- package/claude/skills/session-resume/SKILL.md +8 -8
- package/docs/agents/capture.md +1 -1
- package/docs/agents/commands.md +5 -2
- package/docs/agents/demo.md +1 -1
- package/docs/agents/indexes.md +1 -1
- package/docs/agents/install-and-sync.md +1 -1
- package/docs/agents/intake.md +1 -1
- package/docs/agents/key-changes.md +1 -1
- package/docs/agents/records.md +12 -9
- package/docs/agents/restated.md +2 -2
- package/docs/agents/rule-citations.md +1 -1
- package/docs/agents/sandbox.md +1 -1
- package/docs/agents/tasks.md +6 -6
- package/docs/agents/teach.md +4 -4
- package/docs/ai-workflow.md +26 -19
- package/docs/operating-model.md +3 -3
- package/docs/target-projects.md +22 -2
- package/docs/visual-design-workflow.md +1 -1
- package/governance/rules/claude/555-tasks.md +3 -3
- package/governance/rules/claude/556-groundwork.md +2 -2
- package/governance/rules/claude/557-intake.md +2 -2
- package/governance/rules/claude/558-plan.md +3 -3
- package/governance/rules/claude/559-memory.md +2 -2
- package/governance/rules/claude/560-diagrams.md +3 -3
- package/governance/rules/claude/561-teach.md +2 -2
- package/governance/rules/claude/562-session.md +2 -2
- package/governance/rules/core/035-tasks.md +2 -2
- package/governance/rules/core/045-memory.md +3 -3
- package/governance/rules/core/055-scratch.md +2 -1
- package/governance/rules/core/085-worktrees.md +1 -1
- package/package.json +1 -1
- package/scripts/core/check-ignore-parity.sh +24 -51
- package/scripts/core/install-check.sh +4 -4
- package/scripts/core/snapshot.sh +1 -1
- package/scripts/manage-sandbox.sh +1 -1
- package/scripts/tooling/verify.sh +2 -2
- package/snippets/claude/decision-memo.md +1 -1
- package/snippets/claude/feature-recap.md +1 -1
- package/src/audits/catalog.ts +1 -1
- package/src/claude/cases/all.ts +1 -1
- package/src/claude/seeds.ts +13 -2
- package/src/claude/skills-rank.ts +1 -1
- package/src/cli.ts +4 -4
- package/src/commands/demo.ts +1 -1
- package/src/commands/design.ts +1 -1
- package/src/commands/feedback.ts +5 -5
- package/src/commands/gov.ts +17 -6
- package/src/commands/intake.ts +1 -1
- package/src/commands/migrate.ts +173 -2
- package/src/commands/pr.ts +1 -1
- package/src/commands/records.ts +1 -1
- package/src/commands/serve.ts +2 -2
- package/src/commands/slides.ts +1 -1
- package/src/commands/tasks.ts +5 -5
- package/src/commands/teach.ts +1 -1
- package/src/context/audit.ts +1 -1
- package/src/demo/compile.ts +1 -1
- package/src/gov/citations.ts +1 -1
- package/src/gov/restated.ts +3 -3
- package/src/migrate/apply.ts +67 -1
- package/src/migrate/records.ts +276 -0
- package/src/paths.ts +1 -1
- package/src/pr/paths.ts +1 -1
- package/src/record-root.ts +77 -27
- package/src/records/backup.ts +71 -17
- package/src/records/validate.ts +1 -1
- package/src/sync/layout.ts +11 -2
- package/src/tasks/answers.ts +1 -1
- package/src/tasks/archive.ts +20 -3
- package/standards/diagrams.md +4 -4
- package/standards/glossary.md +1 -1
- package/standards/groundwork.md +3 -3
- package/standards/index.md +2 -2
- package/standards/intake.md +3 -3
- package/standards/memory.md +2 -2
- package/standards/plan.md +3 -3
- package/standards/session.md +2 -2
- package/standards/standard.md +1 -1
- package/standards/tasks.md +9 -9
- package/standards/teach.md +2 -2
- package/standards/versioning.md +1 -1
- package/tooling/base/configs/.husky/post-merge +1 -1
- package/tooling/base/reference.md +1 -1
- package/tooling/claude/manifest.toml +13 -3
- package/tooling/claude/reference.md +28 -15
- package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +8 -8
package/src/migrate/apply.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { $ } from 'bun'
|
|
2
|
-
import { readFile, mkdir, rmdir, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { readFile, mkdir, rename, rmdir, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { dirname, join, relative } from 'node:path'
|
|
4
4
|
import { gitEnv } from '@/git-env'
|
|
5
5
|
import type { RenamePlan, RenameSource } from '@/migrate/plan'
|
|
6
|
+
import { FROM_ROOT as OLD_ROOT, type RecordsPlan } from '@/migrate/records'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* A file whose bytes carry a NUL is read as binary and its content is left
|
|
@@ -33,6 +34,71 @@ export interface ApplyResult {
|
|
|
33
34
|
readonly failed: readonly string[]
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Writes the records move: the citations first, then the folders.
|
|
39
|
+
*
|
|
40
|
+
* The order is the reverse of `applyRename`'s reasoning and lands in the same
|
|
41
|
+
* place. Every folder here is untracked, so a failed move leaves the records
|
|
42
|
+
* where they were and the rewritten citations point at a root nothing has
|
|
43
|
+
* reached yet, which reads as pending. Moving first and failing the rewrite
|
|
44
|
+
* strands the records at a root nothing points at, which reads as lost.
|
|
45
|
+
*
|
|
46
|
+
* A plain rename rather than `git mv`, since none of these paths is tracked and
|
|
47
|
+
* git has no history to carry. The destination root is created once; a rename
|
|
48
|
+
* across filesystems is not handled, because both paths sit inside one project.
|
|
49
|
+
*
|
|
50
|
+
* The folder loop stops on the first failure rather than carrying on. Each move
|
|
51
|
+
* that lands makes the tree more split, and a split tree is what `pushRecords`
|
|
52
|
+
* refuses on, so continuing turns one unmovable folder into a longer list of
|
|
53
|
+
* folders to put back by hand. Stopping does not undo what already moved,
|
|
54
|
+
* which is why the refusal on the push side exists as well.
|
|
55
|
+
*/
|
|
56
|
+
export async function applyRecordsMove(
|
|
57
|
+
root: string,
|
|
58
|
+
plan: RecordsPlan,
|
|
59
|
+
): Promise<ApplyResult> {
|
|
60
|
+
let written = 0
|
|
61
|
+
let moved = 0
|
|
62
|
+
const failed: string[] = []
|
|
63
|
+
|
|
64
|
+
for (const entry of plan.entries) {
|
|
65
|
+
// A rejected write leaves the tree half-rewritten with nothing said, since
|
|
66
|
+
// this runs inside a commander action that would unwind past the report.
|
|
67
|
+
// Reads still resolve through the root fallback, so the recorded failure is
|
|
68
|
+
// what a caller needs rather than a stop.
|
|
69
|
+
const done = await writeFile(join(root, entry.path), entry.text)
|
|
70
|
+
.then(() => true)
|
|
71
|
+
.catch(() => false)
|
|
72
|
+
|
|
73
|
+
if (done) written += 1
|
|
74
|
+
else failed.push(entry.path)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const move of plan.moves) {
|
|
78
|
+
await mkdir(dirname(join(root, move.to)), { recursive: true })
|
|
79
|
+
const done = await rename(join(root, move.from), join(root, move.to))
|
|
80
|
+
.then(() => true)
|
|
81
|
+
.catch(() => false)
|
|
82
|
+
|
|
83
|
+
if (!done) {
|
|
84
|
+
failed.push(move.from)
|
|
85
|
+
break
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
moved += 1
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The old root survives the moves as an empty directory when it held records
|
|
92
|
+
// and nothing else. `rmdir` refuses a directory that still holds anything, so
|
|
93
|
+
// a project keeping its rules, skills, and settings there is untouched and the
|
|
94
|
+
// refusal is ignored rather than reported.
|
|
95
|
+
if (moved > 0) {
|
|
96
|
+
await rmdir(join(root, OLD_ROOT)).catch(() => undefined)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { written, moved, failed }
|
|
100
|
+
}
|
|
101
|
+
|
|
36
102
|
/**
|
|
37
103
|
* Writes the plan. Content lands first and the move follows, so a failed move
|
|
38
104
|
* leaves the rewrite on a path that still exists rather than stranding content
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The move of the gitignored session records from `.claude/` to `.canon/`.
|
|
3
|
+
*
|
|
4
|
+
* Two halves that share one list. The folders themselves are untracked, so
|
|
5
|
+
* relocating them is a filesystem act no commit records, while every tracked
|
|
6
|
+
* file naming one of their paths is a citation that goes stale the moment they
|
|
7
|
+
* land. Running one half without the other leaves a tree whose records are at a
|
|
8
|
+
* root nothing points at, which is why the same verb performs both.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync } from 'node:fs'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { type RecordRoot, RECORD_ENTRIES, spell } from '@/record-root'
|
|
14
|
+
|
|
15
|
+
/** The root the entries below leave, exported so the writer can prune it. */
|
|
16
|
+
export const FROM_ROOT: RecordRoot = '.claude'
|
|
17
|
+
|
|
18
|
+
/** The root they arrive at. */
|
|
19
|
+
const TO_ROOT: RecordRoot = '.canon'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Every entry the move relocates, at the name `.claude/` gave it.
|
|
23
|
+
*
|
|
24
|
+
* The list is `RECORD_ENTRIES`, which `record-root.ts` owns because a seed
|
|
25
|
+
* destination and a superseded-layout report ask the same question. Restating
|
|
26
|
+
* it here would let the sweep and the resolver disagree about what moved.
|
|
27
|
+
*/
|
|
28
|
+
export const MOVED_ENTRIES = RECORD_ENTRIES
|
|
29
|
+
|
|
30
|
+
/** Where an entry sits before the move, relative to the project root. */
|
|
31
|
+
export function sourcePath(entry: string): string {
|
|
32
|
+
return join(FROM_ROOT, entry)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Where it sits after, which is the only place the scratch folder is renamed. */
|
|
36
|
+
export function destinationPath(entry: string): string {
|
|
37
|
+
return join(TO_ROOT, spell(TO_ROOT, entry))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A citation into a moved entry.
|
|
42
|
+
*
|
|
43
|
+
* The tail rejects a following name character rather than asking for a word
|
|
44
|
+
* boundary, and that is what protects the three retired flat archives without
|
|
45
|
+
* an exception list: `.claude/plans-archive` continues into a `-` and never
|
|
46
|
+
* matches, where `.claude/plans/` and a bare `.claude/plans` both do. A
|
|
47
|
+
* boundary would treat the hyphen as a break and rewrite the archive to a root
|
|
48
|
+
* it never sat under.
|
|
49
|
+
*
|
|
50
|
+
* The alternation is ordered longest first so `README.md` is decided before any
|
|
51
|
+
* shorter entry can claim its prefix, and each entry is escaped because two of
|
|
52
|
+
* them carry a dot.
|
|
53
|
+
*/
|
|
54
|
+
const CITATION = new RegExp(
|
|
55
|
+
`${escape(FROM_ROOT)}/(${[...MOVED_ENTRIES]
|
|
56
|
+
.sort((left, right) => right.length - left.length)
|
|
57
|
+
.map(escape)
|
|
58
|
+
.join('|')})(?![A-Za-z0-9._-])`,
|
|
59
|
+
'g',
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
function escape(value: string): string {
|
|
63
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Files whose content is left alone entirely.
|
|
68
|
+
*
|
|
69
|
+
* The changelog is release history, whose entries record what shipped while the
|
|
70
|
+
* records were still under the old root. An eval result is a transcript of the
|
|
71
|
+
* paths a session actually opened. Rewriting either makes it testify to
|
|
72
|
+
* something that never happened.
|
|
73
|
+
*
|
|
74
|
+
* This module and `src/record-root.ts` are the two sources that state the old
|
|
75
|
+
* root on purpose. Sweeping them turns every citation this expression is built
|
|
76
|
+
* from into its own replacement, leaving a rewriter that maps `.canon/` to
|
|
77
|
+
* `.canon/` and matches nothing.
|
|
78
|
+
*
|
|
79
|
+
* A test file is excluded because the fixtures that prove the old root still
|
|
80
|
+
* resolves have to keep building it. Rewriting one is worse than a failing
|
|
81
|
+
* test: the old-root case collapses into a copy of the new-root case beside it
|
|
82
|
+
* and keeps passing, reporting coverage for a fallback nothing exercises.
|
|
83
|
+
*
|
|
84
|
+
* A hook is the third source that states both roots on purpose. Each one guards
|
|
85
|
+
* on a `case` carrying an arm per root, so rewriting the old arm collapses the
|
|
86
|
+
* pair into two copies of the new one and shellcheck reports a pattern that can
|
|
87
|
+
* never match. The guard then stops firing in a project the move has not
|
|
88
|
+
* reached, which is silent: the index goes stale while every save succeeds.
|
|
89
|
+
*/
|
|
90
|
+
const EXCLUDED_PREFIXES: readonly string[] = [
|
|
91
|
+
'src/migrate/',
|
|
92
|
+
'scripts/eval/result-',
|
|
93
|
+
'.claude/hooks/',
|
|
94
|
+
'tooling/claude/seeds/.claude/hooks/',
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
const EXCLUDED_PATHS: readonly string[] = ['CHANGELOG.md', 'src/record-root.ts']
|
|
98
|
+
|
|
99
|
+
const EXCLUDED_SUFFIXES: readonly string[] = ['.test.ts']
|
|
100
|
+
|
|
101
|
+
export function isExcludedPath(path: string): boolean {
|
|
102
|
+
if (EXCLUDED_PATHS.includes(path)) return true
|
|
103
|
+
if (EXCLUDED_PREFIXES.some((prefix) => path.startsWith(prefix))) return true
|
|
104
|
+
return EXCLUDED_SUFFIXES.some((suffix) => path.endsWith(suffix))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Marks a line naming the old root on purpose.
|
|
109
|
+
*
|
|
110
|
+
* Prose that dates a decision, records where a defect landed, or names the
|
|
111
|
+
* fallback a target still resolves through all have to keep saying `.claude/`,
|
|
112
|
+
* and a sweep cannot tell those from a live path. The marker sits on the line
|
|
113
|
+
* itself or on the one above it, which is the placement `canon-keep-retired`
|
|
114
|
+
* and `canon-allow-superseded` already use here.
|
|
115
|
+
*/
|
|
116
|
+
const KEEP_MARKER = 'canon-keep-record-root'
|
|
117
|
+
|
|
118
|
+
function isKept(lines: readonly string[], index: number): boolean {
|
|
119
|
+
if (lines[index]?.includes(KEEP_MARKER)) return true
|
|
120
|
+
return index > 0 && (lines[index - 1]?.includes(KEEP_MARKER) ?? false)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Rewrites every unmarked citation into a moved entry. */
|
|
124
|
+
export function rewriteText(text: string): string {
|
|
125
|
+
const lines = text.split('\n')
|
|
126
|
+
|
|
127
|
+
return lines
|
|
128
|
+
.map((line, index) =>
|
|
129
|
+
isKept(lines, index)
|
|
130
|
+
? line
|
|
131
|
+
: line.replace(CITATION, (_match, entry: string) =>
|
|
132
|
+
destinationPath(entry),
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
.join('\n')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* How many citations `rewriteText` would rewrite, and how many marked lines it
|
|
140
|
+
* left alone. The second number is what says the markers fired at all, which a
|
|
141
|
+
* diff cannot show because a protected line does not appear in one.
|
|
142
|
+
*/
|
|
143
|
+
export function scanText(text: string): {
|
|
144
|
+
readonly rewritten: number
|
|
145
|
+
readonly kept: number
|
|
146
|
+
} {
|
|
147
|
+
const lines = text.split('\n')
|
|
148
|
+
let rewritten = 0
|
|
149
|
+
let kept = 0
|
|
150
|
+
|
|
151
|
+
for (const [index, line] of lines.entries()) {
|
|
152
|
+
const matches = [...line.matchAll(CITATION)].length
|
|
153
|
+
if (matches === 0) continue
|
|
154
|
+
|
|
155
|
+
if (isKept(lines, index)) kept += matches
|
|
156
|
+
else rewritten += matches
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { rewritten, kept }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface FolderMove {
|
|
163
|
+
readonly from: string
|
|
164
|
+
readonly to: string
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The entries actually on disk under the old root, with where each lands.
|
|
169
|
+
*
|
|
170
|
+
* An entry already present at the destination is reported as a collision by the
|
|
171
|
+
* caller rather than filtered out here, since merging two record folders is a
|
|
172
|
+
* judgment no verb should take on a memory pen.
|
|
173
|
+
*/
|
|
174
|
+
export function planFolderMoves(root: string): FolderMove[] {
|
|
175
|
+
return MOVED_ENTRIES.filter((entry) =>
|
|
176
|
+
existsSync(join(root, sourcePath(entry))),
|
|
177
|
+
).map((entry) => ({ from: sourcePath(entry), to: destinationPath(entry) }))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Moves whose destination is already taken, which the verb refuses on. */
|
|
181
|
+
export function collisions(
|
|
182
|
+
root: string,
|
|
183
|
+
moves: readonly FolderMove[],
|
|
184
|
+
): string[] {
|
|
185
|
+
return moves
|
|
186
|
+
.filter((move) => existsSync(join(root, move.to)))
|
|
187
|
+
.map((move) => move.to)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Whether a project's ignore rules already cover the new root.
|
|
192
|
+
*
|
|
193
|
+
* The gate exists because every entry being moved is ignored where it stands.
|
|
194
|
+
* Landing them under a root the project does not ignore publishes the memory
|
|
195
|
+
* pen and the groundwork trails into the next commit, which is the same harm
|
|
196
|
+
* the records remote gate refuses for the same payload. Reading the file rather
|
|
197
|
+
* than asking git keeps the answer available in a project with no commits yet.
|
|
198
|
+
*/
|
|
199
|
+
export function ignoresDestination(gitignore: string): boolean {
|
|
200
|
+
return gitignore
|
|
201
|
+
.split('\n')
|
|
202
|
+
.map((line) => line.trim())
|
|
203
|
+
.some((line) => line === TO_ROOT || line === `${TO_ROOT}/`)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** One tracked file, as the planner reads it. */
|
|
207
|
+
export interface RecordsSource {
|
|
208
|
+
readonly path: string
|
|
209
|
+
readonly text: string
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** One file whose citations move, carried with the text to write back. */
|
|
213
|
+
export interface CitationEntry {
|
|
214
|
+
readonly path: string
|
|
215
|
+
readonly text: string
|
|
216
|
+
readonly rewritten: number
|
|
217
|
+
readonly kept: number
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface RecordsPlan {
|
|
221
|
+
readonly moves: readonly FolderMove[]
|
|
222
|
+
readonly collisions: readonly string[]
|
|
223
|
+
readonly entries: readonly CitationEntry[]
|
|
224
|
+
readonly excluded: readonly string[]
|
|
225
|
+
readonly rewritten: number
|
|
226
|
+
readonly kept: number
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* What the move would do, without doing it.
|
|
231
|
+
*
|
|
232
|
+
* The folder half reads disk and the citation half is pure over the sources it
|
|
233
|
+
* is handed, so a caller can report the whole plan and apply it from the same
|
|
234
|
+
* value. A file whose text does not change is dropped rather than carried as a
|
|
235
|
+
* no-op, which keeps the reported file count equal to what the sweep writes.
|
|
236
|
+
*/
|
|
237
|
+
export function planRecordsMove(
|
|
238
|
+
root: string,
|
|
239
|
+
sources: readonly RecordsSource[],
|
|
240
|
+
): RecordsPlan {
|
|
241
|
+
const moves = planFolderMoves(root)
|
|
242
|
+
const entries: CitationEntry[] = []
|
|
243
|
+
const excluded: string[] = []
|
|
244
|
+
let kept = 0
|
|
245
|
+
|
|
246
|
+
for (const source of sources) {
|
|
247
|
+
if (isExcludedPath(source.path)) {
|
|
248
|
+
// Only an excluded file that actually carries a citation is reported. The
|
|
249
|
+
// predicate covers every test file in the tree, so counting them all would
|
|
250
|
+
// report hundreds of exclusions the sweep was never going to touch and
|
|
251
|
+
// bury the handful a reader has to go and check by hand.
|
|
252
|
+
if (scanText(source.text).rewritten > 0) excluded.push(source.path)
|
|
253
|
+
continue
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const counts = scanText(source.text)
|
|
257
|
+
kept += counts.kept
|
|
258
|
+
if (counts.rewritten === 0) continue
|
|
259
|
+
|
|
260
|
+
entries.push({
|
|
261
|
+
path: source.path,
|
|
262
|
+
text: rewriteText(source.text),
|
|
263
|
+
rewritten: counts.rewritten,
|
|
264
|
+
kept: counts.kept,
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
moves,
|
|
270
|
+
collisions: collisions(root, moves),
|
|
271
|
+
entries,
|
|
272
|
+
excluded,
|
|
273
|
+
rewritten: entries.reduce((sum, entry) => sum + entry.rewritten, 0),
|
|
274
|
+
kept,
|
|
275
|
+
}
|
|
276
|
+
}
|
package/src/paths.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { sep } from 'node:path'
|
|
|
3
3
|
/**
|
|
4
4
|
* Whether a resolved path is the directory itself or sits inside it. The
|
|
5
5
|
* separator guard is the whole of it: a bare prefix test reads
|
|
6
|
-
* `.claude/plans-archive` as living under `.
|
|
6
|
+
* `.claude/plans-archive` as living under `.canon/plans`, which is a sibling
|
|
7
7
|
* rather than a child and is exactly the pair the plan folders spell.
|
|
8
8
|
*
|
|
9
9
|
* It lives at the root rather than beside either caller because both resolve
|
package/src/pr/paths.ts
CHANGED
|
@@ -66,7 +66,7 @@ const LINE_SUFFIX = /:\d+(?:-\d+)?$/
|
|
|
66
66
|
* Whitespace separates a backticked command from a backticked path, and it is
|
|
67
67
|
* the whole answer to one of the four observed false-positive classes:
|
|
68
68
|
* `canon markdown audit .claude/rules --json` carries a slash and names no file.
|
|
69
|
-
* Angle brackets answer a second, since `.
|
|
69
|
+
* Angle brackets answer a second, since `.canon/plans/feature-<slug>.md`
|
|
70
70
|
* describes a shape rather than naming a file. A glob and a caret describe a
|
|
71
71
|
* shape too, `^src/` being a grep pattern one body spelled in Key Changes, and
|
|
72
72
|
* a leading anchor names something outside this repository.
|
package/src/record-root.ts
CHANGED
|
@@ -5,44 +5,86 @@ import { join } from 'node:path'
|
|
|
5
5
|
* The roots a session record folder is read at, in precedence order.
|
|
6
6
|
*
|
|
7
7
|
* `.canon/` wins because a tree that carries it has been migrated, and reading
|
|
8
|
-
* `.claude/` there would answer from the copy the move left behind.
|
|
9
|
-
*
|
|
10
|
-
* until
|
|
8
|
+
* `.claude/` there would answer from the copy the move left behind. The second
|
|
9
|
+
* spelling is what an unmigrated project still resolves through, so it stays
|
|
10
|
+
* until no target reaches one, which nothing measures.
|
|
11
11
|
*
|
|
12
12
|
* The shape is `readStamp`'s: order the spellings, take the first that exists,
|
|
13
13
|
* and stand the creation default in when none does.
|
|
14
14
|
*/
|
|
15
|
+
// canon-keep-record-root
|
|
15
16
|
export const RECORD_ROOTS = ['.canon', '.claude'] as const
|
|
16
17
|
|
|
17
18
|
export type RecordRoot = (typeof RECORD_ROOTS)[number]
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
|
-
* The root a record folder is created at.
|
|
21
|
+
* The root a record folder is created at when no root carries it yet.
|
|
21
22
|
*
|
|
22
|
-
* It
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
* It agrees with the head of the read precedence, which is what the move
|
|
24
|
+
* changed. While the two disagreed, creation stayed at `.claude/` so a record
|
|
25
|
+
* could not land under a root whose ignore line had yet to reach the project.
|
|
26
|
+
* The ignore line ships now, so a fresh project scaffolds one root and an
|
|
27
|
+
* unmigrated one keeps resolving its own through the fallback above.
|
|
27
28
|
*/
|
|
28
|
-
export const CREATION_ROOT: RecordRoot = '.
|
|
29
|
+
export const CREATION_ROOT: RecordRoot = '.canon'
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* The deletable scratch folder, named at the spelling `.claude/` gives it.
|
|
32
33
|
*
|
|
33
|
-
* It is the one folder whose name differs by root
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
34
|
+
* It is the one folder whose name differs by root, and callers name it at the
|
|
35
|
+
* old spelling because `spell` is the only place the variant is decided. Inside
|
|
36
|
+
* a dotted root the leading dot hides nothing already hidden and costs a bare
|
|
37
|
+
* `ls` that omits the folder, so the move dropped it. Every other record folder
|
|
38
|
+
* keeps its name, `.records.git` included, where the dot marks the mechanism
|
|
39
|
+
* apart from a payload rather than hiding it.
|
|
38
40
|
*/
|
|
39
41
|
export const SCRATCH = '.tmp'
|
|
40
42
|
|
|
41
43
|
/** The scratch folder's name under `.canon/`. */
|
|
42
44
|
const CANON_SCRATCH = 'tmp'
|
|
43
45
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Every entry that lives under the record root, at the name `.claude/` gave it.
|
|
48
|
+
*
|
|
49
|
+
* These are the twelve ignore patterns the move to `.canon/` collapsed into one,
|
|
50
|
+
* so the list counts entries rather than record folders: `.records.git` is the
|
|
51
|
+
* backup history rather than a record, and `README.md` is a file a records pull
|
|
52
|
+
* writes back. `worktrees` is absent because the harness creates a worktree
|
|
53
|
+
* under `.claude/` and requires its target to sit there.
|
|
54
|
+
*
|
|
55
|
+
* Everything absent from this list is committed and stays where it is, which is
|
|
56
|
+
* the rule the move ran on. `context`, `rules`, `skills`, `hooks`, `wireframes`,
|
|
57
|
+
* and the loose documents at the root are all in that set, which is why a seed
|
|
58
|
+
* and a superseded-layout report each ask this rather than assuming a root.
|
|
59
|
+
*/
|
|
60
|
+
export const RECORD_ENTRIES: readonly string[] = [
|
|
61
|
+
'.records.git',
|
|
62
|
+
SCRATCH,
|
|
63
|
+
'README.md',
|
|
64
|
+
'diagrams',
|
|
65
|
+
'groundwork',
|
|
66
|
+
'intake',
|
|
67
|
+
'memory',
|
|
68
|
+
'plans',
|
|
69
|
+
'proposals',
|
|
70
|
+
'review',
|
|
71
|
+
'tasks',
|
|
72
|
+
'teach',
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
/** Whether a name under `.claude/` is one the record root owns. */
|
|
76
|
+
export function isRecordEntry(name: string): boolean {
|
|
77
|
+
return RECORD_ENTRIES.includes(name)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* How a root spells a folder name. Only the scratch folder differs.
|
|
82
|
+
*
|
|
83
|
+
* Exported because the migration verb has to name a folder's destination and
|
|
84
|
+
* deriving it there would state the one naming variant in a second place, where
|
|
85
|
+
* a tree half-moved by one rule and read by the other resolves nothing.
|
|
86
|
+
*/
|
|
87
|
+
export function spell(root: RecordRoot, folder: string): string {
|
|
46
88
|
return root === '.canon' && folder === SCRATCH ? CANON_SCRATCH : folder
|
|
47
89
|
}
|
|
48
90
|
|
|
@@ -98,21 +140,29 @@ export function recordDirs(
|
|
|
98
140
|
)
|
|
99
141
|
}
|
|
100
142
|
|
|
101
|
-
/**
|
|
102
|
-
|
|
143
|
+
/**
|
|
144
|
+
* The creation destination relative to the project root, which is the form a
|
|
145
|
+
* message displays and an option default carries.
|
|
146
|
+
*
|
|
147
|
+
* It resolves the root the same way a read does rather than pinning the
|
|
148
|
+
* creation default, because the two stopped disagreeing when the move flipped
|
|
149
|
+
* `CREATION_ROOT`. Pinning it now would write a new record to `.canon/` in a
|
|
150
|
+
* project whose board is still `.claude/`, which splits one project's records
|
|
151
|
+
* across two roots and leaves every reader answering from the half the writer
|
|
152
|
+
* did not use.
|
|
153
|
+
*
|
|
154
|
+
* The root is a parameter rather than the working directory, because two of the
|
|
155
|
+
* callers spell a path they later join onto a target root. Resolving against
|
|
156
|
+
* the working directory there would read one project to answer about another.
|
|
157
|
+
*/
|
|
158
|
+
export function creationRel(
|
|
103
159
|
root: string,
|
|
104
160
|
folder: string,
|
|
105
161
|
...rest: string[]
|
|
106
162
|
): string {
|
|
107
|
-
|
|
108
|
-
}
|
|
163
|
+
const at = rootOf(root, folder)
|
|
109
164
|
|
|
110
|
-
|
|
111
|
-
* The creation destination relative to the project root, which is the form a
|
|
112
|
-
* message displays and an option default carries.
|
|
113
|
-
*/
|
|
114
|
-
export function creationRel(folder: string, ...rest: string[]): string {
|
|
115
|
-
return join(CREATION_ROOT, spell(CREATION_ROOT, folder), ...rest)
|
|
165
|
+
return join(at, spell(at, folder), ...rest)
|
|
116
166
|
}
|
|
117
167
|
|
|
118
168
|
/**
|
package/src/records/backup.ts
CHANGED
|
@@ -2,28 +2,29 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
import { join, relative, resolve } from 'node:path'
|
|
3
3
|
import { $ } from 'bun'
|
|
4
4
|
import { gitEnv } from '@/git-env'
|
|
5
|
-
import { recordRoot } from '@/record-root'
|
|
5
|
+
import { RECORD_ROOTS, recordRoot } from '@/record-root'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* The folders a backup carries, relative to the record root `workTree` resolves
|
|
9
|
-
* rather than to
|
|
10
|
-
* whichever
|
|
11
|
-
* manifest ships, minus three: the scratch folder, which is defined as deletable
|
|
12
|
-
* without loss, `worktrees/`, whose contents belong to the enclosing repository
|
|
13
|
-
* already, and `.records.git/`, which is the history the rest are pushed into.
|
|
14
|
-
* The list is spelled out rather than read off that group so adding an ignore
|
|
15
|
-
* entry cannot silently enlarge the payload.
|
|
9
|
+
* rather than to either root specifically, since the same nine names sit under
|
|
10
|
+
* whichever one a tree holds.
|
|
16
11
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
12
|
+
* Nothing bounds this list any more, and the move is what took the bound away.
|
|
13
|
+
* The claude manifest used to ship a folder apiece, so the `# Claude` group
|
|
14
|
+
* named a superset this could be read against; it ships one `.canon/` root
|
|
15
|
+
* entry now and names no folder at all. Spelling the list out is therefore the
|
|
16
|
+
* whole of the protection rather than half of it, since a record folder added
|
|
17
|
+
* under `.canon/` is ignored the moment it exists and enters no payload until
|
|
18
|
+
* a name is written here.
|
|
23
19
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
20
|
+
* Three counts describe this surface and each is right about a different
|
|
21
|
+
* question, so they are stated apart rather than reconciled. Nine is what a
|
|
22
|
+
* disk loss would take, which is this list. Eleven is what sat under `.claude/`
|
|
23
|
+
* as an ignored folder before the move, which adds the scratch folder that is
|
|
24
|
+
* deletable without loss and `worktrees/`, whose contents belong to the
|
|
25
|
+
* enclosing repository already. Twelve is what the move relocated, which counts
|
|
26
|
+
* ignore entries rather than folders: the eleven less `worktrees/`, which stayed,
|
|
27
|
+
* plus `.records.git/` and the `README.md` a records pull writes back.
|
|
27
28
|
*
|
|
28
29
|
* Each entry is a top-level record folder and every archive sits inside the one
|
|
29
30
|
* it archives, so the three former archive entries are covered by their parents
|
|
@@ -103,6 +104,7 @@ const COMMIT_IDENTITY = [
|
|
|
103
104
|
]
|
|
104
105
|
|
|
105
106
|
export const BACKUP_REFUSALS = [
|
|
107
|
+
'split-roots',
|
|
106
108
|
'no-repository',
|
|
107
109
|
'no-remote',
|
|
108
110
|
'remote-unreadable',
|
|
@@ -297,6 +299,52 @@ async function resolveRemote(root: string): Promise<string | BackupRefused> {
|
|
|
297
299
|
return url
|
|
298
300
|
}
|
|
299
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Backed folders sitting at a record root other than the one `workTree` chose.
|
|
304
|
+
*
|
|
305
|
+
* A half-migrated tree is the case this reads. `recordRoot` answers for the
|
|
306
|
+
* whole tree on the first root that exists, so one folder left behind by a move
|
|
307
|
+
* that failed partway is absent from the work tree while the records index
|
|
308
|
+
* still names it. `scopedFolders` puts it in the pathspec on the index side,
|
|
309
|
+
* `add -A` stages its deletion, and the push drops it from the remote, which is
|
|
310
|
+
* the harm `RETIRED_FOLDERS` documents reached by a different route.
|
|
311
|
+
*
|
|
312
|
+
* Both verbs refuse on it rather than only `push`. A `pull` resets the resolved
|
|
313
|
+
* work tree hard and leaves the stranded copy beside it, which is not a loss but
|
|
314
|
+
* is a tree where two roots disagree and neither is wrong.
|
|
315
|
+
*
|
|
316
|
+
* Only `BACKED_FOLDERS` is read. The scratch folder is deletable by definition
|
|
317
|
+
* and `worktrees/` belongs to the enclosing repository, so neither stranded
|
|
318
|
+
* anywhere costs a record.
|
|
319
|
+
*/
|
|
320
|
+
function strandedFolders(root: string): string[] {
|
|
321
|
+
const resolved = resolve(workTree(root))
|
|
322
|
+
|
|
323
|
+
return RECORD_ROOTS.flatMap((candidate) => {
|
|
324
|
+
const dir = join(root, candidate)
|
|
325
|
+
if (resolve(dir) === resolved) return []
|
|
326
|
+
|
|
327
|
+
return BACKED_FOLDERS.filter((folder) => existsSync(join(dir, folder))).map(
|
|
328
|
+
(folder) => join(candidate, folder),
|
|
329
|
+
)
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Refuses a tree whose records sit under both roots, naming what to move. */
|
|
334
|
+
function refuseSplitRoots(root: string): BackupRefused | undefined {
|
|
335
|
+
const stranded = strandedFolders(root)
|
|
336
|
+
if (stranded.length === 0) return undefined
|
|
337
|
+
|
|
338
|
+
return refuse(
|
|
339
|
+
'split-roots',
|
|
340
|
+
[
|
|
341
|
+
`Records sit under both roots, so ${relative(root, workTree(root))} is not the whole set and a push would stage the rest as deleted:`,
|
|
342
|
+
...stranded.map((path) => ` ${path}`),
|
|
343
|
+
'Finish the move with canon migrate records --write, or put these back beside the others by hand.',
|
|
344
|
+
].join('\n'),
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
|
|
300
348
|
/**
|
|
301
349
|
* The subset of the backed and retired names a pathspec can name: on disk, or
|
|
302
350
|
* already in the records index.
|
|
@@ -345,6 +393,9 @@ function countLines(text: string): number {
|
|
|
345
393
|
* that commit on one disk, which is the state the whole verb exists to end.
|
|
346
394
|
*/
|
|
347
395
|
export async function pushRecords(root: string): Promise<PushOutcome> {
|
|
396
|
+
const split = refuseSplitRoots(root)
|
|
397
|
+
if (split) return split
|
|
398
|
+
|
|
348
399
|
const remote = await resolveRemote(root)
|
|
349
400
|
if (typeof remote !== 'string') return remote
|
|
350
401
|
|
|
@@ -406,6 +457,9 @@ export async function pushRecords(root: string): Promise<PushOutcome> {
|
|
|
406
457
|
* resolves by pushing first or by moving the local folders aside.
|
|
407
458
|
*/
|
|
408
459
|
export async function pullRecords(root: string): Promise<PullOutcome> {
|
|
460
|
+
const split = refuseSplitRoots(root)
|
|
461
|
+
if (split) return split
|
|
462
|
+
|
|
409
463
|
const remote = await resolveRemote(root)
|
|
410
464
|
if (typeof remote !== 'string') return remote
|
|
411
465
|
|
package/src/records/validate.ts
CHANGED
|
@@ -977,7 +977,7 @@ export function governedPaths(statement: string): string[] {
|
|
|
977
977
|
/**
|
|
978
978
|
* The words a governed path offers a filename. Each segment gives its own word
|
|
979
979
|
* and, where it carries a prefix or a placeholder, the parts either side of a
|
|
980
|
-
* hyphen, so `.
|
|
980
|
+
* hyphen, so `.canon/tasks/session-<slug>.md` offers `tasks` and `session`.
|
|
981
981
|
*
|
|
982
982
|
* A dotted segment gives nothing. It names the folder holding the artifact
|
|
983
983
|
* rather than the artifact, and a standard named for it would pass this check
|