@erclx/canon 4.6.1 → 4.8.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 +17 -11
- package/docs/agents/commands.md +8 -5
- package/docs/agents/demo.md +2 -2
- package/docs/agents/driver.md +2 -2
- package/docs/agents/index.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/state-scoped-risk.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 -2
- package/scripts/core/check-ignore-parity.sh +24 -51
- package/scripts/core/install-check.sh +4 -4
- package/scripts/core/regen-hero.sh +6 -3
- 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/capture/render.ts +166 -0
- package/src/capture/sources.ts +42 -0
- package/src/capture/stamp.ts +52 -0
- 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 +5 -5
- package/src/commands/capture.ts +52 -41
- 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/demo/drive.ts +5 -8
- package/src/driver/drive.ts +7 -8
- package/src/gate/measures.ts +1 -1
- package/src/gate/stages.ts +1 -1
- package/src/gov/citations.ts +1 -1
- package/src/gov/restated.ts +3 -3
- package/src/inventory/walk.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/gov/restated.ts
CHANGED
|
@@ -43,7 +43,7 @@ const MIRRORS: readonly (readonly [string, string])[] = [
|
|
|
43
43
|
*
|
|
44
44
|
* Under one percent of the 2750 statements this repository offers. Tuned
|
|
45
45
|
* against that corpus rather than reasoned to, which is what the plan asked of
|
|
46
|
-
* the first run: `.
|
|
46
|
+
* the first run: `.canon/plans/` sits at 14 and is the anchor the motivating
|
|
47
47
|
* case turns on, while `file` sits at 371 and matches most of the tree.
|
|
48
48
|
*/
|
|
49
49
|
export const COMMON_CEILING = 20
|
|
@@ -55,7 +55,7 @@ export const ANCHOR_FLOOR = 3
|
|
|
55
55
|
* What a backticked token is worth against a plain word.
|
|
56
56
|
*
|
|
57
57
|
* An author marking a span as code named an identifier rather than describing
|
|
58
|
-
* one, so `.
|
|
58
|
+
* one, so `.canon/plans/archive/` says more about what a statement governs
|
|
59
59
|
* than any two prose words do. Weighting it is what lets the floor rise high
|
|
60
60
|
* enough to drop a coincidental word pair without losing a rule two surfaces
|
|
61
61
|
* spelled entirely differently around one shared path.
|
|
@@ -267,7 +267,7 @@ interface Indexed<T extends Statement> {
|
|
|
267
267
|
/**
|
|
268
268
|
* Splits text into the tokens an anchor can be drawn from.
|
|
269
269
|
*
|
|
270
|
-
* A code span keeps its inner text whole, since `.
|
|
270
|
+
* A code span keeps its inner text whole, since `.canon/plans/archive/` is the
|
|
271
271
|
* strongest anchor this corpus offers and splitting it on the punctuation would
|
|
272
272
|
* leave three words every second bullet also carries.
|
|
273
273
|
*/
|
package/src/inventory/walk.ts
CHANGED
|
@@ -15,9 +15,9 @@ import type { Subject } from '@/inventory/subjects'
|
|
|
15
15
|
* and `src/commands/inventory.ts` reaches it through a dynamic import so no
|
|
16
16
|
* other command resolves the engine at startup.
|
|
17
17
|
*
|
|
18
|
-
* Like `@/demo/drive
|
|
19
|
-
* a command whose whole purpose is running inside someone else's
|
|
20
|
-
* stay toolkit-only.
|
|
18
|
+
* Like `@/demo/drive`, `@/driver/drive`, and `@/capture/render`, this module
|
|
19
|
+
* ships, because a command whose whole purpose is running inside someone else's
|
|
20
|
+
* project cannot stay toolkit-only.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
/**
|
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
|
/**
|