@erclx/canon 4.72.1 → 4.73.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/session-relay/REQUIREMENT.md +2 -0
- package/claude/skills/session-relay/SKILL.md +1 -0
- package/docs/agents/install-and-sync.md +3 -2
- package/docs/target-projects.md +1 -1
- package/governance/rules/core/091-channel.md +13 -0
- package/package.json +1 -1
- package/src/claude/skills-reach.ts +13 -3
- package/src/commands/context.ts +7 -4
- package/src/commands/design.ts +6 -1
- package/src/context/architecture.ts +15 -8
- package/src/context/citations.ts +31 -6
- package/src/context/folders.ts +27 -8
- package/src/gate/measures.ts +17 -8
- package/src/migrate/records.ts +11 -3
- package/src/surface-root.ts +101 -0
- package/src/sync/stamp.ts +20 -5
|
@@ -18,6 +18,7 @@ Neither attempt named who the relay is from. A message an operator relays by han
|
|
|
18
18
|
## Must
|
|
19
19
|
|
|
20
20
|
- Fire from inside `role-worker` or `role-planner`'s own `## The channel` section, at the point that section finds no message-sending tool
|
|
21
|
+
- Refuse a caller holding neither role, checked ahead of the tool-availability guard
|
|
21
22
|
- Read the sender's own name off `canon sessions list --self --json` before composing anything, and degrade to naming itself from the environment when the installed CLI answers `--self` with an unknown option rather than a refusal carrying a reason
|
|
22
23
|
- Resolve the addressee by running the calling role's own ladder rather than a second one
|
|
23
24
|
- Carry the message body the calling role's bullet already names, verbatim
|
|
@@ -32,6 +33,7 @@ Neither attempt named who the relay is from. A message an operator relays by han
|
|
|
32
33
|
|
|
33
34
|
## Guards
|
|
34
35
|
|
|
36
|
+
- The calling session holds neither role: refuse, and state the addressing and timing it owes instead, since the plugin skill it would otherwise defer to reaches a target the moment it merges while a governance rule reaches one only through a separate install. Checked first, since the tool guard read alone let the incident behind this row stop at "you have a tool" without ever learning steps 2 and 3 were unreachable regardless.
|
|
35
37
|
- A message-sending tool is available: refuse, name it, and send through it instead
|
|
36
38
|
|
|
37
39
|
## Out of scope
|
|
@@ -11,6 +11,7 @@ Read the calling body's `## The channel` section for the message owed and the la
|
|
|
11
11
|
|
|
12
12
|
## Guards
|
|
13
13
|
|
|
14
|
+
- Refuse to fire when the calling session holds neither `role-worker` nor `role-planner`. Say so, and state what it owes instead: resolve the addressee at send time through `canon sessions list --json`, never by name prefix, and send a block out as a message before it becomes an interactive prompt, leaving the message content to whatever dispatched it. Checked first, since a caller that never held either role never had a ladder here to run, and the tool guard below reads as the whole reason only once this one has cleared.
|
|
14
15
|
- Refuse to fire when a message-sending tool is available. Say so, name the tool, and send through it. This skill exists for the gap, never as an alternative to the ordinary channel.
|
|
15
16
|
|
|
16
17
|
## Steps
|
|
@@ -158,8 +158,9 @@ Use `--json` for the machine-readable report and `--exit-code` to fail a CI job.
|
|
|
158
158
|
and missing files are both excluded from that exit code: a project-authored
|
|
159
159
|
rule never converges, and a sync that added a missing one silently changes
|
|
160
160
|
what the project is governed by, which stays a separate command an operator
|
|
161
|
-
chooses to run. Attribution reads
|
|
162
|
-
install and sync writes
|
|
161
|
+
chooses to run. Attribution reads `canon/config/config.json` first and falls
|
|
162
|
+
back to `.claude/canon/config.json`, which every install and sync still writes
|
|
163
|
+
in this release.
|
|
163
164
|
|
|
164
165
|
A target installed before stamping shipped has no such file, and neither does
|
|
165
166
|
that fallback do anything to migrate it. A target stamped before the file
|
package/docs/target-projects.md
CHANGED
|
@@ -204,7 +204,7 @@ The report opens by naming the binary running it. The installed version reads ag
|
|
|
204
204
|
|
|
205
205
|
A `stale` file still matches what the toolkit installed, so the update is mechanical. A `customized` file carries local edits, so taking the upstream version is a decision and `canon:seed-sync` is the tool for it. A `stranded` file sits where an older toolkit installed it and the toolkit has since moved, which is a relocation the report names but no command runs.
|
|
206
206
|
|
|
207
|
-
That attribution comes from `.claude/canon/config.json`, a stamp every install and sync writes. A target stamped before that path shipped is read from the retired `.claude/canon.json` instead, reported rather than migrated. Governance records a hash per installed file, plus the stack `canon gov install` was given, and tooling records the stack chain it resolved instead of any file hash, since its install runs no per-file walk to attribute.
|
|
207
|
+
That attribution comes from `canon/config/config.json` when a project carries it, falling back to `.claude/canon/config.json`, a stamp every install and sync still writes in this release. A target stamped before that path shipped is read from the retired `.claude/canon.json` instead, reported rather than migrated. Governance records a hash per installed file, plus the stack `canon gov install` was given, and tooling records the stack chain it resolved instead of any file hash, since its install runs no per-file walk to attribute.
|
|
208
208
|
|
|
209
209
|
Each domain holds its own toolkit commit, so syncing governance today does not move the revision tooling measures against, and each domain reports the upstream commits touching its own source path. Running any sync stamps that domain, and the report names the ones still unstamped.
|
|
210
210
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: The addressing and timing a session holding neither role-worker nor role-planner owes when it must message whoever dispatched it
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Channel standards
|
|
6
|
+
|
|
7
|
+
## A session holding neither role
|
|
8
|
+
|
|
9
|
+
- Resolve the addressee at send time through `canon sessions list --json`, keyed on the id or branch the dispatcher named at launch, never by name prefix. A name is derived from what a session turns out to be doing and goes stale before a build finishes.
|
|
10
|
+
- Send a block out as a message before it becomes an interactive prompt. A session already waiting on input never reaches the tool round that drains an inbound message, so an answer relayed afterwards arrives under the open question and changes nothing.
|
|
11
|
+
- Leave the message content, and which transition earns one, to whatever dispatched this session. Both are task-specific and already stated where they apply.
|
|
12
|
+
- A session holding `canon:role-worker` or `canon:role-planner` follows that body's own addressee ladder instead, stated in its own `## The channel` section. This rule states what neither role leaves stated elsewhere, not a replacement for either ladder.
|
|
13
|
+
- Report it rather than proceeding silently when neither skill resolves to check against. Both ship with the plugin and this rule ships with the CLI, so a project that installed governance alone does not have them.
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { resolveSkillsCorpus } from '@/claude/skills-list'
|
|
4
|
+
import { SURFACE_ROOTS } from '@/surface-root'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* The authoring roots this repository owns and no install channel delivers.
|
|
@@ -15,9 +16,14 @@ import { resolveSkillsCorpus } from '@/claude/skills-list'
|
|
|
15
16
|
* report a correct citation on every run and bury the defect this measures.
|
|
16
17
|
* `docs/agents/` is the exception, being the CLI contract pages that exist
|
|
17
18
|
* here alone.
|
|
19
|
+
*
|
|
20
|
+
* `.claude/context/` carries both surface-root spellings, since this list
|
|
21
|
+
* decides whether a shipped body names this repository's own tree and a body
|
|
22
|
+
* naming either root is doing that regardless of which root a given checkout
|
|
23
|
+
* carries.
|
|
18
24
|
*/
|
|
19
25
|
const AUTHORING_ROOTS = [
|
|
20
|
-
|
|
26
|
+
...SURFACE_ROOTS.map((root) => `${root}/context/`),
|
|
21
27
|
'claude/',
|
|
22
28
|
'docs/agents/',
|
|
23
29
|
'governance/',
|
|
@@ -92,9 +98,13 @@ export function isQualified(line: string): boolean {
|
|
|
92
98
|
* by corpus instead.
|
|
93
99
|
*/
|
|
94
100
|
export function authoringRootsFor(corpus: string): readonly string[] {
|
|
95
|
-
if (!corpus.startsWith(
|
|
101
|
+
if (!SURFACE_ROOTS.some((root) => corpus.startsWith(`${root}/`))) {
|
|
102
|
+
return AUTHORING_ROOTS
|
|
103
|
+
}
|
|
96
104
|
|
|
97
|
-
return AUTHORING_ROOTS.filter(
|
|
105
|
+
return AUTHORING_ROOTS.filter(
|
|
106
|
+
(root) => !SURFACE_ROOTS.some((surface) => root.startsWith(`${surface}/`)),
|
|
107
|
+
)
|
|
98
108
|
}
|
|
99
109
|
|
|
100
110
|
/**
|
package/src/commands/context.ts
CHANGED
|
@@ -17,11 +17,11 @@ import {
|
|
|
17
17
|
type SectionFinding,
|
|
18
18
|
} from '@/context/audit'
|
|
19
19
|
import {
|
|
20
|
+
architectureRel,
|
|
20
21
|
type ArchitectureReport,
|
|
21
22
|
coveredCount,
|
|
22
23
|
isOverLength,
|
|
23
24
|
measureArchitecture,
|
|
24
|
-
RECORD_REL,
|
|
25
25
|
testableCount,
|
|
26
26
|
} from '@/context/architecture'
|
|
27
27
|
import { auditCitations, type CitationReport } from '@/context/citations'
|
|
@@ -251,7 +251,7 @@ async function runAudit(
|
|
|
251
251
|
reportProvenance(entries, folders)
|
|
252
252
|
reportNarration(entries, folders, narration)
|
|
253
253
|
reportDrift(drift)
|
|
254
|
-
reportRecord(record)
|
|
254
|
+
reportRecord(record, root)
|
|
255
255
|
outro()
|
|
256
256
|
}
|
|
257
257
|
|
|
@@ -761,12 +761,15 @@ const CLAIM_LABEL: Record<string, string> = {
|
|
|
761
761
|
* anchors it sits beside already do. Nothing is stored: every run reclassifies,
|
|
762
762
|
* so an entry rewritten tomorrow is read as it stands then.
|
|
763
763
|
*/
|
|
764
|
-
function reportRecord(
|
|
764
|
+
function reportRecord(
|
|
765
|
+
report: ArchitectureReport | undefined,
|
|
766
|
+
root: string,
|
|
767
|
+
): void {
|
|
765
768
|
logStep('Architecture record')
|
|
766
769
|
|
|
767
770
|
if (report === undefined) {
|
|
768
771
|
logInfo(
|
|
769
|
-
`Out of scope. The project carries no ${
|
|
772
|
+
`Out of scope. The project carries no ${architectureRel(root)}, so there was no record to measure.`,
|
|
770
773
|
)
|
|
771
774
|
return
|
|
772
775
|
}
|
package/src/commands/design.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { renderDesignDoc } from '@/design/render'
|
|
|
11
11
|
import { DESIGN_BASE_CSS, DESIGN_DOCUMENT, regenDesign } from '@/design/regen'
|
|
12
12
|
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
13
13
|
import { creationRel } from '@/record-root'
|
|
14
|
+
import { surfaceDir } from '@/surface-root'
|
|
14
15
|
import { recordStamp, runDomainSync } from '@/sync/engine'
|
|
15
16
|
import { resolveTarget } from '@/target'
|
|
16
17
|
import { intro, logAdd, logError, logInfo, logWarn, outro, palette } from '@/ui'
|
|
@@ -88,7 +89,11 @@ export function register(program: Command): void {
|
|
|
88
89
|
design
|
|
89
90
|
.command('render')
|
|
90
91
|
.description('Render DESIGN.md tokens to HTML and CSS preview')
|
|
91
|
-
.option(
|
|
92
|
+
.option(
|
|
93
|
+
'-s, --source <path>',
|
|
94
|
+
'Source DESIGN.md path',
|
|
95
|
+
relative(process.cwd(), surfaceDir(process.cwd(), 'DESIGN.md')),
|
|
96
|
+
)
|
|
92
97
|
.option(
|
|
93
98
|
'-o, --out <path>',
|
|
94
99
|
'Output directory',
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { access, readFile } from 'node:fs/promises'
|
|
2
|
-
import { join } from 'node:path'
|
|
2
|
+
import { join, relative } from 'node:path'
|
|
3
3
|
import { AUDITS } from '@/audits/catalog'
|
|
4
4
|
import { bodyLines } from '@/markdown/scan'
|
|
5
|
+
import { surfaceDir } from '@/surface-root'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
|
-
* The record this measures, relative to
|
|
8
|
+
* The record this measures, relative to `root`.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* A function rather than a fixed constant, because the path now resolves at
|
|
11
|
+
* either surface root and a constant naming one of them would be believed to
|
|
12
|
+
* be true of a project that has moved. One fixed name rather than a folder
|
|
13
|
+
* walk otherwise, because the standard governing it names one document, and
|
|
14
|
+
* the length rule this measures is stated by whichever record sits there
|
|
15
|
+
* rather than by the standard or by this file.
|
|
12
16
|
*/
|
|
13
|
-
export
|
|
17
|
+
export function architectureRel(root: string): string {
|
|
18
|
+
return relative(root, surfaceDir(root, 'ARCHITECTURE.md'))
|
|
19
|
+
}
|
|
14
20
|
|
|
15
21
|
/**
|
|
16
22
|
* The line allowances a record states for itself, absent when it states none.
|
|
@@ -299,7 +305,8 @@ export function ceilingFor(allowances: Allowances, decisions: number): number {
|
|
|
299
305
|
export async function measureArchitecture(
|
|
300
306
|
root: string,
|
|
301
307
|
): Promise<ArchitectureReport | undefined> {
|
|
302
|
-
const
|
|
308
|
+
const rel = architectureRel(root)
|
|
309
|
+
const path = join(root, rel)
|
|
303
310
|
|
|
304
311
|
let source: string
|
|
305
312
|
try {
|
|
@@ -330,7 +337,7 @@ export async function measureArchitecture(
|
|
|
330
337
|
)
|
|
331
338
|
|
|
332
339
|
return {
|
|
333
|
-
rel
|
|
340
|
+
rel,
|
|
334
341
|
lines: source.replace(/\n$/, '').split('\n').length,
|
|
335
342
|
...(allowances !== undefined && {
|
|
336
343
|
allowances,
|
package/src/context/citations.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'
|
|
|
3
3
|
import { resolve } from 'node:path'
|
|
4
4
|
import { listRepositoryFiles } from '@/git-files'
|
|
5
5
|
import { RECORD_ROOTS } from '@/record-root'
|
|
6
|
+
import { SURFACE_ROOTS } from '@/surface-root'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Suppresses citation checking for the source line carrying it.
|
|
@@ -78,17 +79,41 @@ export function isFixture(rel: string): boolean {
|
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
82
|
+
* The boundary a root's own leading character rules out.
|
|
83
|
+
*
|
|
84
|
+
* A dotted root cannot be a suffix of a path segment, since a segment boundary
|
|
85
|
+
* is a slash and a slash can never sit inside the dot itself, so a preceding
|
|
86
|
+
* slash is not a false match to guard against and the boundary admits it. That
|
|
87
|
+
* is what a relative link needs, since `../.claude/context/<entry>.md` carries
|
|
88
|
+
* a slash immediately before `.claude`. A bare root has no such protection: it
|
|
89
|
+
* is a suffix of a dotted root's own name and of any `/<root>/` path segment,
|
|
90
|
+
* so its boundary rejects a slash along with a name character or a dot.
|
|
91
|
+
*/
|
|
92
|
+
function rootBoundary(root: string): string {
|
|
93
|
+
return root.startsWith('.') ? '(?<![\\w.])' : '(?<![\\w./])'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Every record and surface root is spelled, so a citation into a folder that
|
|
98
|
+
* has moved is still resolved. A pattern fixed at one root matches nothing
|
|
99
|
+
* after the move and reports nothing, which is a stale reference passing the
|
|
100
|
+
* check that exists to find it rather than a check that fails.
|
|
101
|
+
*
|
|
102
|
+
* Each root carries its own boundary rather than one shared ahead of the whole
|
|
103
|
+
* alternation, per `rootBoundary`. A shared boundary rejecting a slash blocked
|
|
104
|
+
* every dotted root from a relative link, which is the same failure this
|
|
105
|
+
* function's own boundary exists to prevent, aimed at the roots that never
|
|
106
|
+
* needed it.
|
|
85
107
|
*/
|
|
86
108
|
export function citationPattern(folders: readonly string[]): RegExp {
|
|
87
109
|
const names = folders.map((name) => escape(name))
|
|
88
|
-
const roots = RECORD_ROOTS
|
|
110
|
+
const roots = [...new Set([...RECORD_ROOTS, ...SURFACE_ROOTS])]
|
|
111
|
+
const alternatives = roots
|
|
112
|
+
.map((root) => `${rootBoundary(root)}${escape(root)}`)
|
|
113
|
+
.join('|')
|
|
89
114
|
|
|
90
115
|
return new RegExp(
|
|
91
|
-
`(?:${
|
|
116
|
+
`(?:${alternatives})/(?:${names.join('|')})/[A-Za-z0-9._/-]+\\.md`,
|
|
92
117
|
'g',
|
|
93
118
|
)
|
|
94
119
|
}
|
package/src/context/folders.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
import { dirname, relative, resolve } from 'node:path'
|
|
3
3
|
import { INDEX_FILE, listIndexes } from '@/indexes/walk'
|
|
4
4
|
import { RECORD_ROOTS } from '@/record-root'
|
|
5
|
+
import { SURFACE_ROOTS } from '@/surface-root'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Folder names under a record root audited by default.
|
|
@@ -23,16 +24,34 @@ export const DEFAULT_FOLDERS: readonly string[] = [
|
|
|
23
24
|
]
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
|
-
* The bases every folder in the default list is looked for under
|
|
27
|
-
* roots' own precedence order.
|
|
27
|
+
* The bases every folder in the default list is looked for under.
|
|
28
28
|
*
|
|
29
|
-
* `diagrams` is
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
29
|
+
* `diagrams` is a session record and resolves under `RECORD_ROOTS`, while
|
|
30
|
+
* `context` and `wireframes` are tracked and resolve under `SURFACE_ROOTS`, so
|
|
31
|
+
* the split this comment used to describe as hypothetical is real: two
|
|
32
|
+
* folders on this list read from two different root lists. The base array is
|
|
33
|
+
* their union rather than a per-name map, since a name resolving at the wrong
|
|
34
|
+
* root costs one extra `existsSync` and nothing else, where a map states the
|
|
35
|
+
* split a second time next to the one each resolver module already carries.
|
|
36
|
+
*
|
|
37
|
+
* `.claude` is pushed last because both lists name it, and each list's own
|
|
38
|
+
* precedence otherwise survives: `canon` still precedes `.claude` for a
|
|
39
|
+
* `SURFACE_ROOTS` name, and `.canon` still precedes `.claude` for a
|
|
40
|
+
* `RECORD_ROOTS` one. Order between `canon` and `.canon` is unobserved, since
|
|
41
|
+
* no name on this list resolves under both.
|
|
42
|
+
*
|
|
43
|
+
* Adding `canon` here means a target holding a root-level `canon/` folder of
|
|
44
|
+
* its own now resolves it as the toolkit's, since a project writing about a
|
|
45
|
+
* product called canon is a plausible name collision `canResolveAtRoot`'s own
|
|
46
|
+
* project-root gate does not cover. The audit only reports, so the cost is a
|
|
47
|
+
* wrong scope line rather than a wrong edit.
|
|
34
48
|
*/
|
|
35
|
-
const CLAUDE_BASES: readonly string[] =
|
|
49
|
+
const CLAUDE_BASES: readonly string[] = [
|
|
50
|
+
...new Set(
|
|
51
|
+
[...SURFACE_ROOTS, ...RECORD_ROOTS].filter((root) => root !== '.claude'),
|
|
52
|
+
),
|
|
53
|
+
'.claude',
|
|
54
|
+
]
|
|
36
55
|
|
|
37
56
|
/** The project root, reached only by a name the caller asked for. */
|
|
38
57
|
const ROOT_BASE = '.'
|
package/src/gate/measures.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
3
|
-
import { join } from 'node:path'
|
|
3
|
+
import { join, relative } from 'node:path'
|
|
4
4
|
import {
|
|
5
5
|
CLIENT_COMMAND_MARKER,
|
|
6
6
|
CLIENT_COMMANDS,
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
SHIPPED_CORPORA,
|
|
16
16
|
type ShippedReference,
|
|
17
17
|
} from '@/shipped/references'
|
|
18
|
+
import { surfaceDir } from '@/surface-root'
|
|
18
19
|
import {
|
|
19
20
|
README_PARAPHRASE_MARKER,
|
|
20
21
|
readmeCitationsIn,
|
|
@@ -122,11 +123,18 @@ export const SANDBOX_UNDECLARED_CEILING = 47
|
|
|
122
123
|
export const SANDBOX_ASSERTED_FLOOR = 26
|
|
123
124
|
|
|
124
125
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
126
|
+
* Where the retained counts the audit stage compares each run against are
|
|
127
|
+
* read, relative to `root`.
|
|
128
|
+
*
|
|
129
|
+
* A function rather than a spelled constant, since the read now resolves at
|
|
130
|
+
* either surface root and a constant naming one of them would be believed of
|
|
131
|
+
* a project that has moved. `canon audits run` owns writing it, and the write
|
|
132
|
+
* stays at the creation default for this batch, so the two can disagree for
|
|
133
|
+
* exactly the release window `src/surface-root.ts` documents.
|
|
128
134
|
*/
|
|
129
|
-
export
|
|
135
|
+
export function auditsBaselineRel(root: string): string {
|
|
136
|
+
return relative(root, surfaceDir(root, 'canon', 'baseline.json'))
|
|
137
|
+
}
|
|
130
138
|
|
|
131
139
|
export const CAPTURE_STAMP_FAILURE =
|
|
132
140
|
'A capture set disagrees with the stamp written when its image was captured. Run canon capture assets/captures --selector .window --out assets and commit each frame with its image and its stamp.'
|
|
@@ -958,16 +966,17 @@ export const auditSet: Measure = async (ctx) => {
|
|
|
958
966
|
),
|
|
959
967
|
)
|
|
960
968
|
}
|
|
969
|
+
const baselineRel = auditsBaselineRel(ctx.root)
|
|
961
970
|
emissions.push(
|
|
962
971
|
summary.grown > 0
|
|
963
972
|
? warn(
|
|
964
|
-
`${summary.grown} measure(s) grew against ${
|
|
973
|
+
`${summary.grown} measure(s) grew against ${baselineRel}. Run bun src/cli.ts audits run to see which, then fix them or re-record and say why.`,
|
|
965
974
|
)
|
|
966
|
-
: info(`No measure grew against ${
|
|
975
|
+
: info(`No measure grew against ${baselineRel}`),
|
|
967
976
|
)
|
|
968
977
|
if (typeof summary.shrunk === 'number' && summary.shrunk > 0) {
|
|
969
978
|
emissions.push(
|
|
970
|
-
info(`${summary.shrunk} measure(s) fell against ${
|
|
979
|
+
info(`${summary.shrunk} measure(s) fell against ${baselineRel}`),
|
|
971
980
|
)
|
|
972
981
|
}
|
|
973
982
|
|
package/src/migrate/records.ts
CHANGED
|
@@ -76,10 +76,14 @@ function escape(value: string): string {
|
|
|
76
76
|
* paths a session actually opened. Rewriting either makes it testify to
|
|
77
77
|
* something that never happened.
|
|
78
78
|
*
|
|
79
|
-
* This module and `src/record-root.ts` are the
|
|
79
|
+
* This module and `src/record-root.ts` are two of the sources that state the old
|
|
80
80
|
* root on purpose. Sweeping them turns every citation this expression is built
|
|
81
81
|
* from into its own replacement, leaving a rewriter that maps `.canon/` to
|
|
82
|
-
* `.canon/` and matches nothing.
|
|
82
|
+
* `.canon/` and matches nothing. `src/surface-root.ts` joins the list a release
|
|
83
|
+
* early, for the same reason: it spells `.claude` as data the moment it
|
|
84
|
+
* exists, and excluding it later would leave one release where a records
|
|
85
|
+
* migration in a target could rewrite the resolver that migration itself
|
|
86
|
+
* depends on.
|
|
83
87
|
*
|
|
84
88
|
* A test file is excluded because the fixtures that prove the old root still
|
|
85
89
|
* resolves have to keep building it. Rewriting one is worse than a failing
|
|
@@ -106,7 +110,11 @@ const EXCLUDED_PREFIXES: readonly string[] = [
|
|
|
106
110
|
'tooling/claude/seeds/.claude/hooks/',
|
|
107
111
|
]
|
|
108
112
|
|
|
109
|
-
const EXCLUDED_PATHS: readonly string[] = [
|
|
113
|
+
const EXCLUDED_PATHS: readonly string[] = [
|
|
114
|
+
'CHANGELOG.md',
|
|
115
|
+
'src/record-root.ts',
|
|
116
|
+
'src/surface-root.ts',
|
|
117
|
+
]
|
|
110
118
|
|
|
111
119
|
const EXCLUDED_SUFFIXES: readonly string[] = ['.test.ts']
|
|
112
120
|
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The roots a tracked toolkit surface is read at, in precedence order.
|
|
6
|
+
*
|
|
7
|
+
* These are not `RECORD_ROOTS`, and the difference is what each root is for
|
|
8
|
+
* rather than an oversight. A record folder moves into `.canon/` because it is
|
|
9
|
+
* gitignored, so nothing tracked ever lands there. A surface here is committed,
|
|
10
|
+
* so a single shared list would put a record folder under a root a tracked
|
|
11
|
+
* file also resolves against, which is a collision this list never has to
|
|
12
|
+
* consider on its own.
|
|
13
|
+
*
|
|
14
|
+
* `canon` wins because a tree that carries it has moved, and reading `.claude/`
|
|
15
|
+
* there would answer from the copy the move left behind. Read precedence
|
|
16
|
+
* agreeing with the eventual creation default means the flip a later batch
|
|
17
|
+
* takes changes one line rather than two.
|
|
18
|
+
*/
|
|
19
|
+
export const SURFACE_ROOTS = ['canon', '.claude'] as const
|
|
20
|
+
|
|
21
|
+
export type SurfaceRoot = (typeof SURFACE_ROOTS)[number]
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The root a surface is created at when neither root carries it yet.
|
|
25
|
+
*
|
|
26
|
+
* Disagreeing with the head of the read order for exactly one release: read
|
|
27
|
+
* precedence is new-first so a tree that has moved is never answered from the
|
|
28
|
+
* copy left behind, while creation stays at the old root so nothing writes a
|
|
29
|
+
* fresh tracked file under a root a target's installed binary may not resolve
|
|
30
|
+
* yet. A later batch flips this once a release carries the read side.
|
|
31
|
+
*/
|
|
32
|
+
export const CREATION_ROOT: SurfaceRoot = '.claude'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Every tracked surface this module resolves, at the name `.claude/` gives it.
|
|
36
|
+
*
|
|
37
|
+
* `canon` names the stamp folder rather than the CLI itself, which is the one
|
|
38
|
+
* entry `spell` respells per root.
|
|
39
|
+
*/
|
|
40
|
+
export const SURFACE_ENTRIES: readonly string[] = [
|
|
41
|
+
'ARCHITECTURE.md',
|
|
42
|
+
'REQUIREMENTS.md',
|
|
43
|
+
'DESIGN.md',
|
|
44
|
+
'context',
|
|
45
|
+
'wireframes',
|
|
46
|
+
'canon',
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* How a root spells an entry name. Only the stamp folder differs, since a
|
|
51
|
+
* project under `canon/` reserves the bare name for the CLI's own install
|
|
52
|
+
* rather than for its config.
|
|
53
|
+
*/
|
|
54
|
+
export function spell(root: SurfaceRoot, entry: string): string {
|
|
55
|
+
return root === 'canon' && entry === 'canon' ? 'config' : entry
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The root a surface resolves at: the first that carries it, and the creation
|
|
60
|
+
* default when neither does.
|
|
61
|
+
*/
|
|
62
|
+
function rootOf(root: string, entry: string): SurfaceRoot {
|
|
63
|
+
return (
|
|
64
|
+
SURFACE_ROOTS.find((candidate) =>
|
|
65
|
+
existsSync(join(root, candidate, spell(candidate, entry))),
|
|
66
|
+
) ?? CREATION_ROOT
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Where a tracked surface is read.
|
|
72
|
+
*
|
|
73
|
+
* `entry` is the surface itself and `rest` is whatever sits inside it, so a
|
|
74
|
+
* caller spells no root and no per-root naming variant of its own.
|
|
75
|
+
*/
|
|
76
|
+
export function surfaceDir(
|
|
77
|
+
root: string,
|
|
78
|
+
entry: string,
|
|
79
|
+
...rest: string[]
|
|
80
|
+
): string {
|
|
81
|
+
const at = rootOf(root, entry)
|
|
82
|
+
return join(root, at, spell(at, entry), ...rest)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Every root a surface would be read at, in precedence order, whether or not
|
|
87
|
+
* it is on disk.
|
|
88
|
+
*
|
|
89
|
+
* Containment tests take this rather than `surfaceDir`, since a path written
|
|
90
|
+
* against the root a tree no longer uses is still a path into that surface,
|
|
91
|
+
* and reading it as outside would report a live reference as stale.
|
|
92
|
+
*/
|
|
93
|
+
export function surfaceDirs(
|
|
94
|
+
root: string,
|
|
95
|
+
entry: string,
|
|
96
|
+
...rest: string[]
|
|
97
|
+
): string[] {
|
|
98
|
+
return SURFACE_ROOTS.map((candidate) =>
|
|
99
|
+
join(root, candidate, spell(candidate, entry), ...rest),
|
|
100
|
+
)
|
|
101
|
+
}
|
package/src/sync/stamp.ts
CHANGED
|
@@ -84,17 +84,30 @@ export function retiredNameStampPath(target: string): string {
|
|
|
84
84
|
return join(target, '.claude', 'aitk', 'config.json')
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* The stamp path under the new surface root, `canon/config/config.json`.
|
|
89
|
+
*
|
|
90
|
+
* Read ahead of `stampPath`, extending the same mechanism rather than adding a
|
|
91
|
+
* new one: a target that has moved reads its config from the root it moved
|
|
92
|
+
* to, and one that has not falls through to the spellings below unchanged.
|
|
93
|
+
* The write destination does not move to it in this batch.
|
|
94
|
+
*/
|
|
95
|
+
function surfaceStampPath(target: string): string {
|
|
96
|
+
return join(target, 'canon', 'config', 'config.json')
|
|
97
|
+
}
|
|
98
|
+
|
|
87
99
|
/**
|
|
88
100
|
* Every spelling a stamp has been written under, current first. The order is
|
|
89
101
|
* the read order, so a target carrying more than one resolves to the newest.
|
|
90
102
|
*
|
|
91
|
-
* The fallback carries no end date. It costs
|
|
92
|
-
* already touches the filesystem, and dropping
|
|
93
|
-
* change aimed at exactly the targets that were slowest to
|
|
94
|
-
* time.
|
|
103
|
+
* The fallback carries no end date. It costs three path reads on a command
|
|
104
|
+
* that already touches the filesystem, and dropping any of them later is a
|
|
105
|
+
* second breaking change aimed at exactly the targets that were slowest to
|
|
106
|
+
* migrate the first time.
|
|
95
107
|
*/
|
|
96
108
|
export function stampPaths(target: string): readonly string[] {
|
|
97
109
|
return [
|
|
110
|
+
surfaceStampPath(target),
|
|
98
111
|
stampPath(target),
|
|
99
112
|
retiredNameStampPath(target),
|
|
100
113
|
legacyStampPath(target),
|
|
@@ -107,7 +120,9 @@ export function stampPaths(target: string): readonly string[] {
|
|
|
107
120
|
* there is nothing to migrate off of.
|
|
108
121
|
*/
|
|
109
122
|
export function isLegacyStamped(target: string): boolean {
|
|
110
|
-
if (existsSync(stampPath(target)))
|
|
123
|
+
if (existsSync(surfaceStampPath(target)) || existsSync(stampPath(target))) {
|
|
124
|
+
return false
|
|
125
|
+
}
|
|
111
126
|
return (
|
|
112
127
|
existsSync(retiredNameStampPath(target)) ||
|
|
113
128
|
existsSync(legacyStampPath(target))
|