@erclx/canon 5.33.0 → 5.35.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/README.md +3 -5
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/REQUIREMENT.md +2 -3
- package/claude/skills/api-design/SKILL.md +2 -3
- package/claude/skills/api-design/references/adopted.md +1 -1
- package/claude/skills/auto-ship/references/draft-mark.md +1 -1
- package/claude/skills/canon-cli/SKILL.md +10 -11
- package/claude/skills/canon-feedback/REQUIREMENT.md +1 -0
- package/claude/skills/canon-feedback/SKILL.md +10 -28
- package/claude/skills/canon-feedback/references/template.md +53 -0
- package/claude/skills/canvas-board/SKILL.md +4 -1
- package/claude/skills/canvas-board/references/canvas.md +26 -3
- package/claude/skills/code-craft/REQUIREMENT.md +1 -1
- package/claude/skills/code-craft/SKILL.md +0 -1
- package/claude/skills/create-rule/SKILL.md +1 -1
- package/claude/skills/create-rule/references/rule.md +3 -1
- package/claude/skills/create-skill/SKILL.md +2 -1
- package/claude/skills/create-skill/references/skill-paths.md +12 -15
- package/{standards/standard.md → claude/skills/create-skill/references/skill-reference.md} +50 -45
- package/claude/skills/create-skill/references/skill.md +10 -4
- package/claude/skills/draft-and-pick/SKILL.md +1 -1
- package/claude/skills/git-commit/references/versioning.md +13 -13
- package/claude/skills/git-issue/references/issue.md +4 -1
- package/claude/skills/git-pr/SKILL.md +1 -1
- package/claude/skills/git-pr/references/publish.md +1 -1
- package/claude/skills/review-address/REQUIREMENT.md +1 -1
- package/claude/skills/review-address/SKILL.md +2 -2
- package/claude/skills/review-craft/references/ui.md +1 -1
- package/claude/skills/review-pr/SKILL.md +3 -3
- package/claude/skills/review-pr/references/post.md +1 -1
- package/claude/skills/review-ui/REQUIREMENT.md +1 -1
- package/claude/skills/review-ui/SKILL.md +8 -6
- package/claude/skills/review-ui/references/driving.md +8 -8
- package/claude/skills/review-ui/references/post.md +52 -16
- package/claude/skills/role-orchestrator/REQUIREMENT.md +2 -2
- package/claude/skills/role-orchestrator/SKILL.md +11 -12
- package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +3 -1
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +11 -8
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +10 -4
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +11 -2
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +11 -12
- package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +12 -15
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +6 -4
- package/claude/skills/role-orchestrator/scripts/poll.ts +50 -1
- package/claude/skills/role-reviewer/REQUIREMENT.md +17 -7
- package/claude/skills/role-reviewer/SKILL.md +17 -10
- package/claude/skills/role-worker/REQUIREMENT.md +1 -1
- package/claude/skills/role-worker/SKILL.md +6 -6
- package/claude/skills/session-compact/references/handoff-note.md +1 -1
- package/claude/skills/target-setup/SKILL.md +12 -6
- package/claude/skills/target-setup/references/detect.md +22 -5
- package/claude/skills/test-craft/REQUIREMENT.md +2 -3
- package/claude/skills/test-craft/SKILL.md +15 -3
- package/docs/agents/audits.md +1 -1
- package/docs/agents/canvas.md +5 -1
- package/docs/agents/commands.md +3 -5
- package/docs/agents/counts.md +5 -6
- package/docs/agents/drift-surfaces.md +15 -19
- package/docs/agents/gov-detect.md +47 -0
- package/docs/agents/index.md +6 -4
- package/docs/agents/install-and-sync.md +11 -31
- package/docs/agents/markdown-audit.md +10 -12
- package/docs/agents/migrate.md +1 -1
- package/docs/agents/pr-evidence-layout.md +70 -0
- package/docs/agents/pr-evidence.md +8 -9
- package/docs/agents/pr-frames.md +7 -2
- package/docs/agents/pr-lift.md +132 -0
- package/docs/agents/records-stale.md +1 -1
- package/docs/agents/records-validate.md +11 -23
- package/docs/agents/records.md +1 -1
- package/docs/agents/routing.md +1 -1
- package/docs/agents/rule-citations.md +11 -14
- package/docs/agents/scripting.md +4 -21
- package/docs/agents/state-scoped-risk.md +1 -1
- package/docs/agents/tasks-validate.md +1 -1
- package/docs/agents/teach.md +1 -1
- package/docs/target/index.md +1 -0
- package/docs/target/migrations.md +4 -0
- package/docs/target/projects.md +11 -11
- package/docs/target/scenarios.md +5 -6
- package/docs/target/standards-moved.md +32 -0
- package/docs/target/sync.md +5 -5
- package/docs/workflow/operating-model.md +16 -18
- package/docs/workflow/skill-map.md +1 -1
- package/governance/detect.toml +197 -0
- package/governance/renames.toml +0 -1
- package/governance/rules/code/000-code.md +1 -13
- package/governance/rules/code/010-testing.md +2 -30
- package/governance/rules/ui/400-ui.md +1 -1
- package/governance/rules/ui/480-canvas.md +13 -0
- package/governance/stacks/base.toml +15 -8
- package/governance/stacks/go.toml +2 -0
- package/governance/stacks/node.toml +1 -1
- package/governance/stacks/php.toml +2 -0
- package/governance/stacks/python.toml +1 -1
- package/package.json +1 -4
- package/scripts/core/install-check.sh +2 -3
- package/scripts/core/regen-hero.ts +0 -6
- package/scripts/lib/ui.sh +0 -9
- package/src/audits/catalog.ts +0 -9
- package/src/canvas/arrange.ts +66 -0
- package/src/canvas/capture.ts +20 -6
- package/src/canvas/content.ts +39 -0
- package/src/canvas/fit.ts +238 -0
- package/src/canvas/tokens.ts +28 -10
- package/src/capture/frame-values.ts +2 -32
- package/src/claude/routing.ts +1 -1
- package/src/claude/skills-reach.ts +4 -6
- package/src/cli/help.ts +3 -4
- package/src/cli.ts +0 -2
- package/src/commands/canvas.ts +179 -1
- package/src/commands/gov.ts +149 -10
- package/src/commands/init.ts +1 -1
- package/src/commands/labels.ts +4 -44
- package/src/commands/migrate.ts +1 -1
- package/src/commands/pr.ts +376 -1
- package/src/commands/records.ts +4 -20
- package/src/commands/tooling.ts +2 -4
- package/src/counts/catalogs.ts +0 -6
- package/src/feedback/body.ts +26 -5
- package/src/gate/boundaries.ts +2 -2
- package/src/gate/measures.ts +64 -72
- package/src/gate/sequencer.ts +51 -2
- package/src/gate/stages.ts +3 -22
- package/src/gov/citations.ts +11 -43
- package/src/gov/detect.ts +301 -0
- package/src/gov/list.ts +23 -2
- package/src/gov/stacks.ts +9 -0
- package/src/markdown/bans.ts +1 -1
- package/src/migrate/scratch-evidence.ts +2 -2
- package/src/pr/evidence.ts +218 -45
- package/src/pr/frames.ts +23 -3
- package/src/pr/lift.ts +418 -0
- package/src/pr/rest.ts +3 -0
- package/src/pr/review-scope.ts +76 -0
- package/src/records/validate.ts +6 -194
- package/src/sandbox/equivalence.ts +14 -7
- package/src/sandbox/provision.ts +0 -5
- package/src/secrets/shipped.ts +1 -2
- package/src/serve/static.ts +1 -2
- package/src/shipped/references.ts +29 -80
- package/src/sync/check.ts +18 -14
- package/src/tooling/inject.ts +6 -37
- package/src/tooling/read.ts +2 -3
- package/src/tooling/scan.ts +4 -17
- package/src/tooling/subfolder.ts +2 -66
- package/tooling/base/configs/scripts/verify.json +1 -1
- package/tooling/base/configs/scripts/verify.ts +1 -12
- package/tooling/base/manifest.toml +1 -2
- package/tooling/base/reference.md +10 -16
- package/tooling/base/seeds/.github/workflows/verify.yml +0 -3
- package/tooling/base/seeds/.lintstagedrc +1 -2
- package/tooling/base/seeds/canon/context/ci.md +0 -1
- package/tooling/base/seeds/canon/context/development.md +1 -1
- package/tooling/claude/reference.md +1 -1
- package/tooling/claude/seeds/.claude/hooks/review-post-scan.sh +13 -1
- package/tooling/go/reference.md +1 -1
- package/tooling/php/reference.md +1 -1
- package/tooling/python/reference.md +2 -2
- package/tooling/web/configs/.github/workflows/verify.yml +0 -3
- package/tooling/web/manifest.toml +0 -1
- package/tooling/web/reference.md +2 -4
- package/docs/agents/standards-audit.md +0 -40
- package/governance/rules/tooling/765-spelling.md +0 -19
- package/governance/stacks/astro.toml +0 -2
- package/governance/stacks/nextjs.toml +0 -2
- package/governance/stacks/node-server.toml +0 -2
- package/governance/stacks/python-fastapi.toml +0 -2
- package/governance/stacks/react.toml +0 -2
- package/src/commands/standards.ts +0 -278
- package/src/labels/spelling.ts +0 -120
- package/src/standards/audit.ts +0 -146
- package/src/standards/list.ts +0 -33
- package/src/standards/read.ts +0 -177
- package/src/standards/scope.ts +0 -56
- package/standards/index.md +0 -10
- package/tooling/base/configs/.vscode/extensions.json +0 -9
- package/tooling/base/configs/.vscode/settings.json +0 -3
- package/tooling/base/seeds/.cspell/project-terms.txt +0 -0
- package/tooling/base/seeds/.cspell/tech-stack.txt +0 -25
- package/tooling/base/seeds/cspell.json +0 -20
- package/tooling/go/seeds/.cspell/tech-stack.txt +0 -16
- package/tooling/nextjs/seeds/.cspell/tech-stack.txt +0 -3
- package/tooling/php/seeds/.cspell/tech-stack.txt +0 -5
- package/tooling/python/seeds/.cspell/tech-stack.txt +0 -18
- package/tooling/vite-react/seeds/.cspell/project-terms.txt +0 -1
- package/tooling/vite-react/seeds/.cspell/tech-stack.txt +0 -2
- package/tooling/web/configs/.vscode/extensions.json +0 -13
- package/tooling/web/configs/.vscode/settings.json +0 -10
- package/tooling/web/seeds/.cspell/tech-stack.txt +0 -21
package/src/records/validate.ts
CHANGED
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
recordDir as resolveRecordDir,
|
|
8
8
|
recordDirs as resolveRecordDirs,
|
|
9
9
|
} from '@/roots/record'
|
|
10
|
-
import { ATTRIBUTE_MARKER, governedPaths, readScope } from '@/standards/scope'
|
|
11
10
|
import {
|
|
12
11
|
TEACH_GLOSSARY,
|
|
13
12
|
TEACH_MISSION,
|
|
@@ -18,34 +17,10 @@ import {
|
|
|
18
17
|
WORKSPACE_NAME,
|
|
19
18
|
} from '@/teach/workspace'
|
|
20
19
|
|
|
21
|
-
export const RECORD_KINDS = [
|
|
22
|
-
'plans',
|
|
23
|
-
'groundwork',
|
|
24
|
-
'intake',
|
|
25
|
-
'standards',
|
|
26
|
-
'teach',
|
|
27
|
-
] as const
|
|
20
|
+
export const RECORD_KINDS = ['plans', 'groundwork', 'intake', 'teach'] as const
|
|
28
21
|
|
|
29
22
|
export type RecordKind = (typeof RECORD_KINDS)[number]
|
|
30
23
|
|
|
31
|
-
/**
|
|
32
|
-
* The folders standards reads, in precedence order.
|
|
33
|
-
*
|
|
34
|
-
* It carries two because the corpus authors at the project root and installs
|
|
35
|
-
* under `.claude/`. The authoring root wins where both exist, since the
|
|
36
|
-
* installed tree is a generated copy here and a finding fixed there is
|
|
37
|
-
* overwritten by the next regen. A project that consumed the corpus holds only
|
|
38
|
-
* the second, so one order serves both.
|
|
39
|
-
*
|
|
40
|
-
* Every other kind is a session record and takes the record roots instead,
|
|
41
|
-
* resolved by `@/roots/record`. Standards is the one kind that is tracked, so it
|
|
42
|
-
* does not move and spells its own two candidates here.
|
|
43
|
-
*/
|
|
44
|
-
const STANDARDS_FOLDERS: readonly string[] = [
|
|
45
|
-
'standards',
|
|
46
|
-
join('.claude', 'standards'),
|
|
47
|
-
]
|
|
48
|
-
|
|
49
24
|
/**
|
|
50
25
|
* `unknown-kind` is raised at the argument boundary rather than by the walk, and
|
|
51
26
|
* it sits here because both reach a caller through the same `reason` field. A
|
|
@@ -60,7 +35,6 @@ export const FINDING_KINDS = [
|
|
|
60
35
|
'name-malformed',
|
|
61
36
|
'title-missing',
|
|
62
37
|
'section-missing',
|
|
63
|
-
'scope-unanchored',
|
|
64
38
|
'entry-unreasoned',
|
|
65
39
|
'suggestion-missing',
|
|
66
40
|
'question-unanswerable',
|
|
@@ -103,10 +77,6 @@ export type ValidateOutcome = ValidateReport | ValidateRefused
|
|
|
103
77
|
|
|
104
78
|
/** Every folder a kind would accept, whether or not it is on disk. */
|
|
105
79
|
export function recordDirs(root: string, kind: RecordKind): string[] {
|
|
106
|
-
if (kind === 'standards') {
|
|
107
|
-
return STANDARDS_FOLDERS.map((folder) => join(root, folder))
|
|
108
|
-
}
|
|
109
|
-
|
|
110
80
|
return resolveRecordDirs(root, kind)
|
|
111
81
|
}
|
|
112
82
|
|
|
@@ -114,19 +84,8 @@ export function recordDirs(root: string, kind: RecordKind): string[] {
|
|
|
114
84
|
* The folder a kind reads. The first candidate on disk wins, and the creation
|
|
115
85
|
* default stands in when none exists, so a refusal and a test fixture both name
|
|
116
86
|
* the location the kind would be written to.
|
|
117
|
-
*
|
|
118
|
-
* The default is where this parts company with `dirs[0]`, which the record
|
|
119
|
-
* kinds can no longer take: their first candidate is the root the move lands at
|
|
120
|
-
* and nothing creates there yet, so a fallback reading it would name a folder no
|
|
121
|
-
* verb would ever write. Standards has no such split, since it is tracked and
|
|
122
|
-
* its first candidate is where it is authored.
|
|
123
87
|
*/
|
|
124
88
|
export function recordsDir(root: string, kind: RecordKind): string {
|
|
125
|
-
if (kind === 'standards') {
|
|
126
|
-
const dirs = recordDirs(root, kind)
|
|
127
|
-
return dirs.find((dir) => existsSync(dir)) ?? dirs[0]
|
|
128
|
-
}
|
|
129
|
-
|
|
130
89
|
return resolveRecordDir(root, kind)
|
|
131
90
|
}
|
|
132
91
|
|
|
@@ -134,18 +93,6 @@ export function isRecordKind(value: string): value is RecordKind {
|
|
|
134
93
|
return (RECORD_KINDS as readonly string[]).includes(value)
|
|
135
94
|
}
|
|
136
95
|
|
|
137
|
-
/**
|
|
138
|
-
* Whether a kind's folder is shared session scratch at the main worktree root.
|
|
139
|
-
*
|
|
140
|
-
* The five record folders are, so every session validates the records every
|
|
141
|
-
* other session reads. The corpus is tracked instead, so a linked worktree holds
|
|
142
|
-
* its own edited copy, and defaulting that kind to the main root would report on
|
|
143
|
-
* a tree the session never touched and say nothing about which one it read.
|
|
144
|
-
*/
|
|
145
|
-
export function isSharedScratch(kind: RecordKind): boolean {
|
|
146
|
-
return kind !== 'standards'
|
|
147
|
-
}
|
|
148
|
-
|
|
149
96
|
const NONE_IDENTIFIED = 'None identified.'
|
|
150
97
|
const NUMBERED_FILE = /^\d{2}-[a-z0-9]+(-[a-z0-9]+)*\.md$/
|
|
151
98
|
|
|
@@ -920,127 +867,6 @@ async function checkWorkspace(dir: string, slug: string): Promise<Finding[]> {
|
|
|
920
867
|
]
|
|
921
868
|
}
|
|
922
869
|
|
|
923
|
-
const STANDARD_INDEX = 'index.md'
|
|
924
|
-
const STANDARD_FIELDS = ['title', 'description'] as const
|
|
925
|
-
|
|
926
|
-
const DOES_NOT_GOVERN = 'Does not govern:'
|
|
927
|
-
|
|
928
|
-
/**
|
|
929
|
-
* The words a governed path offers a filename. Each segment gives its own word
|
|
930
|
-
* and, where it carries a prefix or a placeholder, the parts either side of a
|
|
931
|
-
* hyphen, so `.canon/tasks/session-<slug>.md` offers `tasks` and `session`.
|
|
932
|
-
*
|
|
933
|
-
* A dotted segment gives nothing. It names the folder holding the artifact
|
|
934
|
-
* rather than the artifact, and a standard named for it would pass this check
|
|
935
|
-
* while naming the container every sibling shares.
|
|
936
|
-
*/
|
|
937
|
-
export function pathWords(path: string): string[] {
|
|
938
|
-
const words: string[] = []
|
|
939
|
-
|
|
940
|
-
for (const segment of path.split('/')) {
|
|
941
|
-
if (segment.startsWith('.')) continue
|
|
942
|
-
|
|
943
|
-
const stem = segment.replace(/\.[a-z]+$/i, '').toLowerCase()
|
|
944
|
-
words.push(stem)
|
|
945
|
-
if (stem.includes('-')) words.push(...stem.split('-'))
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
return words.filter((word) => /^[a-z]+$/.test(word))
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
/**
|
|
952
|
-
* Accepts the singular and the plural of one word. A standard over a single
|
|
953
|
-
* document is named for the document and one over a folder of them is named for
|
|
954
|
-
* either, and picking a side would report a conforming half of the corpus.
|
|
955
|
-
*/
|
|
956
|
-
function namesWord(stem: string, word: string): boolean {
|
|
957
|
-
return stem === word || `${stem}s` === word || stem === `${word}s`
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
function checkStandardName(name: string, statement: string): Finding[] {
|
|
961
|
-
const paths = governedPaths(statement)
|
|
962
|
-
|
|
963
|
-
// The marker is read only where the first sentence backticks nothing, which
|
|
964
|
-
// is the catalog's own rule. A statement naming a path publishes that path
|
|
965
|
-
// however the rest of the statement describes itself.
|
|
966
|
-
if (paths.length === 0) {
|
|
967
|
-
if (statement.includes(ATTRIBUTE_MARKER)) return []
|
|
968
|
-
|
|
969
|
-
return [
|
|
970
|
-
finding(
|
|
971
|
-
'scope-unanchored',
|
|
972
|
-
name,
|
|
973
|
-
name,
|
|
974
|
-
'backticks no path in its first scope sentence and does not call itself an attribute standard, so it names no artifact to be named for.',
|
|
975
|
-
),
|
|
976
|
-
]
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
const stem = name.replace(/\.md$/, '')
|
|
980
|
-
const words = paths.flatMap(pathWords)
|
|
981
|
-
|
|
982
|
-
if (words.some((word) => namesWord(stem, word))) return []
|
|
983
|
-
|
|
984
|
-
return [
|
|
985
|
-
finding(
|
|
986
|
-
'name-malformed',
|
|
987
|
-
name,
|
|
988
|
-
name,
|
|
989
|
-
`names no part of ${paths.join(', ')}, which is what it governs. A rename reaches every target that installed the corpus and every surface citing it by bare filename.`,
|
|
990
|
-
),
|
|
991
|
-
]
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
export function checkStandard(name: string, text: string): Finding[] {
|
|
995
|
-
const findings: Finding[] = []
|
|
996
|
-
const frontmatter = parseFrontmatter(text)
|
|
997
|
-
|
|
998
|
-
const missing = STANDARD_FIELDS.filter(
|
|
999
|
-
(field) => !readField(frontmatter, field),
|
|
1000
|
-
)
|
|
1001
|
-
|
|
1002
|
-
if (missing.length > 0) {
|
|
1003
|
-
findings.push(
|
|
1004
|
-
finding(
|
|
1005
|
-
'frontmatter-incomplete',
|
|
1006
|
-
name,
|
|
1007
|
-
name,
|
|
1008
|
-
`carries no ${missing.join(' and no ')}.`,
|
|
1009
|
-
),
|
|
1010
|
-
)
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
const scope = readScope(text)
|
|
1014
|
-
|
|
1015
|
-
// The name derives from the scope statement, so an absent section leaves
|
|
1016
|
-
// nothing to derive against. Reporting the name as well would name one defect
|
|
1017
|
-
// twice and point the fix at the wrong file.
|
|
1018
|
-
if (!scope) {
|
|
1019
|
-
return [
|
|
1020
|
-
...findings,
|
|
1021
|
-
finding(
|
|
1022
|
-
'section-missing',
|
|
1023
|
-
name,
|
|
1024
|
-
'## Scope',
|
|
1025
|
-
'is absent, so the standard claims no jurisdiction and can refuse no rule.',
|
|
1026
|
-
),
|
|
1027
|
-
]
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
if (!scope.lines.some((line) => line.trim().startsWith(DOES_NOT_GOVERN))) {
|
|
1031
|
-
findings.push(
|
|
1032
|
-
finding(
|
|
1033
|
-
'section-missing',
|
|
1034
|
-
name,
|
|
1035
|
-
DOES_NOT_GOVERN,
|
|
1036
|
-
'is absent from the scope section, so no boundary names the owner it hands off to.',
|
|
1037
|
-
),
|
|
1038
|
-
)
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
return [...findings, ...checkStandardName(name, scope.statement)]
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
870
|
/**
|
|
1045
871
|
* The three kinds whose records are folders rather than files. Keyed by kind so
|
|
1046
872
|
* a seventh arrives as an entry here and the compiler names the walk it owes,
|
|
@@ -1048,7 +874,7 @@ export function checkStandard(name: string, text: string): Finding[] {
|
|
|
1048
874
|
*/
|
|
1049
875
|
const FOLDER_CHECK: Readonly<
|
|
1050
876
|
Record<
|
|
1051
|
-
Exclude<RecordKind, 'plans'
|
|
877
|
+
Exclude<RecordKind, 'plans'>,
|
|
1052
878
|
(dir: string, slug: string) => Promise<Finding[]>
|
|
1053
879
|
>
|
|
1054
880
|
> = {
|
|
@@ -1066,9 +892,8 @@ async function validateFiles(
|
|
|
1066
892
|
dir: string,
|
|
1067
893
|
kind: RecordKind,
|
|
1068
894
|
check: (name: string, text: string) => Finding[],
|
|
1069
|
-
skip: (file: string) => boolean = () => false,
|
|
1070
895
|
): Promise<ValidateReport> {
|
|
1071
|
-
const files =
|
|
896
|
+
const files = await listMarkdown(dir)
|
|
1072
897
|
|
|
1073
898
|
const perFile = await Promise.all(
|
|
1074
899
|
files.map(async (file) =>
|
|
@@ -1080,12 +905,9 @@ async function validateFiles(
|
|
|
1080
905
|
}
|
|
1081
906
|
|
|
1082
907
|
/**
|
|
1083
|
-
* Reports what every record in one folder claims against the shape its
|
|
1084
|
-
* fixes. It writes nothing
|
|
1085
|
-
*
|
|
1086
|
-
* that guessed wrong could not be undone. A standard installs into every target
|
|
1087
|
-
* and is cited by bare filename, so a rename costs more than the file move it
|
|
1088
|
-
* looks like.
|
|
908
|
+
* Reports what every record in one folder claims against the shape its reference
|
|
909
|
+
* fixes. It writes nothing, since a session record is per-machine scratch with
|
|
910
|
+
* no history behind it, so a repair that guessed wrong could not be undone.
|
|
1089
911
|
*/
|
|
1090
912
|
export async function validateRecords(
|
|
1091
913
|
root: string,
|
|
@@ -1102,16 +924,6 @@ export async function validateRecords(
|
|
|
1102
924
|
|
|
1103
925
|
if (kind === 'plans') return validateFiles(dir, kind, checkPlan)
|
|
1104
926
|
|
|
1105
|
-
// The walk stays flat, matching install and the catalog.
|
|
1106
|
-
if (kind === 'standards') {
|
|
1107
|
-
return validateFiles(
|
|
1108
|
-
dir,
|
|
1109
|
-
kind,
|
|
1110
|
-
checkStandard,
|
|
1111
|
-
(file) => file === STANDARD_INDEX,
|
|
1112
|
-
)
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
927
|
const folders = await listFolders(dir)
|
|
1116
928
|
const check = FOLDER_CHECK[kind]
|
|
1117
929
|
const perFolder = await Promise.all(
|
|
@@ -701,13 +701,16 @@ function canonVersion(): string {
|
|
|
701
701
|
}
|
|
702
702
|
|
|
703
703
|
/**
|
|
704
|
-
* Adds
|
|
705
|
-
*
|
|
706
|
-
*
|
|
707
|
-
*
|
|
704
|
+
* Adds a detached worktree of `ref` at `path`, so it carries full history for
|
|
705
|
+
* the scenarios that read it, and returns a remover that is safe to call twice.
|
|
706
|
+
* `node_modules` is linked from the working checkout, since the copy runs the
|
|
707
|
+
* same CLI and carries no install of its own.
|
|
708
708
|
*/
|
|
709
|
-
function
|
|
710
|
-
|
|
709
|
+
export function addDetachedWorktree(
|
|
710
|
+
root: string,
|
|
711
|
+
path: string,
|
|
712
|
+
ref: string,
|
|
713
|
+
): () => void {
|
|
711
714
|
execFileSync('git', ['worktree', 'add', '--detach', path, ref], {
|
|
712
715
|
cwd: root,
|
|
713
716
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
@@ -761,7 +764,11 @@ export async function runEquivalence(
|
|
|
761
764
|
|
|
762
765
|
let removeBaseline: () => void
|
|
763
766
|
try {
|
|
764
|
-
removeBaseline =
|
|
767
|
+
removeBaseline = addDetachedWorktree(
|
|
768
|
+
options.root,
|
|
769
|
+
join(workDir, 'base'),
|
|
770
|
+
options.base,
|
|
771
|
+
)
|
|
765
772
|
} catch (error) {
|
|
766
773
|
// An unknown ref must not leave an empty scratch folder behind.
|
|
767
774
|
if (options.out === undefined)
|
package/src/sandbox/provision.ts
CHANGED
|
@@ -220,11 +220,6 @@ function install(
|
|
|
220
220
|
fail(`Could not install ${label} into the sandbox.`)
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
-
/**
|
|
224
|
-
* No standards install. The corpus installs into no target, so a scenario
|
|
225
|
-
* driving a skill that reads one takes the `canon standards <name>` path a real
|
|
226
|
-
* project takes.
|
|
227
|
-
*/
|
|
228
223
|
function setupAssets(
|
|
229
224
|
root: string,
|
|
230
225
|
sandbox: string,
|
package/src/secrets/shipped.ts
CHANGED
|
@@ -13,8 +13,7 @@ import { join } from 'node:path'
|
|
|
13
13
|
*
|
|
14
14
|
* What it does not cover is the plugin, which a marketplace install reads live
|
|
15
15
|
* from `claude/` rather than from a tarball. That folder is a `files` entry
|
|
16
|
-
* too,
|
|
17
|
-
* own right, so both routes land inside the same corpus.
|
|
16
|
+
* too, so both routes land inside the same corpus.
|
|
18
17
|
*/
|
|
19
18
|
export type ShipEntries =
|
|
20
19
|
/** The field declares a corpus, which is what this check reads. */
|
package/src/serve/static.ts
CHANGED
|
@@ -200,8 +200,7 @@ export function resolveWithin(
|
|
|
200
200
|
/**
|
|
201
201
|
* Re-tests containment after following symlinks. `resolveWithin` is lexical
|
|
202
202
|
* and `resolve` does not follow a link, so a link inside the root clears that
|
|
203
|
-
* test while the file it points at sits outside.
|
|
204
|
-
* instance, since `claude/standards` is a link out of `claude/`.
|
|
203
|
+
* test while the file it points at sits outside.
|
|
205
204
|
*
|
|
206
205
|
* Only a path that exists is checked, because a link can be followed only once
|
|
207
206
|
* there is something on the other end, and a path that resolves to nothing is
|
|
@@ -10,8 +10,7 @@ import { isMarked } from '@/sweep/exempt-marker'
|
|
|
10
10
|
* on purpose rather than by omission. What decides membership here is whether
|
|
11
11
|
* anything serves the file to a reader who does not hold this repository: a
|
|
12
12
|
* skill body loads into a session, a docs page is read through `canon docs`, a
|
|
13
|
-
*
|
|
14
|
-
* through an install. A `src/` doc comment
|
|
13
|
+
* rule through a glob match, and a seed through an install. A `src/` doc comment
|
|
15
14
|
* is read by someone working on the toolkit, in this checkout, where every
|
|
16
15
|
* number resolves.
|
|
17
16
|
*
|
|
@@ -19,18 +18,12 @@ import { isMarked } from '@/sweep/exempt-marker'
|
|
|
19
18
|
* `src/design/base.css` and `src/design/tokens.ts` carry sixteen values shaped
|
|
20
19
|
* `#191512`, which no width or boundary rule separates from a pull request
|
|
21
20
|
* number, so telling them apart would need the line's syntactic position.
|
|
22
|
-
*
|
|
23
|
-
* `claude/standards` is a symlink into one of the roots below, and a walk that
|
|
24
|
-
* followed it would read that corpus twice and report every finding in it
|
|
25
|
-
* under two paths. Nothing is lost by not following, since it is a corpus here
|
|
26
|
-
* in its own right.
|
|
27
21
|
*/
|
|
28
22
|
export const SHIPPED_CORPORA = [
|
|
29
23
|
'claude',
|
|
30
24
|
'docs',
|
|
31
25
|
'governance',
|
|
32
26
|
'scripts',
|
|
33
|
-
'standards',
|
|
34
27
|
'tooling',
|
|
35
28
|
] as const
|
|
36
29
|
|
|
@@ -146,37 +139,15 @@ const SAME_REPOSITORY =
|
|
|
146
139
|
const DOCS_PATH = /(?<![\w./-])docs\/[^\s`)\]]*\.md\b/g
|
|
147
140
|
|
|
148
141
|
/**
|
|
149
|
-
* A
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
* Scoped to that one corpus rather than every `isShippedCorpus` reads,
|
|
155
|
-
* because `docs/agents/` and `docs/workflow/` carry the identical
|
|
156
|
-
* `standards/<name>.md` shape correctly: a docs page resolves from this
|
|
157
|
-
* checkout's own root rather than through a skill's `${CLAUDE_SKILL_DIR}`,
|
|
158
|
-
* so the same string is the fix in one corpus and the defect in the other.
|
|
159
|
-
* Widening the pattern to every corpus turns it into a mass false-positive
|
|
160
|
-
* over the dozens of correct citations those two folders carry.
|
|
161
|
-
*
|
|
162
|
-
* The same leading-boundary discipline as `DOCS_PATH` excludes the already
|
|
163
|
-
* correct form: a `/` sits ahead of `standards` in
|
|
164
|
-
* `${CLAUDE_SKILL_DIR}/../../standards/<name>.md`, which the negative
|
|
165
|
-
* lookbehind rejects, so a citation already rewritten to the resolving form
|
|
166
|
-
* does not fail this check a second time.
|
|
142
|
+
* A call to `canon standards`, a verb no installed CLI carries any more. A
|
|
143
|
+
* shipped body naming it sends a target reader to an unknown-command error,
|
|
144
|
+
* so every corpus reports it, `docs/` included. A page saying the verb is gone
|
|
145
|
+
* carries the reference marker on that line.
|
|
167
146
|
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* the shape a project's own `standards/<name>.md` takes writes a bracketed
|
|
171
|
-
* token this pattern also matches, such as `standards/<slug>.md`
|
|
172
|
-
* in a skill that drafts one, which names no real file. A bare,
|
|
173
|
-
* non-bracketed match now reports whether or not the file exists, unlike
|
|
174
|
-
* `DOCS_PATH`, which still gates on existence: a same-repository citation
|
|
175
|
-
* under this corpus names no target project's own tree the way a `docs/`
|
|
176
|
-
* path can, so shape alone separates a placeholder from a broken citation
|
|
177
|
-
* here with no false-positive class existence was catching.
|
|
147
|
+
* The trailing lookahead keeps a hyphenated token such as a `standards-audit`
|
|
148
|
+
* page name from matching, since that names a file rather than a call.
|
|
178
149
|
*/
|
|
179
|
-
const
|
|
150
|
+
const STANDARDS_CALL = /\bcanon standards(?![\w-])/g
|
|
180
151
|
|
|
181
152
|
/**
|
|
182
153
|
* A phase-label-shaped token: exactly two numeric groups, with a negative
|
|
@@ -223,7 +194,7 @@ export interface ShippedReference {
|
|
|
223
194
|
| 'pull-request'
|
|
224
195
|
| 'commit'
|
|
225
196
|
| 'docs-path'
|
|
226
|
-
| '
|
|
197
|
+
| 'retired-verb'
|
|
227
198
|
| 'phase-label'
|
|
228
199
|
| 'rule-path'
|
|
229
200
|
/** The reference as written, so a report names the token to qualify. */
|
|
@@ -272,50 +243,31 @@ function isDocsPathReportable(
|
|
|
272
243
|
}
|
|
273
244
|
|
|
274
245
|
/**
|
|
275
|
-
* Whether `file` sits in the one corpus `
|
|
276
|
-
*
|
|
246
|
+
* Whether `file` sits in the one corpus `RULE_PATH` gates: a shipped skill
|
|
247
|
+
* body, minus its own `REQUIREMENT.md`.
|
|
277
248
|
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
* resolver rule this pair enforces ever applies to it. One predicate serves
|
|
284
|
-
* both call sites rather than two copies drifting apart with nothing
|
|
285
|
-
* comparing them.
|
|
249
|
+
* The ban lives in `598-authoring-layout.md`, stated for one reader: a
|
|
250
|
+
* session loading the `SKILL.md` body a target actually receives.
|
|
251
|
+
* `REQUIREMENT.md` is excluded for the reason that file states there, since a
|
|
252
|
+
* maintainer or an audit command reads it rather than a session loading it,
|
|
253
|
+
* so the resolver rule never applies to it.
|
|
286
254
|
*/
|
|
287
255
|
function isSkillBodyScope(file: string): boolean {
|
|
288
256
|
return file.startsWith('claude/skills/') && !file.endsWith('/REQUIREMENT.md')
|
|
289
257
|
}
|
|
290
258
|
|
|
291
|
-
/**
|
|
292
|
-
* Whether a `STANDARDS_PATH` match should be reported: every match that is
|
|
293
|
-
* not a placeholder, whether or not the named file exists.
|
|
294
|
-
*
|
|
295
|
-
* Existence used to gate this the same way `isDocsPathReportable` gates its
|
|
296
|
-
* corpus, which conflated a deliberate placeholder with a genuinely broken,
|
|
297
|
-
* real-looking citation: both fail existence and both passed muted. A bare
|
|
298
|
-
* `standards/<name>.md` names no target-project tree the way a `docs/` path
|
|
299
|
-
* can, so shape alone separates the two classes here with no false-positive
|
|
300
|
-
* class existence was catching.
|
|
301
|
-
*/
|
|
302
|
-
function isStandardsPathReportable(path: string): boolean {
|
|
303
|
-
return !isPlaceholderPath(path)
|
|
304
|
-
}
|
|
305
|
-
|
|
306
259
|
/**
|
|
307
260
|
* Every reference in one shipped file that no marker mutes.
|
|
308
261
|
*
|
|
309
262
|
* The corpus walk is deliberately absent, which lets most of the shape be
|
|
310
263
|
* tested against a string rather than against a fixture. That is the seam
|
|
311
264
|
* `headingCitationsIn` draws in `src/claude/skills-headings.ts` and `citationsIn`
|
|
312
|
-
* draws in `skills-reach.ts`. `DOCS_PATH`
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
* findings for both rather than raising, which is the wrong failure
|
|
265
|
+
* draws in `skills-reach.ts`. `DOCS_PATH` is the one pattern that still
|
|
266
|
+
* needs a filesystem, since resolving against this checkout is the only thing
|
|
267
|
+
* that separates a real citation from an illustration, so `referencesIn`
|
|
268
|
+
* takes `root` as the one caller-supplied exception to that rule. `root` is
|
|
269
|
+
* required rather than defaulted, since a caller that dropped it silently
|
|
270
|
+
* would report zero findings rather than raising, which is the wrong failure
|
|
319
271
|
* direction for a gate.
|
|
320
272
|
*
|
|
321
273
|
* The unit is the match rather than the line, unlike those two, because one
|
|
@@ -375,16 +327,13 @@ export function referencesIn(
|
|
|
375
327
|
})
|
|
376
328
|
}
|
|
377
329
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
text: match[0],
|
|
386
|
-
})
|
|
387
|
-
}
|
|
330
|
+
for (const match of line.matchAll(STANDARDS_CALL)) {
|
|
331
|
+
references.push({
|
|
332
|
+
file,
|
|
333
|
+
line: index + 1,
|
|
334
|
+
kind: 'retired-verb',
|
|
335
|
+
text: match[0],
|
|
336
|
+
})
|
|
388
337
|
}
|
|
389
338
|
|
|
390
339
|
for (const match of line.matchAll(PHASE_LABEL)) {
|
package/src/sync/check.ts
CHANGED
|
@@ -3,9 +3,10 @@ import { basename, join, sep } from 'node:path'
|
|
|
3
3
|
import { execa } from 'execa'
|
|
4
4
|
import { gitEnv } from '@/git/env'
|
|
5
5
|
import { createDesignAdapter, DESIGN_INSTALL_DIR } from '@/design/adapter'
|
|
6
|
-
import { createGovAdapter
|
|
6
|
+
import { createGovAdapter } from '@/gov/adapter'
|
|
7
7
|
import { successorResolver } from '@/gov/renames'
|
|
8
|
-
import {
|
|
8
|
+
import { lookupRules } from '@/gov/install'
|
|
9
|
+
import { resolveMissingRules, resolveRules } from '@/gov/stacks'
|
|
9
10
|
import { planSync, type ScanEntry, type SyncAdapter } from '@/sync/engine'
|
|
10
11
|
import {
|
|
11
12
|
collectSuperseded,
|
|
@@ -576,7 +577,8 @@ export function readInstalledRules(
|
|
|
576
577
|
* not already hold.
|
|
577
578
|
*
|
|
578
579
|
* The band test is the entitlement filter, and `bands` carries two sources: the
|
|
579
|
-
* folders the target already holds, and the folders
|
|
580
|
+
* folders the target already holds, and the folders holding a rule the base
|
|
581
|
+
* stack resolves to.
|
|
580
582
|
* A rule authored under `lang/` or `ui/` is named by an individual stack, so it
|
|
581
583
|
* belongs to some targets and not others, and listing every added file would
|
|
582
584
|
* tell a base consumer about rules it was never entitled to. The test
|
|
@@ -615,27 +617,29 @@ export function selectNewRules(
|
|
|
615
617
|
}
|
|
616
618
|
|
|
617
619
|
/**
|
|
618
|
-
* Band folders the base stack
|
|
619
|
-
* so a rule authored
|
|
620
|
+
* Band folders holding a rule the base stack resolves to. Every governance
|
|
621
|
+
* stack extends base, so a new rule authored beside one of base's rules is
|
|
622
|
+
* reported to every target the fallback below reads.
|
|
620
623
|
*
|
|
621
624
|
* This is what covers a band no target can carry yet. Entitlement is otherwise
|
|
622
|
-
* read off folders the target already holds, and a
|
|
625
|
+
* read off folders the target already holds, and a band base reaches later
|
|
623
626
|
* exists in no installed tree, so without this the rules inside it would reach
|
|
624
|
-
* nobody. Read
|
|
625
|
-
*
|
|
627
|
+
* nobody. Read through `resolveRules` rather than off the stack's entries,
|
|
628
|
+
* since base names its rules one by one and an entry is a slug rather than a
|
|
629
|
+
* folder.
|
|
626
630
|
*
|
|
627
|
-
* A folder only a leaf stack
|
|
631
|
+
* A folder only a leaf stack reaches is deliberately absent. It is entitled to
|
|
628
632
|
* some targets and not others, which is the distinction the band test makes and
|
|
629
633
|
* the installed tree is the only evidence of.
|
|
630
634
|
*/
|
|
631
635
|
export function baseBands(root: string): Set<string> {
|
|
632
|
-
const
|
|
633
|
-
if (
|
|
636
|
+
const resolution = resolveRules(root, 'base')
|
|
637
|
+
if (!resolution.ok) return new Set()
|
|
634
638
|
|
|
635
639
|
return new Set(
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
640
|
+
lookupRules(root, resolution.rules)
|
|
641
|
+
.found.map((source) => source.subdir.split(sep)[0] ?? '')
|
|
642
|
+
.filter((band) => band !== ''),
|
|
639
643
|
)
|
|
640
644
|
}
|
|
641
645
|
|
package/src/tooling/inject.ts
CHANGED
|
@@ -20,13 +20,7 @@ import {
|
|
|
20
20
|
readPackage,
|
|
21
21
|
serializePackage,
|
|
22
22
|
} from '@/tooling/package'
|
|
23
|
-
import {
|
|
24
|
-
hasSpellConfig,
|
|
25
|
-
isSubfolderTarget,
|
|
26
|
-
isWithheldInSubfolder,
|
|
27
|
-
SUBFOLDER_SPELL_CONFIG,
|
|
28
|
-
subfolderSpellConfig,
|
|
29
|
-
} from '@/tooling/subfolder'
|
|
23
|
+
import { isSubfolderTarget, isWithheldInSubfolder } from '@/tooling/subfolder'
|
|
30
24
|
import { logAdd, logRemove, logStep, logWarn } from '@/cli/ui'
|
|
31
25
|
|
|
32
26
|
export async function injectConfigs(
|
|
@@ -72,30 +66,13 @@ async function writeSeed(src: string, dest: string): Promise<void> {
|
|
|
72
66
|
}
|
|
73
67
|
|
|
74
68
|
/**
|
|
75
|
-
* Copies a seed when the target lacks it
|
|
76
|
-
* the seed is a `.txt` word list, missing lines are appended and the file is
|
|
77
|
-
* re-sorted. Every other existing file is left untouched.
|
|
69
|
+
* Copies a seed when the target lacks it and leaves an existing file untouched.
|
|
78
70
|
*/
|
|
79
|
-
async function
|
|
71
|
+
async function copySeedIfMissing(src: string, dest: string): Promise<void> {
|
|
80
72
|
await mkdir(dirname(dest), { recursive: true })
|
|
73
|
+
if (existsSync(dest)) return
|
|
81
74
|
|
|
82
|
-
|
|
83
|
-
await writeSeed(src, dest)
|
|
84
|
-
return
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
if (!src.endsWith('.txt')) return
|
|
88
|
-
|
|
89
|
-
const existing = (await readFile(dest, 'utf8')).split('\n')
|
|
90
|
-
const incoming = (await readFile(src, 'utf8')).split('\n')
|
|
91
|
-
const missing = incoming.filter(
|
|
92
|
-
(word) => word !== '' && !existing.includes(word),
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
if (missing.length === 0) return
|
|
96
|
-
|
|
97
|
-
const merged = [...existing.filter((line) => line !== ''), ...missing].sort()
|
|
98
|
-
await writeFile(dest, `${merged.join('\n')}\n`)
|
|
75
|
+
await writeSeed(src, dest)
|
|
99
76
|
}
|
|
100
77
|
|
|
101
78
|
export async function injectSeeds(
|
|
@@ -116,20 +93,12 @@ export async function injectSeeds(
|
|
|
116
93
|
logStep(`Applying ${manifest.name} seeds`)
|
|
117
94
|
for (const rel of files) {
|
|
118
95
|
const dest = resolveSurfacePath(target, rel)
|
|
119
|
-
await
|
|
96
|
+
await copySeedIfMissing(join(manifest.seedsDir, rel), dest)
|
|
120
97
|
logAdd(relative(target, dest))
|
|
121
98
|
applied.push(rel)
|
|
122
99
|
}
|
|
123
100
|
}
|
|
124
101
|
|
|
125
|
-
const spellConfig = isSubfolder ? subfolderSpellConfig(chain) : undefined
|
|
126
|
-
if (spellConfig !== undefined && !hasSpellConfig(target)) {
|
|
127
|
-
logStep('Applying subfolder spell config')
|
|
128
|
-
await writeFile(join(target, SUBFOLDER_SPELL_CONFIG), spellConfig)
|
|
129
|
-
logAdd(SUBFOLDER_SPELL_CONFIG)
|
|
130
|
-
applied.push(SUBFOLDER_SPELL_CONFIG)
|
|
131
|
-
}
|
|
132
|
-
|
|
133
102
|
return applied
|
|
134
103
|
}
|
|
135
104
|
|
package/src/tooling/read.ts
CHANGED
|
@@ -28,9 +28,8 @@ function toolingSourceDir(root: string): string {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
* The roots a stack reference resolves against
|
|
32
|
-
*
|
|
33
|
-
* more, so the package corpus is the only root that answers there. The
|
|
31
|
+
* The roots a stack reference resolves against. No stack reference installs
|
|
32
|
+
* into a project any more, so the package corpus is the only root that answers there. The
|
|
34
33
|
* authoring root stays ahead of it for the toolkit's own repository, which
|
|
35
34
|
* authors the corpus this reads, and for a project authoring stacks of its
|
|
36
35
|
* own under the same layout.
|