@erclx/canon 5.49.0 → 5.51.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/CHANGELOG.md +4024 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/references/fan-in.md +20 -6
- package/claude/skills/canon-cli/SKILL.md +9 -7
- package/claude/skills/canvas-board/REQUIREMENT.md +4 -3
- package/claude/skills/canvas-board/SKILL.md +8 -9
- package/claude/skills/ci-workflow/REQUIREMENT.md +3 -3
- package/claude/skills/ci-workflow/SKILL.md +26 -1
- package/claude/skills/ci-workflow/references/workflows.md +28 -0
- package/claude/skills/context-fold/references/context.md +4 -3
- package/claude/skills/design-record/REQUIREMENT.md +33 -0
- package/claude/skills/design-record/SKILL.md +84 -0
- package/claude/skills/design-record/references/folder.md +69 -0
- package/claude/skills/design-taste/SKILL.md +3 -2
- package/claude/skills/git-followup/SKILL.md +1 -0
- package/claude/skills/git-pr/references/pr.md +1 -1
- package/claude/skills/markdown-craft/REQUIREMENT.md +1 -0
- package/claude/skills/markdown-craft/SKILL.md +6 -1
- package/claude/skills/markdown-craft/references/markdown.md +3 -3
- package/claude/skills/markdown-craft/references/tables.md +30 -0
- package/claude/skills/pick-by-eye/SKILL.md +10 -8
- package/claude/skills/plan-feature/SKILL.md +13 -1
- package/claude/skills/plan-feature/references/batches.md +1 -1
- package/claude/skills/session-worktree/SKILL.md +1 -1
- package/claude/skills/target-check/references/domains.md +8 -6
- package/docs/agents/index.md +2 -2
- package/docs/agents/markdown-audit.md +5 -2
- package/docs/agents/migrate.md +2 -0
- package/docs/agents/targets.md +8 -1
- package/docs/target/migrations.md +8 -1
- package/docs/target/projects.md +1 -1
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map.md +2 -1
- package/docs/workflow/visual-design-workflow.md +2 -1
- package/governance/detect.toml +1 -1
- package/governance/ledger/tooling/ci-workflow.md +6 -0
- package/governance/ledger/ui/design-reference.md +8 -12
- package/governance/rules/tooling/ci-workflow.md +4 -0
- package/governance/rules/ui/design-reference.md +4 -6
- package/package.json +2 -1
- package/scripts/ci/check-browsers.sh +13 -0
- package/scripts/ci/delete-previews.sh +34 -0
- package/scripts/ci/publish-check.sh +29 -0
- package/scripts/ci/refresh-frames-pr.sh +63 -0
- package/scripts/ci/report-baseline.sh +23 -0
- package/scripts/ci/stack-issues.sh +24 -0
- package/scripts/ci/verify-npm-token.sh +14 -0
- package/src/claude/cases/misc.ts +5 -0
- package/src/cli/help.ts +1 -0
- package/src/cli.ts +2 -0
- package/src/commands/changelog.ts +146 -0
- package/src/commands/claude.ts +26 -1
- package/src/commands/gate.ts +57 -16
- package/src/commands/hooks.ts +7 -5
- package/src/commands/markdown.ts +98 -19
- package/src/commands/migrate.ts +77 -0
- package/src/commands/worktrees.ts +273 -0
- package/src/context/audit.ts +6 -5
- package/src/design/budget.ts +1 -1
- package/src/design/components.ts +1 -1
- package/src/design/document.ts +1 -1
- package/src/design/regen.ts +1 -1
- package/src/design/render.ts +1 -1
- package/src/gate/hand-suite.ts +52 -0
- package/src/gate/measures.ts +9 -2
- package/src/gate/sequencer.ts +17 -8
- package/src/gov/detect.ts +28 -10
- package/src/hooks/post-merge.ts +45 -6
- package/src/markdown/gated.ts +5 -7
- package/src/markdown/scope.ts +60 -10
- package/src/markdown/structure.ts +1 -1
- package/src/markdown/tables.ts +150 -0
- package/src/migrate/status.ts +128 -0
- package/src/tasks/validate.ts +15 -12
- package/src/version/changelog.ts +156 -0
- package/src/worktrees/members.ts +540 -0
- package/tooling/base/configs/.husky/post-merge +7 -6
- package/tooling/claude/reference.md +1 -0
- package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +11 -1
- package/tooling/claude/seeds/CLAUDE.md +9 -0
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +10 -28
- package/tooling/cloudflare/configs/scripts/delete-previews.sh +36 -0
- package/tooling/cloudflare/reference.md +7 -4
- package/tooling/web/reference.md +5 -0
- package/tooling/web/seeds/canon/design/index.md +23 -0
- /package/claude/skills/{canvas-board → design-record}/references/design.md +0 -0
package/src/claude/cases/misc.ts
CHANGED
|
@@ -39,6 +39,11 @@ export const MISC_CASES: readonly SkillCase[] = [
|
|
|
39
39
|
'Draw the signup screen as a frame on the canvas, then restyle the element I just selected there.',
|
|
40
40
|
expect: 'canvas-board',
|
|
41
41
|
},
|
|
42
|
+
{
|
|
43
|
+
prompt:
|
|
44
|
+
'The operator picked the new settings panel, so land it in canon/design with an index row for it.',
|
|
45
|
+
expect: 'design-record',
|
|
46
|
+
},
|
|
42
47
|
{
|
|
43
48
|
prompt:
|
|
44
49
|
"We're about to hit the context limit, write the handoff before we lose state.",
|
package/src/cli/help.ts
CHANGED
|
@@ -20,6 +20,7 @@ export const COMMAND_GROUPS: { title: string; rows: CommandRow[] }[] = [
|
|
|
20
20
|
['sync [path]', 'Sync all installed domains in a project'],
|
|
21
21
|
['upgrade', 'Reinstall the CLI with the manager that installed it'],
|
|
22
22
|
['migrate [cmd]', 'Move a project off a retired name or layout'],
|
|
23
|
+
['changelog', 'Read the releases between a stamp and this binary'],
|
|
23
24
|
['targets [cmd]', 'Projects this toolkit installed into'],
|
|
24
25
|
],
|
|
25
26
|
},
|
package/src/cli.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { register as census } from '@/commands/census'
|
|
|
40
40
|
import { register as upstream } from '@/commands/upstream'
|
|
41
41
|
import { register as targets } from '@/commands/targets'
|
|
42
42
|
import { register as upgrade } from '@/commands/upgrade'
|
|
43
|
+
import { register as changelog } from '@/commands/changelog'
|
|
43
44
|
import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
|
|
44
45
|
import { renderHelp } from '@/cli/help'
|
|
45
46
|
import { renderAll, routeHelp } from '@/cli/help-all'
|
|
@@ -100,6 +101,7 @@ upstream(program)
|
|
|
100
101
|
audits(program)
|
|
101
102
|
gate(program)
|
|
102
103
|
upgrade(program)
|
|
104
|
+
changelog(program)
|
|
103
105
|
|
|
104
106
|
const route = routeHelp(program, process.argv.slice(2))
|
|
105
107
|
if (route.kind === 'top') showHelp()
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
import type { Command } from 'commander'
|
|
4
|
+
import { PROJECT_ROOT } from '@/roots/project'
|
|
5
|
+
import { readStamp } from '@/sync/stamp'
|
|
6
|
+
import {
|
|
7
|
+
intro,
|
|
8
|
+
logInfo,
|
|
9
|
+
logStep,
|
|
10
|
+
logWarn,
|
|
11
|
+
outro,
|
|
12
|
+
pipeOutput,
|
|
13
|
+
plural,
|
|
14
|
+
} from '@/cli/ui'
|
|
15
|
+
import { type ChangelogReport, resolveChangelog } from '@/version/changelog'
|
|
16
|
+
import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
|
|
17
|
+
|
|
18
|
+
interface ChangelogOptions {
|
|
19
|
+
readonly since?: string
|
|
20
|
+
readonly root?: string
|
|
21
|
+
readonly json?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const REFUSALS = {
|
|
25
|
+
'no-stamp':
|
|
26
|
+
'the target carries no stamped version and --since named none, so there is no start to range from',
|
|
27
|
+
'no-changelog':
|
|
28
|
+
'the installed package carries no CHANGELOG.md with release entries to read',
|
|
29
|
+
'unknown-version':
|
|
30
|
+
'the changelog lists no such release, so the range would be a guess',
|
|
31
|
+
} as const
|
|
32
|
+
|
|
33
|
+
const HELP = [
|
|
34
|
+
'',
|
|
35
|
+
'Prints the release entries after a version, up to the running one, from the',
|
|
36
|
+
'CHANGELOG.md the installed package ships. It reads no registry and needs no',
|
|
37
|
+
'`gh`, so it answers offline.',
|
|
38
|
+
'',
|
|
39
|
+
'The start defaults to the oldest `version` across the target stamp domains,',
|
|
40
|
+
'since the oldest domain is the one furthest behind. --since overrides it.',
|
|
41
|
+
'',
|
|
42
|
+
'Exit codes:',
|
|
43
|
+
' 0 the range was read, empty when the target is already current',
|
|
44
|
+
' 1 refused, with the reason on stderr',
|
|
45
|
+
'',
|
|
46
|
+
'A refusal names a "reason" rather than printing an empty range. no-stamp',
|
|
47
|
+
'means no start was recorded or given, no-changelog means the file or its',
|
|
48
|
+
'release headings are missing, and unknown-version means the start or the',
|
|
49
|
+
'running version is absent from the file, which covers a pre-release and a',
|
|
50
|
+
'start newer than the binary.',
|
|
51
|
+
'',
|
|
52
|
+
'The JSON carries "since", "running", and "entries", each an object with the',
|
|
53
|
+
'release "version" and its raw "markdown". Read the record rather than the',
|
|
54
|
+
'exit when a skill consumes this, since a shell function wrapping the binary',
|
|
55
|
+
'may flatten the status.',
|
|
56
|
+
'',
|
|
57
|
+
'Examples:',
|
|
58
|
+
' canon changelog',
|
|
59
|
+
' canon changelog --since 5.47.0 --json',
|
|
60
|
+
' canon changelog --root ../frameboard',
|
|
61
|
+
'',
|
|
62
|
+
].join('\n')
|
|
63
|
+
|
|
64
|
+
export function register(program: Command): void {
|
|
65
|
+
program
|
|
66
|
+
.command('changelog')
|
|
67
|
+
.description(
|
|
68
|
+
'Print the release entries after a version up to the running one',
|
|
69
|
+
)
|
|
70
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
71
|
+
.option(
|
|
72
|
+
'--since <version>',
|
|
73
|
+
'Start after this version instead of the stamp',
|
|
74
|
+
)
|
|
75
|
+
.option('--root <path>', 'Target whose stamp sets the start', '.')
|
|
76
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
77
|
+
.addHelpText('after', HELP)
|
|
78
|
+
.action((opts: ChangelogOptions) => {
|
|
79
|
+
process.exitCode = runChangelog(opts)
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readChangelogText(): string | undefined {
|
|
84
|
+
const path = join(PROJECT_ROOT, 'CHANGELOG.md')
|
|
85
|
+
|
|
86
|
+
return existsSync(path) ? readFileSync(path, 'utf8') : undefined
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function stampedVersions(root: string): string[] {
|
|
90
|
+
const stamp = readStamp(root)
|
|
91
|
+
if (stamp === undefined) return []
|
|
92
|
+
|
|
93
|
+
return Object.values(stamp.domains)
|
|
94
|
+
.map((domain) => domain?.version)
|
|
95
|
+
.filter((version): version is string => version !== undefined)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function runChangelog(opts: ChangelogOptions): number {
|
|
99
|
+
intro('canon changelog')
|
|
100
|
+
|
|
101
|
+
const root = resolve(opts.root ?? '.')
|
|
102
|
+
const report = resolveChangelog({
|
|
103
|
+
text: readChangelogText(),
|
|
104
|
+
running: readInstalled().version ?? UNKNOWN_LABEL,
|
|
105
|
+
since: opts.since,
|
|
106
|
+
stamped: stampedVersions(root),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
if (report.kind === 'range') {
|
|
110
|
+
reportRange(report)
|
|
111
|
+
} else {
|
|
112
|
+
reportRefusal(report)
|
|
113
|
+
}
|
|
114
|
+
outro()
|
|
115
|
+
|
|
116
|
+
if (opts.json) {
|
|
117
|
+
process.stdout.write(`${JSON.stringify(report)}\n`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return report.kind === 'range' ? 0 : 1
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function reportRange(
|
|
124
|
+
report: Extract<ChangelogReport, { kind: 'range' }>,
|
|
125
|
+
): void {
|
|
126
|
+
logStep('Range')
|
|
127
|
+
logInfo(`${report.since} to ${report.running}`)
|
|
128
|
+
|
|
129
|
+
if (report.entries.length === 0) {
|
|
130
|
+
logInfo('No release after the start, so nothing is pending')
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
logStep(plural(report.entries.length, 'release'))
|
|
135
|
+
for (const entry of report.entries) {
|
|
136
|
+
pipeOutput(entry.markdown)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function reportRefusal(
|
|
141
|
+
report: Extract<ChangelogReport, { kind: 'refused' }>,
|
|
142
|
+
): void {
|
|
143
|
+
logStep('Refused')
|
|
144
|
+
const version = report.version === undefined ? '' : ` (${report.version})`
|
|
145
|
+
logWarn(`${report.reason}${version}: ${REFUSALS[report.reason]}`)
|
|
146
|
+
}
|
package/src/commands/claude.ts
CHANGED
|
@@ -71,6 +71,9 @@ import {
|
|
|
71
71
|
serializeSettings,
|
|
72
72
|
writeSettings,
|
|
73
73
|
} from '@/claude/settings'
|
|
74
|
+
import { bodyLines } from '@/markdown/scan'
|
|
75
|
+
import { scopedDensityFindings } from '@/markdown/scope'
|
|
76
|
+
import { CAP } from '@/markdown/stacks'
|
|
74
77
|
import { readSessionDefaults } from '@/migrate/session-defaults'
|
|
75
78
|
import { copyPreservingMode } from '@/sync/copy'
|
|
76
79
|
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
|
|
@@ -176,6 +179,22 @@ export function seededDirPath(resolved: string, name: string): string {
|
|
|
176
179
|
? surfaceDir(resolved, name)
|
|
177
180
|
: recordDir(resolved, name)
|
|
178
181
|
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Each bullet run past the limit in the target's `CLAUDE.md` Key paths, read
|
|
185
|
+
* through the scope the audit gates on. Sync warns rather than gates, since a
|
|
186
|
+
* target gates only the prefixes its own config lists.
|
|
187
|
+
*/
|
|
188
|
+
export async function longKeyPathsRuns(
|
|
189
|
+
resolved: string,
|
|
190
|
+
): Promise<{ line: number; count: number }[]> {
|
|
191
|
+
const path = join(resolved, 'CLAUDE.md')
|
|
192
|
+
if (!existsSync(path)) return []
|
|
193
|
+
const lines = bodyLines(await readFile(path, 'utf8'))
|
|
194
|
+
return scopedDensityFindings('CLAUDE.md', lines)
|
|
195
|
+
.filter((finding) => finding.kind === 'bullet-run')
|
|
196
|
+
.map((finding) => ({ line: finding.line, count: finding.value ?? 0 }))
|
|
197
|
+
}
|
|
179
198
|
const USER_DIR = join('tooling', 'claude', 'user')
|
|
180
199
|
const STATUSLINE = 'statusline-command.sh'
|
|
181
200
|
const PLUGIN_MANIFEST = join('claude', '.claude-plugin', 'plugin.json')
|
|
@@ -244,7 +263,8 @@ export function register(program: Command): void {
|
|
|
244
263
|
'Writes .gitignore alone: adds the entries the claude manifest lists',
|
|
245
264
|
'and drops ones under its headers that the manifest no longer lists.',
|
|
246
265
|
'A seeded file is reported, never rewritten, and a missing one points',
|
|
247
|
-
'at `canon claude init`.',
|
|
266
|
+
'at `canon claude init`. A CLAUDE.md Key paths running past 5 bullets',
|
|
267
|
+
'is reported with the H3 grouping that fixes it.',
|
|
248
268
|
'',
|
|
249
269
|
'Exit codes:',
|
|
250
270
|
' 0 synced, already up to date, or the prompt was cancelled',
|
|
@@ -665,6 +685,11 @@ async function runSync(target: string): Promise<number> {
|
|
|
665
685
|
if (isDirectory(seededDirPath(resolved, name))) logInfo(`${name}/`)
|
|
666
686
|
else logWarn(`${name}/ missing. Run \`canon claude init\``)
|
|
667
687
|
}
|
|
688
|
+
for (const run of await longKeyPathsRuns(resolved)) {
|
|
689
|
+
logWarn(
|
|
690
|
+
`CLAUDE.md:${run.line} runs ${run.count} Key paths bullets past ${CAP}. Group them under H3s, each opening on a lead sentence`,
|
|
691
|
+
)
|
|
692
|
+
}
|
|
668
693
|
|
|
669
694
|
// Sync never rewrites an existing settings file, so a target seeded before
|
|
670
695
|
// the session defaults learns of them only here.
|
package/src/commands/gate.ts
CHANGED
|
@@ -130,22 +130,9 @@ async function runGate(opts: RunCommandOptions): Promise<number> {
|
|
|
130
130
|
process.stderr.write(`${failed.label}: ${failed.failure}\n`)
|
|
131
131
|
}
|
|
132
132
|
process.stdout.write(
|
|
133
|
-
`${JSON.stringify(
|
|
134
|
-
ok: code === 0,
|
|
135
|
-
|
|
136
|
-
scoped: changed.scoped,
|
|
137
|
-
changed: changed.files.length,
|
|
138
|
-
summary,
|
|
139
|
-
ms: results.reduce((total, result) => total + result.ms, 0),
|
|
140
|
-
stages: results.map(({ id, label, status, failure, ms, queuedMs }) => ({
|
|
141
|
-
id,
|
|
142
|
-
label,
|
|
143
|
-
status,
|
|
144
|
-
failure,
|
|
145
|
-
ms,
|
|
146
|
-
queuedMs,
|
|
147
|
-
})),
|
|
148
|
-
})}\n`,
|
|
133
|
+
`${JSON.stringify(
|
|
134
|
+
gateRecord({ ok: code === 0, root, changed, summary, results }),
|
|
135
|
+
)}\n`,
|
|
149
136
|
)
|
|
150
137
|
return code
|
|
151
138
|
}
|
|
@@ -154,6 +141,60 @@ async function runGate(opts: RunCommandOptions): Promise<number> {
|
|
|
154
141
|
return code
|
|
155
142
|
}
|
|
156
143
|
|
|
144
|
+
/** Lines of a failed stage's output the record keeps, counted from the end. */
|
|
145
|
+
const OUTPUT_TAIL_LINES = 200
|
|
146
|
+
|
|
147
|
+
interface GateRecordInput {
|
|
148
|
+
readonly ok: boolean
|
|
149
|
+
readonly root: string
|
|
150
|
+
readonly changed: {
|
|
151
|
+
readonly scoped: boolean
|
|
152
|
+
readonly files: readonly string[]
|
|
153
|
+
}
|
|
154
|
+
readonly summary: Summary
|
|
155
|
+
readonly results: readonly StageResult[]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The `--json` record. A failed stage carries the tail of the output its
|
|
160
|
+
* checks printed, since a caller reading the record alone otherwise reruns
|
|
161
|
+
* the stage by hand to learn which test failed. The tail rather than a parse
|
|
162
|
+
* of the `FAIL` lines, so a generic stage stays free of one runner's format,
|
|
163
|
+
* and a dot-reporter run prints its failures and summary last.
|
|
164
|
+
*/
|
|
165
|
+
export function gateRecord(input: GateRecordInput) {
|
|
166
|
+
const { ok, root, changed, summary, results } = input
|
|
167
|
+
return {
|
|
168
|
+
ok,
|
|
169
|
+
root,
|
|
170
|
+
scoped: changed.scoped,
|
|
171
|
+
changed: changed.files.length,
|
|
172
|
+
summary,
|
|
173
|
+
ms: results.reduce((total, result) => total + result.ms, 0),
|
|
174
|
+
stages: results.map(
|
|
175
|
+
({ id, label, status, failure, ms, queuedMs, emissions }) => ({
|
|
176
|
+
id,
|
|
177
|
+
label,
|
|
178
|
+
status,
|
|
179
|
+
failure,
|
|
180
|
+
ms,
|
|
181
|
+
queuedMs,
|
|
182
|
+
output: status === 'failed' ? outputTail(emissions) : undefined,
|
|
183
|
+
}),
|
|
184
|
+
),
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function outputTail(emissions: StageResult['emissions']): string {
|
|
189
|
+
const lines = emissions
|
|
190
|
+
.filter((emission) => emission.kind === 'output')
|
|
191
|
+
.map((emission) => emission.text.trimEnd())
|
|
192
|
+
.filter((text) => text !== '')
|
|
193
|
+
.join('\n')
|
|
194
|
+
.split('\n')
|
|
195
|
+
return lines.slice(-OUTPUT_TAIL_LINES).join('\n')
|
|
196
|
+
}
|
|
197
|
+
|
|
157
198
|
function report(result: StageResult): void {
|
|
158
199
|
logStep(result.label)
|
|
159
200
|
for (const emission of result.emissions) {
|
package/src/commands/hooks.ts
CHANGED
|
@@ -39,14 +39,16 @@ export function register(program: Command): void {
|
|
|
39
39
|
'Runs these steps in order, each a child call reading its --json record:',
|
|
40
40
|
...Object.values(STEP_VERBS).map((verb) => ` canon ${verb.join(' ')}`),
|
|
41
41
|
'',
|
|
42
|
-
'The archive
|
|
43
|
-
'Lines go to stderr, and an ordinary merge
|
|
42
|
+
'The archive and the member retirement run once per pull request',
|
|
43
|
+
'number the merge brought in. Lines go to stderr, and an ordinary merge',
|
|
44
|
+
'prints nothing.',
|
|
44
45
|
'',
|
|
45
|
-
'The reclaim
|
|
46
|
-
'inside a linked worktree never removes the
|
|
46
|
+
'The member retirement and the reclaim run from the working directory',
|
|
47
|
+
'with no root, so a pull inside a linked worktree never removes the',
|
|
48
|
+
'worktree it stands in.',
|
|
47
49
|
'',
|
|
48
50
|
'Environment:',
|
|
49
|
-
' CANON_SKIP_RECLAIM=1 skip the
|
|
51
|
+
' CANON_SKIP_RECLAIM=1 skip the member retirement and the reclaim',
|
|
50
52
|
' CANON_SKIP_UPGRADE=1 skip the CLI reinstall',
|
|
51
53
|
' CANON_SKIP_PLUGIN_UPDATE=1 skip the plugin update',
|
|
52
54
|
'',
|
package/src/commands/markdown.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { BAN_SETS, emptyBanSets } from '@/markdown/bans'
|
|
|
6
6
|
import { isChangelog, lengthExemption } from '@/markdown/ceiling'
|
|
7
7
|
import { type ColonFinding, findColons } from '@/markdown/colons'
|
|
8
8
|
import { type MarkdownAuditRefusal, resolveMarkdown } from '@/markdown/files'
|
|
9
|
-
import {
|
|
9
|
+
import type { DensityKind } from '@/markdown/density'
|
|
10
10
|
import {
|
|
11
11
|
type GatedDensityFinding,
|
|
12
12
|
type GatedReferenceShapeFinding,
|
|
@@ -38,7 +38,19 @@ import {
|
|
|
38
38
|
readGatedConfig,
|
|
39
39
|
resolveGatedList,
|
|
40
40
|
} from '@/markdown/gated'
|
|
41
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
densityGates,
|
|
43
|
+
inDensityScope,
|
|
44
|
+
scopedDensityFindings,
|
|
45
|
+
} from '@/markdown/scope'
|
|
46
|
+
import {
|
|
47
|
+
TABLE_CELL,
|
|
48
|
+
TABLE_ROWS,
|
|
49
|
+
TABLE_TOKEN,
|
|
50
|
+
type TableFinding,
|
|
51
|
+
tableFindings,
|
|
52
|
+
type TableKind,
|
|
53
|
+
} from '@/markdown/tables'
|
|
42
54
|
import {
|
|
43
55
|
BASELINE,
|
|
44
56
|
CHECKPOINTS,
|
|
@@ -86,6 +98,8 @@ interface FileReport {
|
|
|
86
98
|
readonly headingCase: readonly HeadingCaseFinding[]
|
|
87
99
|
/** Empty on a path outside the density scope, so a plan never carries one. */
|
|
88
100
|
readonly density: readonly GatedDensityFinding[]
|
|
101
|
+
/** Empty outside the density scope, and never gating anywhere. */
|
|
102
|
+
readonly tables: readonly TableFinding[]
|
|
89
103
|
/** Empty on a page whose frontmatter does not carry `kind: reference`. */
|
|
90
104
|
readonly referenceShape: readonly GatedReferenceShapeFinding[]
|
|
91
105
|
readonly structure: StructureReport
|
|
@@ -131,13 +145,20 @@ export function register(program: Command): void {
|
|
|
131
145
|
'a row, a 200-character bullet, 5 plain bullets in a row, no H4, and a',
|
|
132
146
|
'paragraph or table opening every H3. Only context, docs, skills, tooling',
|
|
133
147
|
'seeds and references, the wiki, README.md, and CONTRIBUTING.md take a',
|
|
134
|
-
'finding.
|
|
135
|
-
'
|
|
136
|
-
'
|
|
137
|
-
'
|
|
138
|
-
'
|
|
148
|
+
'finding. CLAUDE.md and its seed take only the bullet run and the H3',
|
|
149
|
+
'opening, inside Key paths. In a canon checkout one gates under a',
|
|
150
|
+
'prefix in GATED_PATHS in src/markdown/scope.ts, and a bullet run gates',
|
|
151
|
+
'in context, docs, and skills from the start. Elsewhere one gates only',
|
|
152
|
+
'under a prefix canon/config/markdown.toml lists, which canon markdown',
|
|
153
|
+
'gated add writes, and with no such file every density finding reports',
|
|
154
|
+
'and none gates.',
|
|
139
155
|
'A config that does not parse refuses with its path named.',
|
|
140
156
|
'',
|
|
157
|
+
'Tables take four limits on the same paths, CLAUDE.md aside: a',
|
|
158
|
+
'120-character cell, 8 body rows, a list as the next block, and a code',
|
|
159
|
+
'span with a run past 30 characters. A table finding reports and never',
|
|
160
|
+
'fails a run.',
|
|
161
|
+
'',
|
|
141
162
|
'A page whose frontmatter carries kind: reference takes the reference',
|
|
142
163
|
'shape: no H2 beyond Output, Refusals, and See also, each at most once',
|
|
143
164
|
'and in that order, no H3, and no table whose first header cell names',
|
|
@@ -361,17 +382,16 @@ async function runAudit(
|
|
|
361
382
|
bans: scanBans(lines, bans),
|
|
362
383
|
colons: advisory ? findColons(lines) : [],
|
|
363
384
|
headingCase: advisory ? findHeadingCase(source) : [],
|
|
364
|
-
density:
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
: [],
|
|
385
|
+
density: scopedDensityFindings(rel, lines).map((finding) => ({
|
|
386
|
+
...finding,
|
|
387
|
+
gating: densityGates(
|
|
388
|
+
rel,
|
|
389
|
+
finding.kind,
|
|
390
|
+
gated.prefixes,
|
|
391
|
+
gated.alwaysGated,
|
|
392
|
+
),
|
|
393
|
+
})),
|
|
394
|
+
tables: inDensityScope(rel) ? tableFindings(lines) : [],
|
|
375
395
|
referenceShape: referenceShapeFindings(source).map((finding) => ({
|
|
376
396
|
...finding,
|
|
377
397
|
gating: referenceShapeGates(rel, gated.prefixes),
|
|
@@ -393,6 +413,7 @@ async function runAudit(
|
|
|
393
413
|
reportColons(reports)
|
|
394
414
|
reportHeadingCase(reports)
|
|
395
415
|
reportDensity(reports, gated, resolution.source)
|
|
416
|
+
reportTables(reports)
|
|
396
417
|
reportReferenceShape(reports, gated)
|
|
397
418
|
reportBullets(reports, checkpoints)
|
|
398
419
|
reportParagraphs(reports, checkpoints)
|
|
@@ -430,6 +451,7 @@ async function runAudit(
|
|
|
430
451
|
colons: report.colons,
|
|
431
452
|
headingCase: report.headingCase,
|
|
432
453
|
density: report.density,
|
|
454
|
+
tables: report.tables,
|
|
433
455
|
referenceShape: report.referenceShape,
|
|
434
456
|
longestRun: report.structure.longestRun,
|
|
435
457
|
longestRunLine: report.structure.longestRunLine,
|
|
@@ -643,7 +665,7 @@ function reportDensity(
|
|
|
643
665
|
`Paragraphs past ${CHECKPOINTS.paragraph} characters, more than 2 paragraphs in a row, bullets past ${CHECKPOINTS.bullet} characters, more than 5 plain bullets in a row, a heading past H3, and an H3 opening with neither a paragraph nor a table.`,
|
|
644
666
|
)
|
|
645
667
|
logInfo(
|
|
646
|
-
'Measured on context entries, docs, skill bodies, references, and requirements, tooling seeds and stack references, the wiki, README.md, and CONTRIBUTING.md. Every other path takes no density finding.',
|
|
668
|
+
'Measured on context entries, docs, skill bodies, references, and requirements, tooling seeds and stack references, the wiki, README.md, and CONTRIBUTING.md. CLAUDE.md and its seed take the bullet run and the H3 opening inside Key paths alone. Every other path takes no density finding.',
|
|
647
669
|
)
|
|
648
670
|
logInfo(densityGatingLine(gated, source))
|
|
649
671
|
|
|
@@ -695,6 +717,63 @@ function densityGatingLine(
|
|
|
695
717
|
: `A finding gates under a prefix ${GATED_CONFIG_REL} lists (${listed}). Every other finding reports.`
|
|
696
718
|
}
|
|
697
719
|
|
|
720
|
+
const TABLE_LABELS: Record<TableKind, string> = {
|
|
721
|
+
'table-cell': 'cell length',
|
|
722
|
+
'table-rows': 'rows',
|
|
723
|
+
'table-then-list': 'list after table',
|
|
724
|
+
'table-token': 'wide token',
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function describeTable(finding: TableFinding): string {
|
|
728
|
+
const measured =
|
|
729
|
+
finding.kind === 'table-then-list'
|
|
730
|
+
? 'follows the table with no lead sentence'
|
|
731
|
+
: `${finding.value} past ${finding.limit}`
|
|
732
|
+
return ` :${finding.line} ${TABLE_LABELS[finding.kind]} ${measured}`
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Lists every table finding on the density scope's paths. None gates yet,
|
|
737
|
+
* since a gate is decided per path rather than per kind, and a new density
|
|
738
|
+
* kind would fail every gated folder carrying a table hit at once.
|
|
739
|
+
*/
|
|
740
|
+
function reportTables(reports: readonly FileReport[]): void {
|
|
741
|
+
logStep('Tables')
|
|
742
|
+
logInfo(
|
|
743
|
+
`Cells past ${TABLE_CELL} characters, tables past ${TABLE_ROWS} body rows, a list directly after a table, and a code span in a cell holding a run past ${TABLE_TOKEN} characters.`,
|
|
744
|
+
)
|
|
745
|
+
logInfo(
|
|
746
|
+
'Measured on the paths density measures in full, so never on CLAUDE.md, outside fenced blocks. This never fails a run.',
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
const carrying = reports
|
|
750
|
+
.filter((report) => report.tables.length > 0)
|
|
751
|
+
.sort((a, b) => b.tables.length - a.tables.length)
|
|
752
|
+
|
|
753
|
+
if (carrying.length === 0) {
|
|
754
|
+
logInfo('No table finding.')
|
|
755
|
+
return
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const total = carrying.reduce((sum, report) => sum + report.tables.length, 0)
|
|
759
|
+
logWarn(
|
|
760
|
+
`${plural(total, 'finding')} across ${plural(carrying.length, 'file')}`,
|
|
761
|
+
)
|
|
762
|
+
logInfo(
|
|
763
|
+
'Move a long cell into a bullet under the lead, split a long table into groups each under a lead sentence, give a trailing list its own lead, and move a wide token out of the table.',
|
|
764
|
+
)
|
|
765
|
+
pipeOutput(
|
|
766
|
+
carrying
|
|
767
|
+
.map(
|
|
768
|
+
(report) =>
|
|
769
|
+
`${report.rel} ${plural(report.tables.length, 'finding')}\n${report.tables
|
|
770
|
+
.map(describeTable)
|
|
771
|
+
.join('\n')}`,
|
|
772
|
+
)
|
|
773
|
+
.join('\n'),
|
|
774
|
+
)
|
|
775
|
+
}
|
|
776
|
+
|
|
698
777
|
const REFERENCE_SHAPE_LABELS: Record<ReferenceShapeKind, string> = {
|
|
699
778
|
'extra-section': 'section outside the shape',
|
|
700
779
|
'section-order': 'section out of order or repeated',
|
package/src/commands/migrate.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { join, resolve } from 'node:path'
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
|
+
import { cliRunner } from '@/gate/sequencer'
|
|
4
5
|
import { listRepositoryFiles } from '@/git/files'
|
|
5
6
|
import { indexSourceRules } from '@/gov/adapter'
|
|
6
7
|
import { applyRecordsMove, applyRename, readSources } from '@/migrate/apply'
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
SKILL_NAME_MAP,
|
|
36
37
|
SKILL_NAME_RULES,
|
|
37
38
|
} from '@/migrate/skill-names'
|
|
39
|
+
import { readMigrationStatus, statusExitCode } from '@/migrate/status'
|
|
38
40
|
import {
|
|
39
41
|
planSurfaceRootsMove,
|
|
40
42
|
type SurfaceRootsPlan,
|
|
@@ -1039,6 +1041,43 @@ function toSessionDefaultsRecord(
|
|
|
1039
1041
|
}
|
|
1040
1042
|
}
|
|
1041
1043
|
|
|
1044
|
+
interface StatusOptions {
|
|
1045
|
+
readonly json?: boolean
|
|
1046
|
+
readonly root?: string
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Lists every migration a project still needs by running each subcommand's own
|
|
1051
|
+
* dry run, so it reads the contract they share and writes nothing.
|
|
1052
|
+
*/
|
|
1053
|
+
async function runStatus(opts: StatusOptions): Promise<number> {
|
|
1054
|
+
const root = resolve(opts.root ?? process.cwd())
|
|
1055
|
+
|
|
1056
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
1057
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
1058
|
+
|
|
1059
|
+
const report = await readMigrationStatus(cliRunner(root))
|
|
1060
|
+
|
|
1061
|
+
if (opts.json) {
|
|
1062
|
+
process.stdout.write(
|
|
1063
|
+
`${JSON.stringify({ ok: report.refused === 0, ...report })}\n`,
|
|
1064
|
+
)
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
for (const entry of report.migrations) {
|
|
1068
|
+
if (entry.state === 'pending') logInfo(`Pending: ${entry.verb}`)
|
|
1069
|
+
if (entry.state === 'refused') {
|
|
1070
|
+
logError(`Refused: ${entry.verb}: ${entry.reason ?? 'no reason given'}`)
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
if (report.pending === 0 && report.refused === 0) {
|
|
1075
|
+
logInfo('No migration is pending.')
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
return statusExitCode(report)
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1042
1081
|
export function register(program: Command): void {
|
|
1043
1082
|
const migrate = program
|
|
1044
1083
|
.command('migrate')
|
|
@@ -1446,4 +1485,42 @@ export function register(program: Command): void {
|
|
|
1446
1485
|
.action(async (opts: SessionDefaultsOptions) => {
|
|
1447
1486
|
process.exitCode = await runSessionDefaults(opts)
|
|
1448
1487
|
})
|
|
1488
|
+
|
|
1489
|
+
migrate
|
|
1490
|
+
.command('status')
|
|
1491
|
+
.description('List every migration a project still needs, in run order')
|
|
1492
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
1493
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
1494
|
+
.option(
|
|
1495
|
+
'--root <path>',
|
|
1496
|
+
'Project root, defaulting to the working directory',
|
|
1497
|
+
)
|
|
1498
|
+
.addHelpText(
|
|
1499
|
+
'after',
|
|
1500
|
+
[
|
|
1501
|
+
'',
|
|
1502
|
+
'Runs each migrate subcommand as a dry run and sorts it by its exit',
|
|
1503
|
+
'code, in the order a project runs them, records first. It passes',
|
|
1504
|
+
'no --write and writes nothing. A migration is pending or not, with',
|
|
1505
|
+
"the subcommand's own record attached, since the records share no",
|
|
1506
|
+
'count field.',
|
|
1507
|
+
'',
|
|
1508
|
+
"Each dry run reads today's tree, so a clean entry can turn pending",
|
|
1509
|
+
'once an earlier one is applied. Run status again after each --write.',
|
|
1510
|
+
'',
|
|
1511
|
+
'Exit codes:',
|
|
1512
|
+
' 0 no migration is pending',
|
|
1513
|
+
' 1 a subcommand refused, which outranks a pending plan',
|
|
1514
|
+
' 2 at least one migration is pending',
|
|
1515
|
+
'',
|
|
1516
|
+
'Examples:',
|
|
1517
|
+
' canon migrate status',
|
|
1518
|
+
' canon migrate status --json',
|
|
1519
|
+
' canon migrate status --root ../project',
|
|
1520
|
+
'',
|
|
1521
|
+
].join('\n'),
|
|
1522
|
+
)
|
|
1523
|
+
.action(async (opts: StatusOptions) => {
|
|
1524
|
+
process.exitCode = await runStatus(opts)
|
|
1525
|
+
})
|
|
1449
1526
|
}
|