@erclx/canon 5.34.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 +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/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 +9 -3
- package/claude/skills/draft-and-pick/SKILL.md +1 -1
- package/claude/skills/git-commit/references/versioning.md +13 -13
- 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-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 +1 -1
- 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 +2 -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 +2 -2
- package/claude/skills/target-setup/references/detect.md +3 -3
- package/docs/agents/audits.md +1 -1
- package/docs/agents/commands.md +2 -4
- package/docs/agents/counts.md +5 -6
- package/docs/agents/drift-surfaces.md +15 -19
- package/docs/agents/index.md +2 -3
- package/docs/agents/install-and-sync.md +7 -31
- package/docs/agents/markdown-audit.md +10 -12
- package/docs/agents/migrate.md +1 -1
- 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 +2 -20
- 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 +9 -9
- 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 +0 -4
- package/governance/renames.toml +0 -1
- 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/src/audits/catalog.ts +0 -9
- 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/gov.ts +5 -9
- package/src/commands/init.ts +1 -1
- package/src/commands/labels.ts +4 -44
- package/src/commands/migrate.ts +1 -1
- package/src/commands/records.ts +4 -20
- package/src/commands/tooling.ts +2 -4
- package/src/counts/catalogs.ts +0 -6
- package/src/gate/boundaries.ts +2 -2
- package/src/gate/measures.ts +6 -28
- package/src/gate/sequencer.ts +51 -2
- package/src/gate/stages.ts +0 -18
- package/src/gov/citations.ts +11 -43
- package/src/markdown/bans.ts +1 -1
- package/src/migrate/scratch-evidence.ts +2 -2
- package/src/records/validate.ts +6 -194
- 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/gate/stages.ts
CHANGED
|
@@ -25,7 +25,6 @@ import {
|
|
|
25
25
|
skillFamily,
|
|
26
26
|
skillPracticeShape,
|
|
27
27
|
skillProvenance,
|
|
28
|
-
standardCriteria,
|
|
29
28
|
unreferencedRules,
|
|
30
29
|
visualPathGlobs,
|
|
31
30
|
} from '@/gate/measures'
|
|
@@ -502,11 +501,6 @@ export const STAGES: readonly Stage[] = [
|
|
|
502
501
|
label: 'Skill family',
|
|
503
502
|
checks: [{ kind: 'measure', measure: skillFamily }],
|
|
504
503
|
},
|
|
505
|
-
{
|
|
506
|
-
id: 'standard-criteria',
|
|
507
|
-
label: 'Standard success criteria',
|
|
508
|
-
checks: [{ kind: 'measure', measure: standardCriteria }],
|
|
509
|
-
},
|
|
510
504
|
{
|
|
511
505
|
id: 'sandbox-coverage',
|
|
512
506
|
label: 'Sandbox coverage',
|
|
@@ -522,18 +516,6 @@ export const STAGES: readonly Stage[] = [
|
|
|
522
516
|
label: 'Plugin manifests',
|
|
523
517
|
checks: [{ kind: 'measure', measure: pluginManifests }],
|
|
524
518
|
},
|
|
525
|
-
{
|
|
526
|
-
id: 'spelling',
|
|
527
|
-
label: 'Spelling',
|
|
528
|
-
checks: [
|
|
529
|
-
{
|
|
530
|
-
kind: 'command',
|
|
531
|
-
argv: ['bun', 'run', 'check:spell'],
|
|
532
|
-
failure: 'Spell check failed',
|
|
533
|
-
},
|
|
534
|
-
],
|
|
535
|
-
success: 'Spell check passed',
|
|
536
|
-
},
|
|
537
519
|
{
|
|
538
520
|
id: 'shell',
|
|
539
521
|
label: 'Shell',
|
package/src/gov/citations.ts
CHANGED
|
@@ -6,7 +6,6 @@ import { isMarked } from '@/sweep/exempt-marker'
|
|
|
6
6
|
import { gitEnv } from '@/git/env'
|
|
7
7
|
import { listRuleFiles } from '@/gov/payload'
|
|
8
8
|
import { parseFrontmatter } from '@/indexes/frontmatter'
|
|
9
|
-
import { movedStandardPath } from '@/standards/read'
|
|
10
9
|
import { findBacktickSpans } from '@/markdown/backticks'
|
|
11
10
|
|
|
12
11
|
/**
|
|
@@ -42,14 +41,14 @@ export const RULE_DIRS: readonly string[] = [
|
|
|
42
41
|
export const GLOB_CORPUS = join('internal', 'rules')
|
|
43
42
|
|
|
44
43
|
/**
|
|
45
|
-
* How the citation was written, kept on the finding because the
|
|
44
|
+
* How the citation was written, kept on the finding because the two resolve
|
|
46
45
|
* against different roots and a reader repairing one needs to know which.
|
|
47
46
|
*
|
|
48
|
-
* `
|
|
49
|
-
*
|
|
50
|
-
*
|
|
47
|
+
* `path` is a backticked repository path. `sibling` is a rule naming another
|
|
48
|
+
* rule by filename alone, which resolves inside the folder the citing rule
|
|
49
|
+
* sits in.
|
|
51
50
|
*/
|
|
52
|
-
export type CitationForm = 'path' | '
|
|
51
|
+
export type CitationForm = 'path' | 'sibling'
|
|
53
52
|
|
|
54
53
|
/**
|
|
55
54
|
* What the sweep decided about one citation.
|
|
@@ -116,13 +115,6 @@ const FENCE = /^\s*(?:```|~~~)/
|
|
|
116
115
|
|
|
117
116
|
const FRONTMATTER_DELIMITER = /^---\s*$/
|
|
118
117
|
|
|
119
|
-
/**
|
|
120
|
-
* The verb form, with the name captured. A leading letter or digit is required,
|
|
121
|
-
* which is what leaves `canon standards <name>` unmatched: that line teaches the
|
|
122
|
-
* form rather than citing a standard, and it is the only one in either corpus.
|
|
123
|
-
*/
|
|
124
|
-
const STANDARD_CALL = /canon standards ([A-Za-z0-9][A-Za-z0-9._-]*)/g
|
|
125
|
-
|
|
126
118
|
/** A rule filename, which is how a rule names a sibling with no folder around it. */
|
|
127
119
|
const SIBLING_RULE = /^\d{3}-[a-z0-9-]+\.md$/
|
|
128
120
|
|
|
@@ -146,8 +138,8 @@ function isNotRepositoryPath(span: string): boolean {
|
|
|
146
138
|
* This is what separates a citation from the folder and module conventions the
|
|
147
139
|
* corpus is full of. `next/font`, `try/except`, `react-hooks/set-state-in-effect`,
|
|
148
140
|
* and `oven-sh/setup-bun@v2` all carry a slash and name no file, and a trailing
|
|
149
|
-
* slash is a folder rather than a document. The cost is that
|
|
150
|
-
*
|
|
141
|
+
* slash is a folder rather than a document. The cost is that a real folder path
|
|
142
|
+
* goes unchecked, which is the bound the report states.
|
|
151
143
|
*/
|
|
152
144
|
function namesAFile(span: string): boolean {
|
|
153
145
|
if (span.endsWith('/')) return false
|
|
@@ -160,10 +152,10 @@ function namesAFile(span: string): boolean {
|
|
|
160
152
|
* sweep can resolve.
|
|
161
153
|
*
|
|
162
154
|
* The whole span is classified rather than a trailing pattern inside it.
|
|
163
|
-
* Extracting
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
155
|
+
* Extracting a trailing `<folder>/<name>.md` out of a longer path and resolving
|
|
156
|
+
* that against another root manufactures a dead citation out of a file that
|
|
157
|
+
* exists, which a session measuring this corpus did before the check was
|
|
158
|
+
* written.
|
|
167
159
|
*/
|
|
168
160
|
export function classifySpan(span: string): CitationForm | undefined {
|
|
169
161
|
if (isNotRepositoryPath(span)) return undefined
|
|
@@ -176,15 +168,6 @@ export function classifySpan(span: string): CitationForm | undefined {
|
|
|
176
168
|
* Where a citation could answer, which is wherever the reader's own tools look
|
|
177
169
|
* and nowhere else.
|
|
178
170
|
*
|
|
179
|
-
* A standard name takes the authoring root alone, matching `standardRoots` in
|
|
180
|
-
* `@/standards/read`, which reads `standards/` at the working root and then the
|
|
181
|
-
* package corpus, plus the skill reference a retired name moved to. This verb
|
|
182
|
-
* refuses a tree holding no rule corpus, so it runs only where those two roots
|
|
183
|
-
* are one directory. `internal/standards/` is
|
|
184
|
-
* deliberately absent: `canon standards <name>` never reaches it, so admitting it
|
|
185
|
-
* here would pass a citation that refuses for the session opening it, which is a
|
|
186
|
-
* gate failing open.
|
|
187
|
-
*
|
|
188
171
|
* A sibling resolves inside the folder the citing rule sits in, since that is
|
|
189
172
|
* the only place a bare rule filename means anything.
|
|
190
173
|
*/
|
|
@@ -193,11 +176,6 @@ function candidatesFor(
|
|
|
193
176
|
cited: string,
|
|
194
177
|
ruleFile: string,
|
|
195
178
|
): string[] {
|
|
196
|
-
if (form === 'standard') {
|
|
197
|
-
const moved = movedStandardPath(cited)
|
|
198
|
-
|
|
199
|
-
return [join('standards', `${cited}.md`), ...(moved ? [moved] : [])]
|
|
200
|
-
}
|
|
201
179
|
if (form === 'sibling') return [join(dirname(ruleFile), cited)]
|
|
202
180
|
return [cited]
|
|
203
181
|
}
|
|
@@ -244,16 +222,6 @@ export function collectCitations(text: string): RawCitation[] {
|
|
|
244
222
|
: trimmed
|
|
245
223
|
const marked = isMarked(lines, index, CITATION_MARKER)
|
|
246
224
|
|
|
247
|
-
for (const match of line.matchAll(STANDARD_CALL)) {
|
|
248
|
-
found.push({
|
|
249
|
-
line: index + 1,
|
|
250
|
-
form: 'standard',
|
|
251
|
-
cited: match[1] ?? '',
|
|
252
|
-
preview,
|
|
253
|
-
marked,
|
|
254
|
-
})
|
|
255
|
-
}
|
|
256
|
-
|
|
257
225
|
// A backticked span is the only carrier a rule writes a citation in. No
|
|
258
226
|
// rule in either corpus uses a markdown link, and matching running prose
|
|
259
227
|
// would report every sentence that happens to name a file.
|
package/src/markdown/bans.ts
CHANGED
|
@@ -16,7 +16,7 @@ const CHARACTERS = ['—', ';'] as const
|
|
|
16
16
|
* Parsing the prose was the alternative and it put a parser contract on a
|
|
17
17
|
* document authored for people. The set is closed rather than extensible,
|
|
18
18
|
* since a character is the one class a literal match settles. A word is left
|
|
19
|
-
* to `write-human` as guidance and a spelling to
|
|
19
|
+
* to `write-human` as guidance and a spelling to no check at all, because a word ban
|
|
20
20
|
* catches the token and misses the habit behind it.
|
|
21
21
|
*/
|
|
22
22
|
export const BAN_SETS: BanSets = {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* durable record naming a folder under it as its evidence is a citation into
|
|
7
7
|
* something the backup never covers. The nine promoted here are every folder
|
|
8
8
|
* under scratch that a live or archived record cites and no source file under
|
|
9
|
-
* `claude/`, `scripts/`, `src/`,
|
|
9
|
+
* `claude/`, `scripts/`, `src/`, or `governance/` names by path,
|
|
10
10
|
* which is what separates them from a folder a script still writes into on its
|
|
11
11
|
* own schedule.
|
|
12
12
|
*
|
|
@@ -42,7 +42,7 @@ import { recordDir, SCRATCH } from '@/roots/record'
|
|
|
42
42
|
* Derived by the two-clause test `canon migrate scratch-evidence` exists to
|
|
43
43
|
* apply mechanically: a durable record under a `BACKED_FOLDERS` entry, live
|
|
44
44
|
* or archived, names the folder as its evidence, and no file under `claude/`,
|
|
45
|
-
* `scripts/`, `src/`,
|
|
45
|
+
* `scripts/`, `src/`, or `governance/` names that path. Measured
|
|
46
46
|
* 2026-09-06 against nine folders holding thirteen files.
|
|
47
47
|
*
|
|
48
48
|
* `verify-astro` and `verify-vite-react` pass the same test and are excluded
|
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(
|
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)) {
|