@erclx/canon 4.127.0 → 5.2.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 +5 -7
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/references/verb-absent.md +0 -1
- package/claude/skills/canon-feedback/REQUIREMENT.md +1 -1
- package/claude/skills/canon-feedback/SKILL.md +2 -2
- package/claude/skills/canon-operator/REQUIREMENT.md +2 -2
- package/claude/skills/canon-operator/SKILL.md +3 -3
- package/claude/skills/codebase-layout/REQUIREMENT.md +1 -0
- package/claude/skills/codebase-layout/SKILL.md +6 -0
- package/claude/skills/create-rule/REQUIREMENT.md +1 -1
- package/claude/skills/create-standard/REQUIREMENT.md +0 -2
- package/claude/skills/decision-escalate/REQUIREMENT.md +1 -2
- package/claude/skills/draft-context/REQUIREMENT.md +1 -1
- package/claude/skills/draft-docs/REQUIREMENT.md +1 -1
- package/claude/skills/draft-wireframes/REQUIREMENT.md +1 -1
- package/claude/skills/plan-intake/SKILL.md +1 -1
- package/claude/skills/target-setup/REQUIREMENT.md +2 -2
- package/claude/skills/target-setup/SKILL.md +1 -1
- package/claude/skills/target-setup/references/detect.md +1 -1
- package/claude/skills/target-setup/references/indexes.md +1 -5
- package/docs/agents/commands.md +7 -3
- package/docs/agents/counts.md +1 -2
- package/docs/agents/docs.md +1 -1
- package/docs/agents/drift-surfaces.md +6 -4
- package/docs/agents/index.md +1 -1
- package/docs/agents/indexes.md +1 -1
- package/docs/agents/install-and-sync.md +1 -8
- package/docs/agents/markdown-audit.md +2 -2
- package/docs/agents/restated.md +7 -5
- package/docs/agents/review-classification.md +1 -2
- package/docs/agents/scripting.md +2 -3
- package/docs/agents/skills-reach.md +3 -3
- package/docs/agents/state-scoped-risk.md +1 -1
- package/docs/target/index.md +4 -4
- package/docs/target/{target-migrations.md → migrations.md} +17 -1
- package/docs/target/{target-projects.md → projects.md} +5 -7
- package/docs/target/{target-scenarios.md → scenarios.md} +1 -1
- package/docs/target/{target-sync.md → sync.md} +4 -2
- package/docs/workflow/ai-workflow.md +3 -7
- package/docs/workflow/operating-model.md +4 -4
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map.md +1 -2
- package/docs/workflow/visual-design-workflow.md +5 -5
- package/docs/workflow/zshrc-aliases.md +1 -1
- package/package.json +1 -2
- package/scripts/core/check-plugin-boundary.sh +1 -1
- package/scripts/core/install-check.sh +3 -4
- package/scripts/core/regen-hero.sh +2 -9
- package/src/autoship/paths.ts +3 -5
- package/src/claude/cases/authoring.ts +1 -5
- package/src/claude/plugin-update.ts +2 -2
- package/src/claude/skills-reach.ts +1 -2
- package/src/cli.ts +0 -2
- package/src/commands/feedback-format.ts +0 -1
- package/src/commands/gov.ts +8 -7
- package/src/commands/markdown.ts +2 -2
- package/src/commands/upgrade.ts +62 -6
- package/src/counts/catalogs.ts +0 -16
- package/src/counts/scan.ts +1 -1
- package/src/docs/read.ts +25 -25
- package/src/gate/measures.ts +52 -0
- package/src/gate/stages.ts +9 -0
- package/src/gov/restated.ts +60 -22
- package/src/help.ts +0 -2
- package/src/init/steps.ts +1 -1
- package/src/migrate/skill-names.ts +4 -5
- package/src/secrets/shipped.ts +2 -2
- package/src/serve/static.ts +1 -2
- package/src/shipped/references.ts +7 -8
- package/src/sync/engine.ts +2 -2
- package/standards/context.md +2 -0
- package/standards/docs.md +1 -0
- package/standards/index.md +0 -1
- package/standards/skill.md +1 -2
- package/standards/wiki.md +2 -1
- package/tooling/base/reference.md +2 -2
- package/tooling/claude/reference.md +1 -1
- package/claude/skills/create-snippet/REQUIREMENT.md +0 -39
- package/claude/skills/create-snippet/SKILL.md +0 -30
- package/scripts/snippets/create.sh +0 -156
- package/snippets/align.md +0 -12
- package/snippets/claude/decision-memo.md +0 -39
- package/snippets/claude/feature-recap.md +0 -19
- package/snippets/claude/figma-steps.md +0 -24
- package/snippets/compact-summary.md +0 -5
- package/snippets/decision-help.md +0 -6
- package/snippets/meta-prompt.md +0 -14
- package/snippets/research-prompt.md +0 -7
- package/snippets/session-notes.md +0 -11
- package/snippets/snippets.toml +0 -5
- package/snippets/step-by-step.md +0 -10
- package/snippets/web-research.md +0 -21
- package/src/commands/snippets.ts +0 -90
- package/src/snippets/categories.ts +0 -66
- package/src/snippets/list.ts +0 -32
- package/src/snippets/presets.ts +0 -50
- package/standards/snippets.md +0 -76
package/src/commands/upgrade.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Command } from 'commander'
|
|
2
2
|
import { execa } from 'execa'
|
|
3
|
-
import { PROJECT_ROOT } from '@/project-root'
|
|
3
|
+
import { findCheckoutMismatch, PROJECT_ROOT } from '@/project-root'
|
|
4
4
|
import {
|
|
5
5
|
frameError,
|
|
6
6
|
intro,
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
outro,
|
|
11
11
|
select,
|
|
12
12
|
} from '@/ui'
|
|
13
|
+
import { compareVersions, parseVersion } from '@/version/compare'
|
|
13
14
|
import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
|
|
14
15
|
import { detectManager, installCommand, type Manager } from '@/version/manager'
|
|
15
16
|
import {
|
|
@@ -30,7 +31,9 @@ interface UpgradeRecord {
|
|
|
30
31
|
readonly before: string
|
|
31
32
|
readonly after?: string
|
|
32
33
|
readonly latest?: string
|
|
33
|
-
|
|
34
|
+
/** The version of the checkout at cwd, present only on `pending`. */
|
|
35
|
+
readonly checkout?: string
|
|
36
|
+
readonly state: 'upgraded' | 'current' | 'pending' | 'cancelled' | 'refused'
|
|
34
37
|
readonly reason?: string
|
|
35
38
|
/**
|
|
36
39
|
* One rendered line for a caller that reports the outcome without parsing
|
|
@@ -117,7 +120,24 @@ async function runUpgrade(opts: UpgradeOptions): Promise<number> {
|
|
|
117
120
|
logStep('Published')
|
|
118
121
|
logInfo(describeSkew(skew))
|
|
119
122
|
|
|
123
|
+
const checkout = readCheckoutVersion()
|
|
124
|
+
const latest = latestOf(skew)
|
|
125
|
+
|
|
120
126
|
if (skew.state === 'current') {
|
|
127
|
+
const note = pendingNote(latest, checkout)
|
|
128
|
+
if (note !== undefined && checkout !== undefined) {
|
|
129
|
+
logWarn(note)
|
|
130
|
+
outro()
|
|
131
|
+
emit(opts, {
|
|
132
|
+
...base(before, manager, command, skew),
|
|
133
|
+
after: before,
|
|
134
|
+
checkout,
|
|
135
|
+
state: 'pending',
|
|
136
|
+
message: `CLI stays at ${before}. ${note}`,
|
|
137
|
+
})
|
|
138
|
+
return 0
|
|
139
|
+
}
|
|
140
|
+
|
|
121
141
|
outro()
|
|
122
142
|
emit(opts, {
|
|
123
143
|
...base(before, manager, command, skew),
|
|
@@ -128,7 +148,7 @@ async function runUpgrade(opts: UpgradeOptions): Promise<number> {
|
|
|
128
148
|
return 0
|
|
129
149
|
}
|
|
130
150
|
|
|
131
|
-
return await applyUpgrade(opts, before, skew, manager, command)
|
|
151
|
+
return await applyUpgrade(opts, before, skew, manager, command, checkout)
|
|
132
152
|
}
|
|
133
153
|
|
|
134
154
|
/**
|
|
@@ -147,6 +167,7 @@ async function applyUpgrade(
|
|
|
147
167
|
skew: SkewReport,
|
|
148
168
|
manager: Manager,
|
|
149
169
|
command: readonly string[],
|
|
170
|
+
checkout: string | undefined,
|
|
150
171
|
): Promise<number> {
|
|
151
172
|
const proceed = await select({
|
|
152
173
|
message: `Run \`${command.join(' ')}\`?`,
|
|
@@ -199,15 +220,50 @@ async function applyUpgrade(
|
|
|
199
220
|
...base(before, manager, command, skew),
|
|
200
221
|
after,
|
|
201
222
|
state: 'upgraded',
|
|
202
|
-
message: upgradedMessage(before, after),
|
|
223
|
+
message: [upgradedMessage(before, after), pendingNote(after, checkout)]
|
|
224
|
+
.filter((part) => part !== undefined)
|
|
225
|
+
.join(' '),
|
|
203
226
|
})
|
|
204
227
|
return 0
|
|
205
228
|
}
|
|
206
229
|
|
|
230
|
+
/**
|
|
231
|
+
* The version of the checkout the caller stands in, read off cwd rather than
|
|
232
|
+
* off `PROJECT_ROOT`, which is the installed package a bare `canon` resolves
|
|
233
|
+
* to. A target project sits in no canon checkout, so this reads nothing there
|
|
234
|
+
* and the verb behaves as it did.
|
|
235
|
+
*/
|
|
236
|
+
function readCheckoutVersion(): string | undefined {
|
|
237
|
+
const dir = findCheckoutMismatch(process.cwd())
|
|
238
|
+
return dir === undefined ? undefined : readInstalled(dir).version
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The clause a caller appends when the checkout carries a release npm does not
|
|
243
|
+
* serve yet. A pull lands the release commit before the publish job finishes,
|
|
244
|
+
* so for a few minutes the checkout leads the registry, and a release that
|
|
245
|
+
* never publishes keeps it leading. The wording names the served version
|
|
246
|
+
* rather than promising a publish for that reason. `undefined` covers every
|
|
247
|
+
* case with nothing to say, including a version that does not parse.
|
|
248
|
+
*/
|
|
249
|
+
export function pendingNote(
|
|
250
|
+
served: string | undefined,
|
|
251
|
+
checkout: string | undefined,
|
|
252
|
+
): string | undefined {
|
|
253
|
+
if (served === undefined || checkout === undefined) return undefined
|
|
254
|
+
|
|
255
|
+
const local = parseVersion(checkout)
|
|
256
|
+
const published = parseVersion(served)
|
|
257
|
+
if (local === undefined || published === undefined) return undefined
|
|
258
|
+
if (compareVersions(local, published) <= 0) return undefined
|
|
259
|
+
|
|
260
|
+
return `The checkout is at ${checkout} and npm serves ${served}.`
|
|
261
|
+
}
|
|
262
|
+
|
|
207
263
|
export function upgradedMessage(before: string, after: string): string {
|
|
208
264
|
return after === before
|
|
209
|
-
? `
|
|
210
|
-
: `
|
|
265
|
+
? `CLI reinstalled ${after}, unchanged.`
|
|
266
|
+
: `CLI upgraded ${before} to ${after}.`
|
|
211
267
|
}
|
|
212
268
|
|
|
213
269
|
function base(
|
package/src/counts/catalogs.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { join } from 'node:path'
|
|
|
3
3
|
import { AUDITS } from '@/audits/catalog'
|
|
4
4
|
import { listSkills } from '@/claude/skills-list'
|
|
5
5
|
import { buildGovCatalog } from '@/gov/list'
|
|
6
|
-
import { buildSnippetsCatalog } from '@/snippets/list'
|
|
7
6
|
import { listStandards } from '@/standards/read'
|
|
8
7
|
|
|
9
8
|
export interface Catalog {
|
|
@@ -39,16 +38,6 @@ function countCommands(root: string): number | undefined {
|
|
|
39
38
|
return [...readFileSync(cli, 'utf8').matchAll(REGISTER_IMPORT)].length
|
|
40
39
|
}
|
|
41
40
|
|
|
42
|
-
/**
|
|
43
|
-
* The distinct entries across every category, matching what `regen-hero.sh`
|
|
44
|
-
* counts. A snippet reachable from two categories is one snippet, not two.
|
|
45
|
-
*/
|
|
46
|
-
function countSnippets(root: string): number {
|
|
47
|
-
const catalog = buildSnippetsCatalog(root)
|
|
48
|
-
return new Set(catalog.categories.flatMap((category) => category.entries))
|
|
49
|
-
.size
|
|
50
|
-
}
|
|
51
|
-
|
|
52
41
|
/**
|
|
53
42
|
* The closed set of catalogs this sweep counts a document against.
|
|
54
43
|
*
|
|
@@ -75,11 +64,6 @@ export const CATALOGS: readonly Catalog[] = [
|
|
|
75
64
|
nouns: ['standard', 'standards'],
|
|
76
65
|
count: (root) => listStandards(root).length,
|
|
77
66
|
},
|
|
78
|
-
{
|
|
79
|
-
id: 'snippets',
|
|
80
|
-
nouns: ['snippet', 'snippets'],
|
|
81
|
-
count: countSnippets,
|
|
82
|
-
},
|
|
83
67
|
{
|
|
84
68
|
id: 'commands',
|
|
85
69
|
nouns: ['command', 'commands'],
|
package/src/counts/scan.ts
CHANGED
|
@@ -153,7 +153,7 @@ const ARTICLES = ['the', 'a', 'an']
|
|
|
153
153
|
* either number.
|
|
154
154
|
*
|
|
155
155
|
* The optional word between the number and the noun is what reaches a form
|
|
156
|
-
* like `installs 11 shipped
|
|
156
|
+
* like `installs 11 shipped rules` without also reaching past an
|
|
157
157
|
* intervening clause, since a wider gap would start pairing a number in one
|
|
158
158
|
* clause with a noun in the next. Requiring the noun's own plural form to
|
|
159
159
|
* double as its match narrows the corpus this reads without hand-listing
|
package/src/docs/read.ts
CHANGED
|
@@ -56,22 +56,22 @@ export function resolveTopic(
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
|
-
* Names every sub-area file called `<topic>.md
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
59
|
+
* Names every sub-area file called `<topic>.md` in the first root whose folders
|
|
60
|
+
* carry one, so the root precedence above holds one folder down as well. A name
|
|
61
|
+
* carried by more than one folder of that root resolves to none of them, since
|
|
62
|
+
* answering a bare `overview` with whichever folder sorts first is a confident
|
|
63
|
+
* wrong answer where the miss is a listing of what the caller could have typed.
|
|
63
64
|
*/
|
|
64
65
|
function findLeaves(root: string, topic: string): string[] {
|
|
65
|
-
const matches: string[] = []
|
|
66
|
-
|
|
67
66
|
for (const dir of ROOTS) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
const matches = catalogedFolders(join(root, dir))
|
|
68
|
+
.map((folder) => join(dir, folder, `${topic}.md`))
|
|
69
|
+
.filter((rel) => existsSync(join(root, rel)))
|
|
70
|
+
|
|
71
|
+
if (matches.length > 0) return matches
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
return
|
|
74
|
+
return []
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/**
|
|
@@ -100,20 +100,20 @@ export function listTopics(root: string): string[] {
|
|
|
100
100
|
const leaves = ROOTS.map((dir) => collectLeaves(root, dir))
|
|
101
101
|
|
|
102
102
|
const taken = new Set(named.flat())
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
].sort()
|
|
116
|
-
)
|
|
103
|
+
const claimed = new Set<string>()
|
|
104
|
+
|
|
105
|
+
return ROOTS.flatMap((_, position) => {
|
|
106
|
+
const here = leaves[position] as string[]
|
|
107
|
+
const reachable = [...new Set(here)].filter(
|
|
108
|
+
(name) =>
|
|
109
|
+
!taken.has(name) &&
|
|
110
|
+
!claimed.has(name) &&
|
|
111
|
+
here.indexOf(name) === here.lastIndexOf(name),
|
|
112
|
+
)
|
|
113
|
+
for (const name of here) claimed.add(name)
|
|
114
|
+
|
|
115
|
+
return [...(named[position] as string[]), ...reachable].sort()
|
|
116
|
+
})
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
/** The sibling files and split folders of one root, which shadow every leaf. */
|
package/src/gate/measures.ts
CHANGED
|
@@ -361,6 +361,58 @@ export const documentCeiling: Measure = async (ctx) => {
|
|
|
361
361
|
}
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
+
/**
|
|
365
|
+
* Whether a path's stem, its basename up to the first `.`, repeats its
|
|
366
|
+
* immediate parent folder, either whole or as a `<folder>-` prefix.
|
|
367
|
+
*
|
|
368
|
+
* Exact rather than singular-aware, so `standards/standard.md` passes. A false
|
|
369
|
+
* positive on a gate stage teaches a contributor to route around it, and a
|
|
370
|
+
* widened match is cheap to add once a real echo slips through.
|
|
371
|
+
*/
|
|
372
|
+
export function isFolderEchoedName(path: string): boolean {
|
|
373
|
+
const segments = path.split('/')
|
|
374
|
+
if (segments.length < 2) return false
|
|
375
|
+
const folder = segments[segments.length - 2] ?? ''
|
|
376
|
+
const stem = (segments[segments.length - 1] ?? '').split('.')[0] ?? ''
|
|
377
|
+
if (folder === '' || stem === '') return false
|
|
378
|
+
return stem === folder || stem.startsWith(`${folder}-`)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Every tracked file whose name repeats the folder it sits in, which says the
|
|
383
|
+
* folder's name twice in every path that cites it.
|
|
384
|
+
*
|
|
385
|
+
* Reads paths only, so it runs over the whole tree rather than the markdown
|
|
386
|
+
* corpus. It stays out of `canon markdown audit`, which runs inside every
|
|
387
|
+
* target, where a folder-named file may be a convention the target chose.
|
|
388
|
+
*/
|
|
389
|
+
export const folderEchoedNames: Measure = async (ctx) => {
|
|
390
|
+
const files = await listRepositoryFiles(ctx.root)
|
|
391
|
+
if (files === undefined) {
|
|
392
|
+
return {
|
|
393
|
+
emissions: [],
|
|
394
|
+
unmeasured:
|
|
395
|
+
'The tracked file list could not be read, so no filename was checked.',
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const echoed = files.filter(isFolderEchoedName)
|
|
400
|
+
if (echoed.length === 0) {
|
|
401
|
+
return {
|
|
402
|
+
emissions: [
|
|
403
|
+
info(
|
|
404
|
+
`No filename repeats its folder across ${plural(files.length, 'tracked file')}`,
|
|
405
|
+
),
|
|
406
|
+
],
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
emissions: echoed.map((path) => warn(`${path} repeats its folder's name`)),
|
|
412
|
+
failure: `${echoed.length === 1 ? 'One tracked file repeats its' : `${echoed.length} tracked files repeat their`} folder's name: ${echoed.join(', ')}. Rename each for what it covers inside the folder, dropping the prefix the folder already states.`,
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
364
416
|
/**
|
|
365
417
|
* A second run of the records move should rewrite nothing, and the count is
|
|
366
418
|
* only knowable once the folders themselves have landed.
|
package/src/gate/stages.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
captureStamps,
|
|
5
5
|
clientCommandCitations,
|
|
6
6
|
documentCeiling,
|
|
7
|
+
folderEchoedNames,
|
|
7
8
|
markdownBans,
|
|
8
9
|
type Measure,
|
|
9
10
|
pluginManifests,
|
|
@@ -366,6 +367,14 @@ export const STAGES: readonly Stage[] = [
|
|
|
366
367
|
label: 'Document ceiling',
|
|
367
368
|
checks: [{ kind: 'measure', measure: documentCeiling }],
|
|
368
369
|
},
|
|
370
|
+
{
|
|
371
|
+
// Unscoped, since any branch can add a file anywhere and one git ls-files
|
|
372
|
+
// costs nothing. Reads paths rather than markdown, so it sits here rather
|
|
373
|
+
// than in the markdown audit that also runs inside every target.
|
|
374
|
+
id: 'folder-echoed-names',
|
|
375
|
+
label: 'Folder-echoed filenames',
|
|
376
|
+
checks: [{ kind: 'measure', measure: folderEchoedNames }],
|
|
377
|
+
},
|
|
369
378
|
{
|
|
370
379
|
// A rule citing a file that moved fails silently. The consumed-copy drift
|
|
371
380
|
// stage passes an authored rule and its copy that are wrong together, and
|
package/src/gov/restated.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
+
import { RULE_DIRS } from '@/gov/citations'
|
|
3
4
|
|
|
4
5
|
/** The always-loaded file whose bullets are the subjects this sweep matches. */
|
|
5
6
|
export const INSTRUCTIONS_REL = 'CLAUDE.md'
|
|
@@ -11,15 +12,26 @@ export const SEED_REL = join('tooling', 'claude', 'seeds', 'CLAUDE.md')
|
|
|
11
12
|
export const SHIPPED_SKILLS_REL = join('claude', 'skills')
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
* The project's own skill bodies. Here that is the toolkit's internal skills,
|
|
16
|
+
* and in a target it is that project's own, which is equally a place a rule
|
|
17
|
+
* gets restated in prose.
|
|
18
|
+
*/
|
|
19
|
+
export const PROJECT_SKILLS_REL = join('.claude', 'skills')
|
|
20
|
+
|
|
21
|
+
/** Every root a candidate body is read from, each reported under its own path. */
|
|
22
|
+
const SKILL_ROOTS: readonly string[] = [SHIPPED_SKILLS_REL, PROJECT_SKILLS_REL]
|
|
23
|
+
|
|
24
|
+
/*
|
|
25
|
+
* Path-scoped rules are read from every root in `RULE_DIRS`, the authoring
|
|
26
|
+
* roots rather than the consumed copy under `.claude/rules/`. A stack takes a
|
|
27
|
+
* whole rule folder, so a bullet landing anywhere under `governance/rules/`
|
|
28
|
+
* ships to every target the stack reaches, the same way a bullet in the
|
|
29
|
+
* always-loaded file or the seed does, and `internal/rules/` loads the same
|
|
30
|
+
* way for every session in this repository. That is what makes a rule an
|
|
31
|
+
* instruction surface rather than a place a rule is merely quoted, and it is
|
|
32
|
+
* why a bullet moved into either root from the seed still belongs to the
|
|
33
|
+
* corpus this sweep reads rather than leaving it.
|
|
21
34
|
*/
|
|
22
|
-
export const RULES_REL = join('governance', 'rules')
|
|
23
35
|
|
|
24
36
|
/**
|
|
25
37
|
* Path pairs whose duplication is deliberate and already recorded.
|
|
@@ -174,6 +186,22 @@ const STOPWORDS = new Set([
|
|
|
174
186
|
*/
|
|
175
187
|
const PROHIBITIONS = ['never', 'do not', "don't", 'avoid', 'refuse']
|
|
176
188
|
|
|
189
|
+
/**
|
|
190
|
+
* A `no` opening a clause or a comma-set phrase directly on a code span, as in
|
|
191
|
+
* `pinned to major tags, no `@latest``.
|
|
192
|
+
*
|
|
193
|
+
* `no` is admitted here and only here, since sitting on a backticked
|
|
194
|
+
* identifier it names the thing ruled out rather than qualifying a verb. The
|
|
195
|
+
* phrase boundary is what keeps description out: `returns no `jq` output` puts
|
|
196
|
+
* the marker after a verb and reports what happens.
|
|
197
|
+
*
|
|
198
|
+
* `not` and `never` stay out. Admitting them turned two agreeing pairs into
|
|
199
|
+
* contradictions, `, not `pwd`` and `, never `@latest``, because the subject
|
|
200
|
+
* each matched spelled its own negation on plain words or in a clause the
|
|
201
|
+
* anchors tied against, so the reading flipped on one side alone.
|
|
202
|
+
*/
|
|
203
|
+
const NEGATED_SPAN = /(?:^|[,;:(])\s*no\s+`/
|
|
204
|
+
|
|
177
205
|
export type RestatedRefusal = 'no-instructions' | 'no-surfaces'
|
|
178
206
|
|
|
179
207
|
export type Restatement = 'mirror' | 'repetition' | 'contradiction'
|
|
@@ -315,7 +343,10 @@ function prohibits(text: string): boolean {
|
|
|
315
343
|
.replace(/^[^a-z]*/, '')
|
|
316
344
|
.replace(/^(and|but|so|then|also|however)[\s,]+/, '')
|
|
317
345
|
|
|
318
|
-
return
|
|
346
|
+
return (
|
|
347
|
+
PROHIBITIONS.some((marker) => opening.startsWith(marker)) ||
|
|
348
|
+
NEGATED_SPAN.test(opening)
|
|
349
|
+
)
|
|
319
350
|
}
|
|
320
351
|
|
|
321
352
|
/**
|
|
@@ -465,7 +496,10 @@ function readBullets(root: string, relative: string): Statement[] {
|
|
|
465
496
|
* is reused per file rather than rebuilt for prose, and frontmatter is read
|
|
466
497
|
* past for the same reason it is: no line there starts with `- `.
|
|
467
498
|
*/
|
|
468
|
-
function readRuleFiles(root: string,
|
|
499
|
+
function readRuleFiles(root: string, rulesRel: string): Statement[] {
|
|
500
|
+
const rulesRoot = join(root, rulesRel)
|
|
501
|
+
if (!existsSync(rulesRoot)) return []
|
|
502
|
+
|
|
469
503
|
const files = [
|
|
470
504
|
...new Bun.Glob('**/*.md').scanSync({ cwd: rulesRoot, onlyFiles: true }),
|
|
471
505
|
].sort()
|
|
@@ -473,13 +507,13 @@ function readRuleFiles(root: string, rulesRoot: string): Statement[] {
|
|
|
473
507
|
return files.flatMap((file) =>
|
|
474
508
|
readBullets(
|
|
475
509
|
root,
|
|
476
|
-
`${
|
|
510
|
+
`${rulesRel.replaceAll('\\', '/')}/${file.replaceAll('\\', '/')}`,
|
|
477
511
|
),
|
|
478
512
|
)
|
|
479
513
|
}
|
|
480
514
|
|
|
481
515
|
/**
|
|
482
|
-
* Every prose line and bullet in a
|
|
516
|
+
* Every prose line and bullet in a skill body under one skills root.
|
|
483
517
|
*
|
|
484
518
|
* Wider than the bullet rule above because the motivating case was stated in a
|
|
485
519
|
* body as a paragraph, so a bullet-only read would miss the one instance this
|
|
@@ -487,7 +521,10 @@ function readRuleFiles(root: string, rulesRoot: string): Statement[] {
|
|
|
487
521
|
* heading names a section rather than stating a rule, and a fenced block is an
|
|
488
522
|
* example whose words are the surrounding prose's by construction.
|
|
489
523
|
*/
|
|
490
|
-
function readBodyLines(root: string,
|
|
524
|
+
function readBodyLines(root: string, skillsRel: string): Candidate[] {
|
|
525
|
+
const skillsRoot = join(root, skillsRel)
|
|
526
|
+
if (!existsSync(skillsRoot)) return []
|
|
527
|
+
|
|
491
528
|
const candidates: Candidate[] = []
|
|
492
529
|
|
|
493
530
|
const files = [
|
|
@@ -500,7 +537,7 @@ function readBodyLines(root: string, skillsRoot: string): Candidate[] {
|
|
|
500
537
|
for (const file of files) {
|
|
501
538
|
const posix = file.replaceAll('\\', '/')
|
|
502
539
|
const skill = posix.split('/')[0]
|
|
503
|
-
const relative = `${
|
|
540
|
+
const relative = `${skillsRel.replaceAll('\\', '/')}/${posix}`
|
|
504
541
|
|
|
505
542
|
const lines = readFileSync(join(root, relative), 'utf8').split('\n')
|
|
506
543
|
let fenced = false
|
|
@@ -639,8 +676,9 @@ function classify(
|
|
|
639
676
|
* defect the sweep exists for, and a recall-first reading can be narrowed from
|
|
640
677
|
* real output where the reverse cannot.
|
|
641
678
|
*
|
|
642
|
-
* The always-loaded file and every rule
|
|
643
|
-
*
|
|
679
|
+
* The always-loaded file and every rule under either rule root each open the
|
|
680
|
+
* search as a subject, and the seed, the skill bodies under both skill roots,
|
|
681
|
+
* and every rule again close it as a candidate.
|
|
644
682
|
* A rule sits on both sides because a stack ships a whole rule folder, so a
|
|
645
683
|
* bullet duplicated between two rules reaches a target exactly as a bullet
|
|
646
684
|
* duplicated between the always-loaded file and a rule does, and neither shape
|
|
@@ -660,14 +698,14 @@ export function readRestated(root: string): RestatedReport {
|
|
|
660
698
|
kind: 'seed' as const,
|
|
661
699
|
}))
|
|
662
700
|
|
|
663
|
-
const
|
|
664
|
-
|
|
701
|
+
const bodies = SKILL_ROOTS.flatMap((skillsRel) =>
|
|
702
|
+
readBodyLines(root, skillsRel),
|
|
703
|
+
)
|
|
665
704
|
const bodyFiles = new Set(bodies.map((candidate) => candidate.file)).size
|
|
666
705
|
|
|
667
|
-
const
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
: []
|
|
706
|
+
const ruleStatements = RULE_DIRS.flatMap((rulesRel) =>
|
|
707
|
+
readRuleFiles(root, rulesRel),
|
|
708
|
+
)
|
|
671
709
|
const rules: Candidate[] = ruleStatements.map((statement) => ({
|
|
672
710
|
...statement,
|
|
673
711
|
kind: 'rule' as const,
|
package/src/help.ts
CHANGED
|
@@ -26,7 +26,6 @@ export const COMMAND_GROUPS: { title: string; rows: CommandRow[] }[] = [
|
|
|
26
26
|
rows: [
|
|
27
27
|
['gov [cmd]', 'Governance rules (install, sync, list)'],
|
|
28
28
|
['standards [cmd]', 'Standards (install, sync, list, <name>)'],
|
|
29
|
-
['snippets [cmd]', 'Prompt snippets (create, list)'],
|
|
30
29
|
['tooling [cmd]', 'Tooling stacks (sync, ref, create)'],
|
|
31
30
|
['claude [cmd]', 'Claude workflow (init, sync, setup)'],
|
|
32
31
|
['wiki [cmd]', 'Wiki pages (init)'],
|
|
@@ -93,7 +92,6 @@ const EXAMPLES = [
|
|
|
93
92
|
'canon gov sync ../my-app',
|
|
94
93
|
'canon gov restated --json',
|
|
95
94
|
'canon standards markdown',
|
|
96
|
-
'canon snippets list',
|
|
97
95
|
'canon init ../my-app',
|
|
98
96
|
'canon tooling sync base',
|
|
99
97
|
'canon tooling create',
|
package/src/init/steps.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { DomainStep } from '@/init/run'
|
|
|
5
5
|
export type RunFactory = (args: readonly string[]) => () => Promise<boolean>
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* States the same fact as `docs/target/
|
|
8
|
+
* States the same fact as `docs/target/migrations.md`'s backup section in its
|
|
9
9
|
* own words. Keep the two in step by hand; nothing compares them.
|
|
10
10
|
*/
|
|
11
11
|
const RECORDS_SETUP_NOTICE =
|
|
@@ -93,11 +93,10 @@ export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
|
|
|
93
93
|
* one destination, so the ordering is asserted directly.
|
|
94
94
|
*
|
|
95
95
|
* It matches whole tokens. A skill name is a complete name rather than a word
|
|
96
|
-
* stem, and
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* would have moved to a name the folder's own convention contradicts.
|
|
96
|
+
* stem, and a word can open with a name and mean something else: a filename
|
|
97
|
+
* such as `claude-worktrees.md` names the harness feature, not the skill, and
|
|
98
|
+
* a stem matching by prefix would move it to a name that no longer describes
|
|
99
|
+
* what the file documents.
|
|
101
100
|
*
|
|
102
101
|
* No article fixup travels with it. `auto-ship` is the one destination opening
|
|
103
102
|
* on a vowel sound, and the corpus spells an article before it once, which is
|
package/src/secrets/shipped.ts
CHANGED
|
@@ -13,8 +13,8 @@ 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, and its `standards`
|
|
17
|
-
*
|
|
16
|
+
* too, and its `standards` symlink resolves into a tree the field lists in its
|
|
17
|
+
* own right, so both routes land inside the same corpus.
|
|
18
18
|
*/
|
|
19
19
|
export type ShipEntries =
|
|
20
20
|
/** The field declares a corpus, which is what this check reads. */
|
package/src/serve/static.ts
CHANGED
|
@@ -186,8 +186,7 @@ export function resolveWithin(
|
|
|
186
186
|
* Re-tests containment after following symlinks. `resolveWithin` is lexical
|
|
187
187
|
* and `resolve` does not follow a link, so a link inside the root clears that
|
|
188
188
|
* test while the file it points at sits outside. This repository is a live
|
|
189
|
-
* instance, since `claude/standards`
|
|
190
|
-
* `claude/`.
|
|
189
|
+
* instance, since `claude/standards` is a link out of `claude/`.
|
|
191
190
|
*
|
|
192
191
|
* Only a path that exists is checked, because a link can be followed only once
|
|
193
192
|
* there is something on the other end, and a path that resolves to nothing is
|
|
@@ -10,8 +10,8 @@ import { isMarked } from '@/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
|
-
* standard through `canon standards`, a rule through a glob match, a
|
|
14
|
-
* through an
|
|
13
|
+
* standard through `canon standards`, a rule through a glob match, and a seed
|
|
14
|
+
* through an install. A `src/` doc comment
|
|
15
15
|
* is read by someone working on the toolkit, in this checkout, where every
|
|
16
16
|
* number resolves.
|
|
17
17
|
*
|
|
@@ -20,17 +20,16 @@ import { isMarked } from '@/exempt-marker'
|
|
|
20
20
|
* `#191512`, which no width or boundary rule separates from a pull request
|
|
21
21
|
* number, so telling them apart would need the line's syntactic position.
|
|
22
22
|
*
|
|
23
|
-
* `claude/standards`
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
27
|
*/
|
|
28
28
|
export const SHIPPED_CORPORA = [
|
|
29
29
|
'claude',
|
|
30
30
|
'docs',
|
|
31
31
|
'governance',
|
|
32
32
|
'scripts',
|
|
33
|
-
'snippets',
|
|
34
33
|
'standards',
|
|
35
34
|
'tooling',
|
|
36
35
|
] as const
|
|
@@ -142,7 +141,7 @@ const SAME_REPOSITORY =
|
|
|
142
141
|
* `canon/context/` carries no equivalent pattern. The same resolution test
|
|
143
142
|
* cannot separate a path every scaffolded project holds, such as
|
|
144
143
|
* `canon/context/index.md`, from this repository's own domain entry, such
|
|
145
|
-
* as `canon/context/indexes.md`, since both resolve here and only the
|
|
144
|
+
* as `canon/context/context-model/indexes.md`, since both resolve here and only the
|
|
146
145
|
* second is a defect. That is a semantic read no pattern makes, so it stays
|
|
147
146
|
* with the rule and the review checklist rather than joining this reader.
|
|
148
147
|
*/
|
package/src/sync/engine.ts
CHANGED
|
@@ -129,11 +129,11 @@ export type NonInteractivePolicy =
|
|
|
129
129
|
/**
|
|
130
130
|
* The two holes every domain sync leaves open: where a destination file's
|
|
131
131
|
* source lives, and what counts as a change beyond a plain content diff.
|
|
132
|
-
* Everything else in a sync is identical across
|
|
132
|
+
* Everything else in a sync is identical across domains,
|
|
133
133
|
* so it lives in the engine.
|
|
134
134
|
*
|
|
135
135
|
* The optional members exist because one adapter needed each of them. Leaving
|
|
136
|
-
* all three unset reproduces the behavior gov
|
|
136
|
+
* all three unset reproduces the behavior gov already had.
|
|
137
137
|
*/
|
|
138
138
|
export interface SyncAdapter {
|
|
139
139
|
readonly banner: string
|
package/standards/context.md
CHANGED
|
@@ -111,6 +111,8 @@ Only the `development` entry carries this section. It is not a general-purpose h
|
|
|
111
111
|
- Retire a decision or gotcha once its subject is gone, rewriting the bullet to state the current design rather than leaving the narration of what it replaced beside it. A rejected alternative is not a retired one, so what was tried and why it lost stays whatever its age. The rule above protects content whose subject is live, and this one releases content whose subject is not.
|
|
112
112
|
- Rewrite a decision a later one replaced rather than appending the replacement beside it. The subject is still live, so the rule above does not reach it, and two bullets on one subject leave a reader to work out which of them is current. State the design that stands and keep the superseded reasoning only where it is the alternative that lost.
|
|
113
113
|
- Split into a folder (`canon/context/<domain>/<sub-area>.md`) when a domain has three or more sub-areas that do not fit cleanly in one file. That split is the natural ceiling.
|
|
114
|
+
- Group whole single-file entries of two or more small domains into one folder when they share a kind, such as small feature domains that each own a verb. Each entry still covers one domain, and the folder's generated `index.md` carries a `subtitle` naming the kind. It names an entry to start with only when that entry frames the others. A grouping folder is not a split, so no entry inside it is a sub-area of another.
|
|
115
|
+
- Name an entry inside a split or grouping folder per the `codebase-layout` skill, which never repeats the folder's name as the entry's stem or prefix.
|
|
114
116
|
- Keep a split domain's `index.md` generated. The catalog body is rewritten on every regen, so the domain's own overview and layout belong in a sibling file rather than in it, and the `subtitle` is what names the file to start with.
|
|
115
117
|
- Author a catalog that grows a row per shipped thing as a bullet list, never a markdown table. A table pads its columns to a shared width, so one row outgrowing its column reflows every row and turns a one-line edit into a whole-file rewrite that conflicts with any sibling branch. This is what forced the split above.
|
|
116
118
|
|
package/standards/docs.md
CHANGED
|
@@ -72,6 +72,7 @@ A page failing these is non-conforming even when it satisfies every shape rule b
|
|
|
72
72
|
- Move every page carrying that value. A shelf split across two depths is harder to scan than either arrangement taken whole.
|
|
73
73
|
- Give the subfolder a catalog page of its own, so the parent lists one entry where it listed several. The parent lists a subfolder as a folder rather than as a member of the shelf it replaced, so nothing on that page restores the grouping.
|
|
74
74
|
- Retarget every link into a moved page in the same change. A link resolving nowhere is what the move costs, and nothing else reports it.
|
|
75
|
+
- Name a moved page per the `codebase-layout` skill, which drops the prefix the subfolder now states.
|
|
75
76
|
|
|
76
77
|
## Template
|
|
77
78
|
|
package/standards/index.md
CHANGED
|
@@ -34,7 +34,6 @@ Reference docs for consistent authoring across the toolkit and target projects.
|
|
|
34
34
|
- [Skill requirement reference](skill-requirement.md): Shape and content rules for the REQUIREMENT.md beside a skill
|
|
35
35
|
- [Claude skill reference](skill.md): Claude skill structure and authoring rules
|
|
36
36
|
- [Slug reference](slug.md): Transform from a git branch name to a slug, and the three responses to an empty result
|
|
37
|
-
- [Snippet reference](snippets.md): Snippet reference and authoring conventions
|
|
38
37
|
- [Standard reference](standard.md): Shape and content rules for authoring a standard
|
|
39
38
|
- [Tasks reference](tasks.md): Folder layout, filename convention, file format, origin lines, and archiving for task files under .canon/tasks/
|
|
40
39
|
- [Teach reference](teach.md): Workspace layout, ordinal naming, frontmatter, and the mission and learning-record formats for a learning workspace
|