@erclx/canon 5.42.0 → 5.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/SKILL.md +3 -3
- package/claude/skills/ci-workflow/REQUIREMENT.md +2 -1
- package/claude/skills/ci-workflow/SKILL.md +12 -2
- package/claude/skills/ci-workflow/references/workflows.md +12 -9
- package/claude/skills/create-rule/SKILL.md +1 -0
- package/claude/skills/decision-escalate/SKILL.md +1 -2
- package/claude/skills/document-health/references/axes.md +2 -1
- package/claude/skills/draft-doc/references/wiki-kind.md +1 -1
- package/claude/skills/draft-doc/references/wiki.md +1 -1
- package/claude/skills/git-commit/references/commit.md +1 -5
- package/claude/skills/git-pr/references/pr.md +1 -0
- package/claude/skills/plan-feature/SKILL.md +1 -1
- package/claude/skills/plan-feature/references/plan.md +1 -1
- package/claude/skills/pr-evidence/references/evidence.md +1 -1
- package/claude/skills/role-orchestrator/SKILL.md +2 -0
- package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -0
- package/claude/skills/role-planner/REQUIREMENT.md +1 -1
- package/claude/skills/role-planner/SKILL.md +3 -3
- package/claude/skills/role-worker/REQUIREMENT.md +2 -0
- package/claude/skills/role-worker/SKILL.md +26 -2
- package/claude/skills/search-craft/REQUIREMENT.md +1 -1
- package/claude/skills/search-craft/SKILL.md +2 -2
- package/docs/agents/audits.md +1 -0
- package/docs/agents/migrate.md +11 -8
- package/docs/target/migrations.md +11 -0
- package/governance/detect.toml +4 -0
- package/governance/rules/canon/context.md +2 -1
- package/governance/rules/canon/plan.md +3 -5
- package/governance/rules/canon/scratch.md +3 -1
- package/governance/rules/canon/task-scope.md +2 -1
- package/governance/rules/canon/tasks.md +2 -1
- package/governance/rules/canon/worktrees.md +5 -2
- package/governance/rules/claude/output.md +7 -4
- package/governance/rules/claude/skill.md +6 -4
- package/governance/rules/tooling/ci-workflow.md +6 -39
- package/governance/rules/ui/design-reference.md +37 -0
- package/governance/rules/writing/markdown.md +2 -1
- package/package.json +6 -6
- package/src/audits/catalog.ts +42 -0
- package/src/claude/settings.ts +8 -4
- package/src/commands/claude.ts +11 -1
- package/src/commands/gov.ts +198 -0
- package/src/commands/migrate.ts +121 -2
- package/src/commands/pr.ts +4 -77
- package/src/commands/tooling.ts +3 -3
- package/src/commands/worktrees.ts +49 -0
- package/src/gate/stages.ts +15 -0
- package/src/gov/audit.ts +440 -0
- package/src/listeners/belongs.ts +21 -0
- package/src/listeners/machine.ts +93 -0
- package/src/listeners/parse.ts +41 -0
- package/src/migrate/session-defaults.ts +138 -0
- package/src/pr/local.ts +4 -69
- package/src/sync/check.ts +15 -0
- package/src/tooling/stamp.ts +56 -12
- package/src/worktrees/stop.ts +92 -0
- package/tooling/base/configs/.github/workflows/phase-label-gate.yml +6 -2
- package/tooling/base/configs/commitlint.config.js +0 -20
- package/tooling/base/reference.md +1 -1
- package/tooling/base/seeds/.github/workflows/verify.yml +4 -1
- package/tooling/claude/reference.md +13 -7
- package/tooling/claude/seeds/.claude/settings.json +4 -0
- package/tooling/claude/user/settings.template.json +0 -4
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +5 -5
- package/tooling/public/configs/.github/dependabot.yml +12 -0
- package/tooling/public/configs/.github/workflows/release-please.yml +30 -0
- package/tooling/public/manifest.toml +8 -0
- package/tooling/public/reference.md +53 -0
- package/tooling/public/seeds/.github/ISSUE_TEMPLATE/bug.yml +48 -0
- package/tooling/public/seeds/.github/ISSUE_TEMPLATE/config.yml +5 -0
- package/tooling/public/seeds/.github/ISSUE_TEMPLATE/feature.yml +16 -0
- package/tooling/public/seeds/.release-please-manifest.json +3 -0
- package/tooling/public/seeds/CONTRIBUTING.md +50 -0
- package/tooling/public/seeds/LICENSE +21 -0
- package/tooling/public/seeds/SECURITY.md +13 -0
- package/tooling/public/seeds/release-please-config.json +12 -0
- package/tooling/shell/configs/.github/workflows/shell.yml +4 -1
- package/tooling/web/configs/.github/workflows/readme-screenshot.yml +6 -3
- package/tooling/web/configs/.github/workflows/verify.yml +15 -12
|
@@ -11,7 +11,9 @@ paths:
|
|
|
11
11
|
|
|
12
12
|
## Skill files
|
|
13
13
|
|
|
14
|
-
- Load the `canon:create-skill` skill before a hand edit to a `SKILL.md` or a `REQUIREMENT.md
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
14
|
+
- Load the `canon:create-skill` skill before a hand edit to a `SKILL.md` or a `REQUIREMENT.md`, since it carries the skill structure, the requirement shape, and the path rules.
|
|
15
|
+
- Report it rather than proceeding silently when `canon:create-skill` does not resolve, as in a project that installed governance without the plugin.
|
|
16
|
+
- Confirm `canon:create-skill`'s two creation-time questions are answered before a new `SKILL.md` lands, whoever drafted it, `canon:create-skill` included.
|
|
17
|
+
- Carry its third question into the sibling `REQUIREMENT.md`'s `Must not` section as a review criterion rather than a gate.
|
|
18
|
+
- Re-read a skill body this session edited before invoking that skill again in the same session. Revisit when Claude Code reloads a skill body edited after the session first invoked it.
|
|
19
|
+
- Do not read a resolved file path in a held body as evidence the body is current. Revisit when Claude Code marks a held skill body stale once its source changes.
|
|
@@ -1,55 +1,22 @@
|
|
|
1
1
|
---
|
|
2
|
-
description:
|
|
2
|
+
description: Keep GitHub Actions commit-back steps and token scopes safe, and route workflow authoring to the ci-workflow skill
|
|
3
3
|
paths:
|
|
4
4
|
- '.github/workflows/**'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# CI workflow standards
|
|
8
8
|
|
|
9
|
-
##
|
|
10
|
-
|
|
11
|
-
- Include `workflow_dispatch` alongside the primary trigger on every workflow.
|
|
12
|
-
- Pin every action to a major version tag. Never `@latest` or `@main`.
|
|
13
|
-
- Run every job on `ubuntu-latest`.
|
|
14
|
-
|
|
15
|
-
## Job naming
|
|
16
|
-
|
|
17
|
-
- Name every job as an emoji followed by a title, such as `🛡️ Checks`, `🧪 Unit Tests`, `📦 Build Check`, `🎭 E2E Tests`, or `🚀 Deploy`.
|
|
18
|
-
|
|
19
|
-
## Job granularity
|
|
20
|
-
|
|
21
|
-
- Fold static analysis, unit tests, and build into one job while the gate runs under two minutes end to end.
|
|
22
|
-
- Split them into parallel jobs once a run log puts the gate past two minutes.
|
|
23
|
-
- Give E2E, release, and deploy a job each from the start.
|
|
24
|
-
|
|
25
|
-
## Job dependencies
|
|
26
|
-
|
|
27
|
-
- Use `needs` for a data dependency, where a job consumes another job's artifact, or for a job whose cost is prohibitive against the gate in front of it. Leave every other job unwired so it runs in parallel.
|
|
28
|
-
- Gate E2E on the job that uploads the build artifact.
|
|
29
|
-
- Gate release and deploy on E2E.
|
|
30
|
-
- Emit a deploy, publish, or release job with a placeholder step and name what the caller fills in. Never guess a deploy command.
|
|
31
|
-
|
|
32
|
-
## Artifacts
|
|
33
|
-
|
|
34
|
-
- Upload an artifact on `if: failure()` alone, with `retention-days: 7`.
|
|
35
|
-
|
|
36
|
-
## Bun stack
|
|
9
|
+
## Authority
|
|
37
10
|
|
|
38
|
-
-
|
|
39
|
-
- Install with `bun install --frozen-lockfile`.
|
|
40
|
-
- Key the Playwright browser cache on the Playwright version string, never a static key.
|
|
11
|
+
- Load the `canon:ci-workflow` skill for triggers, pinning, job shape, artifacts, and the Bun stack. Report it rather than proceeding silently when the skill does not resolve.
|
|
41
12
|
|
|
42
13
|
## Commit-back
|
|
43
14
|
|
|
44
|
-
- Commit a refreshed file back to the pull request branch only when
|
|
15
|
+
- Commit a refreshed file back to the pull request branch only when that branch's change moved it. Report the diff when an outside input can move it, such as a build-time count or a font.
|
|
45
16
|
- Commit only what a deterministic step regenerates from source, and run the diff check before the commit step so an unchanged capture pushes nothing.
|
|
46
17
|
- Skip a pull request from a fork with `github.event.pull_request.head.repo.full_name == github.repository`, since the default token cannot push to a fork's branch.
|
|
47
|
-
- Set the concurrency group on the ref so a newer push cancels the run committing onto a stale head.
|
|
48
|
-
- Set `HUSKY: 0` on the commit step when the runner lacks a tool a hook shells out to.
|
|
49
18
|
- Never commit back to the trunk. A commit with no pull request and no reviewer needs an operator's decision, not a workflow's.
|
|
50
|
-
- Leave `workflow_dispatch` off a commit-back workflow. A manual run has no branch to commit onto, so the trigger would pass and do nothing.
|
|
51
|
-
- Fail a template's first step while a path list still holds its placeholder, so an unfinished install fails loudly rather than never firing.
|
|
52
19
|
|
|
53
|
-
##
|
|
20
|
+
## Token scopes
|
|
54
21
|
|
|
55
|
-
-
|
|
22
|
+
- In a workflow with more than one job, add a write scope to the job that needs it, never to the workflow-level block, so every other job keeps the read-only token.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Build UI against the tracked design references under canon/design, and show each reference beside its build in the pull request
|
|
3
|
+
paths:
|
|
4
|
+
- 'canon/design/**'
|
|
5
|
+
- '**/*.tsx'
|
|
6
|
+
- '**/*.jsx'
|
|
7
|
+
- '**/*.vue'
|
|
8
|
+
- '**/*.svelte'
|
|
9
|
+
- '**/*.astro'
|
|
10
|
+
- '**/*.html'
|
|
11
|
+
- '**/*.css'
|
|
12
|
+
- '**/*.scss'
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# Design reference standards
|
|
16
|
+
|
|
17
|
+
## Where the design lives
|
|
18
|
+
|
|
19
|
+
- Build UI against `canon/design/`, never against the canvas directly.
|
|
20
|
+
- Land a picked or changed canvas design in `canon/design/` through the project's design sync, as its own pull request, before any feature builds against it.
|
|
21
|
+
- Never copy, edit, or delete a reference file under `canon/design/` by hand.
|
|
22
|
+
- Add a new surface's row to the index in `canon/design/` before running the sync, and remove it only in the change that removes the surface.
|
|
23
|
+
|
|
24
|
+
## Building against a reference
|
|
25
|
+
|
|
26
|
+
- Open each reference image and its frame HTML, light and dark, before changing the surface they show.
|
|
27
|
+
- Match the reference closely or identically, taking sizes, spacing, and colors from the frame HTML.
|
|
28
|
+
|
|
29
|
+
## Showing the comparison
|
|
30
|
+
|
|
31
|
+
- Put each changed surface's reference image beside its built capture, light and dark, in the pull request description.
|
|
32
|
+
- Name each place the build departs from its reference, with its reason, in the pull request description.
|
|
33
|
+
- Report an unexplained departure from a reference as a review finding.
|
|
34
|
+
|
|
35
|
+
## Naming
|
|
36
|
+
|
|
37
|
+
- Give each app part one plain kebab-case folder under `canon/design/`, and name each file `<name>-dark` or `<name>-light`.
|
|
@@ -9,7 +9,8 @@ paths:
|
|
|
9
9
|
## Format
|
|
10
10
|
|
|
11
11
|
- Never write an em dash or a semicolon. `canon markdown audit` reads the full ban list from the package.
|
|
12
|
-
- Load the `canon:markdown-craft` skill before a substantial markdown edit for headings,
|
|
12
|
+
- Load the `canon:markdown-craft` skill before a substantial markdown edit, for headings, structure, code spans, dates, punctuation, emphasis, file references, spelling, and frontmatter.
|
|
13
|
+
- Report it rather than proceeding silently when `canon:markdown-craft` does not resolve.
|
|
13
14
|
- Run `canon markdown audit <path>` after the edit, and rewrite the sentence carrying a hit rather than swapping the token for a near-synonym.
|
|
14
15
|
|
|
15
16
|
## Voice
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erclx/canon",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.43.0",
|
|
5
5
|
"description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"execa": "^9.5.2",
|
|
56
56
|
"highlight.js": "^11.12.0",
|
|
57
57
|
"jszip": "^3.10.1",
|
|
58
|
-
"playwright-core": "1.
|
|
58
|
+
"playwright-core": "1.64.1",
|
|
59
59
|
"pptxgenjs": "^4.0.1",
|
|
60
60
|
"preact": "^11.0.0"
|
|
61
61
|
},
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"@astrojs/react": "^5.0.4",
|
|
65
65
|
"@commitlint/cli": "^20.4.1",
|
|
66
66
|
"@commitlint/config-conventional": "^20.4.1",
|
|
67
|
-
"@playwright/test": "1.
|
|
67
|
+
"@playwright/test": "1.64.1",
|
|
68
68
|
"@tailwindcss/vite": "^4.2.4",
|
|
69
69
|
"@testing-library/jest-dom": "^6.9.1",
|
|
70
70
|
"@types/bun": "^1.2.10",
|
|
@@ -73,11 +73,11 @@
|
|
|
73
73
|
"happy-dom": "^20.14.5",
|
|
74
74
|
"husky": "^9.1.7",
|
|
75
75
|
"micromatch": "^4.0.8",
|
|
76
|
-
"oxfmt": "0.
|
|
77
|
-
"oxlint": "1.
|
|
76
|
+
"oxfmt": "0.73.0",
|
|
77
|
+
"oxlint": "1.88.0",
|
|
78
78
|
"prettier": "^3.8.1",
|
|
79
79
|
"prettier-plugin-astro": "^0.14.1",
|
|
80
|
-
"prettier-plugin-tailwindcss": "^0.
|
|
80
|
+
"prettier-plugin-tailwindcss": "^0.8.1",
|
|
81
81
|
"typescript": "^5.9.3",
|
|
82
82
|
"vitest": "^4.1.5"
|
|
83
83
|
}
|
package/src/audits/catalog.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { ReachRefusal } from '@/claude/skills-reach'
|
|
|
4
4
|
import type { ContextAuditRefusal } from '@/context/audit'
|
|
5
5
|
import type { SweepVerdict } from '@/context/classify/patterns'
|
|
6
6
|
import type { AuditRefusal } from '@/deps/audit'
|
|
7
|
+
import type { RuleAuditRefusal } from '@/gov/audit'
|
|
7
8
|
import type { RestatedRefusal } from '@/gov/restated'
|
|
8
9
|
import type { LabelAuditRefusal } from '@/labels/audit'
|
|
9
10
|
import type { ValidateRefusal as RecordRefusal } from '@/records/validate'
|
|
@@ -412,6 +413,33 @@ function restatedCounts(record: unknown): Record<string, number> | undefined {
|
|
|
412
413
|
})
|
|
413
414
|
}
|
|
414
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Counts rules rather than findings, since a rule past four limits is one
|
|
418
|
+
* rewrite, and keeps the two ledger findings apart because a missing ledger and
|
|
419
|
+
* a short one are fixed by different passes.
|
|
420
|
+
*/
|
|
421
|
+
function ruleCounts(record: unknown): Record<string, number> | undefined {
|
|
422
|
+
const rules = asObject(record)?.rules
|
|
423
|
+
if (!Array.isArray(rules)) return undefined
|
|
424
|
+
|
|
425
|
+
let shapeRules = 0
|
|
426
|
+
let missingLedgers = 0
|
|
427
|
+
let ledgerMismatches = 0
|
|
428
|
+
|
|
429
|
+
for (const raw of rules) {
|
|
430
|
+
const rule = asObject(raw)
|
|
431
|
+
const findings = lengthOf(rule?.findings)
|
|
432
|
+
const ledger = asObject(rule?.ledger)
|
|
433
|
+
if (findings === undefined || ledger === undefined) return undefined
|
|
434
|
+
|
|
435
|
+
if (findings > 0) shapeRules += 1
|
|
436
|
+
if (ledger.status === 'missing') missingLedgers += 1
|
|
437
|
+
if ((lengthOf(ledger.mismatches) ?? 0) > 0) ledgerMismatches += 1
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return { shapeRules, missingLedgers, ledgerMismatches }
|
|
441
|
+
}
|
|
442
|
+
|
|
415
443
|
function findingsOnly(record: unknown): Record<string, number> | undefined {
|
|
416
444
|
const root = asObject(record)
|
|
417
445
|
if (root === undefined) return undefined
|
|
@@ -681,6 +709,20 @@ export const AUDITS: readonly AuditSpec[] = [
|
|
|
681
709
|
] satisfies RestatedRefusal[],
|
|
682
710
|
counts: restatedCounts,
|
|
683
711
|
},
|
|
712
|
+
{
|
|
713
|
+
id: 'rules',
|
|
714
|
+
label: 'Rule shape and ledgers',
|
|
715
|
+
argv: ['gov', 'audit', '--json'],
|
|
716
|
+
// Reports rather than gates. The corpus starts far from zero, and the
|
|
717
|
+
// `rule-shape` stage already fails a push on each folder once its rewrite
|
|
718
|
+
// lists it as gated, so gating the whole set here would fail every push.
|
|
719
|
+
gatingExits: [],
|
|
720
|
+
corpus: 'tracked',
|
|
721
|
+
// A target that authors no rule of its own has no corpus here, which is an
|
|
722
|
+
// absence rather than a break, on the same test the restated sweep takes.
|
|
723
|
+
absentReasons: ['no-rules'] satisfies RuleAuditRefusal[],
|
|
724
|
+
counts: ruleCounts,
|
|
725
|
+
},
|
|
684
726
|
{
|
|
685
727
|
id: 'counts',
|
|
686
728
|
label: 'Self-stated catalog counts',
|
package/src/claude/settings.ts
CHANGED
|
@@ -5,6 +5,10 @@ import { dirname } from 'node:path'
|
|
|
5
5
|
const DEFAULT_INDENT = ' '
|
|
6
6
|
const INDENT = /^([ \t]+)\S/m
|
|
7
7
|
|
|
8
|
+
// The pair setup once wrote to suppress Claude's attribution. Only this exact
|
|
9
|
+
// value is retracted, since anything else is a person's own choice.
|
|
10
|
+
const RETIRED_ATTRIBUTION = { commit: '', pr: '' }
|
|
11
|
+
|
|
8
12
|
export type SettingsRecord = Record<string, unknown>
|
|
9
13
|
|
|
10
14
|
export interface SettingsKey {
|
|
@@ -107,10 +111,10 @@ export function planSettings(
|
|
|
107
111
|
}
|
|
108
112
|
keys.push({ label: 'statusLine', changed: statusLineChanged })
|
|
109
113
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
+
if (equals(next.attribution, RETIRED_ATTRIBUTION)) {
|
|
115
|
+
delete next.attribution
|
|
116
|
+
keys.push({ label: 'attribution', changed: true })
|
|
117
|
+
}
|
|
114
118
|
|
|
115
119
|
const permissions = asRecord(next.permissions)
|
|
116
120
|
const merged: SettingsRecord = { ...permissions }
|
package/src/commands/claude.ts
CHANGED
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
serializeSettings,
|
|
70
70
|
writeSettings,
|
|
71
71
|
} from '@/claude/settings'
|
|
72
|
+
import { readSessionDefaults } from '@/migrate/session-defaults'
|
|
72
73
|
import { copyPreservingMode } from '@/sync/copy'
|
|
73
74
|
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
|
|
74
75
|
import { recordDir } from '@/roots/record'
|
|
@@ -255,7 +256,7 @@ export function register(program: Command): void {
|
|
|
255
256
|
|
|
256
257
|
claude
|
|
257
258
|
.command('setup')
|
|
258
|
-
.description('One-shot user-level config (statusline,
|
|
259
|
+
.description('One-shot user-level config (statusline, permissions)')
|
|
259
260
|
.argument('[dest]', 'User config directory', join(homedir(), '.claude'))
|
|
260
261
|
.helpOption('-h, --help', 'Show this help message')
|
|
261
262
|
.action(async (dest: string) => {
|
|
@@ -663,6 +664,15 @@ async function runSync(target: string): Promise<number> {
|
|
|
663
664
|
else logWarn(`${name}/ missing. Run \`canon claude init\``)
|
|
664
665
|
}
|
|
665
666
|
|
|
667
|
+
// Sync never rewrites an existing settings file, so a target seeded before
|
|
668
|
+
// the session defaults learns of them only here.
|
|
669
|
+
const defaults = await readSessionDefaults(resolved, PROJECT_ROOT)
|
|
670
|
+
if (defaults.kind === 'planned' && defaults.plan.predates) {
|
|
671
|
+
logWarn(
|
|
672
|
+
'settings.json lacks the session defaults. Run `canon migrate session-defaults --write`',
|
|
673
|
+
)
|
|
674
|
+
}
|
|
675
|
+
|
|
666
676
|
logStep('Scanning .gitignore')
|
|
667
677
|
const chain = claudeChain(PROJECT_ROOT)
|
|
668
678
|
const pruned = await pruneGitignore(chain, resolved)
|
package/src/commands/gov.ts
CHANGED
|
@@ -32,6 +32,16 @@ import {
|
|
|
32
32
|
SEED_REL,
|
|
33
33
|
SHIPPED_SKILLS_REL,
|
|
34
34
|
} from '@/gov/restated'
|
|
35
|
+
import {
|
|
36
|
+
auditExit,
|
|
37
|
+
hasFinding,
|
|
38
|
+
PRACTICE_FOLDERS,
|
|
39
|
+
readRuleAudit,
|
|
40
|
+
RULE_GATED_FOLDERS,
|
|
41
|
+
RULE_LIMITS,
|
|
42
|
+
type RuleAuditReport,
|
|
43
|
+
type RuleReading,
|
|
44
|
+
} from '@/gov/audit'
|
|
35
45
|
import {
|
|
36
46
|
CITATION_MARKER,
|
|
37
47
|
type CitationReport,
|
|
@@ -123,6 +133,12 @@ interface CountsOptions {
|
|
|
123
133
|
readonly json?: boolean
|
|
124
134
|
}
|
|
125
135
|
|
|
136
|
+
interface AuditOptions {
|
|
137
|
+
readonly root?: string
|
|
138
|
+
readonly json?: boolean
|
|
139
|
+
readonly gate?: boolean
|
|
140
|
+
}
|
|
141
|
+
|
|
126
142
|
/** What a reader does about each way the sweep produced no reading. */
|
|
127
143
|
const RESTATED_REFUSALS: Record<RestatedRefusal, string> = {
|
|
128
144
|
'no-instructions': `No ${INSTRUCTIONS_REL} here, or it carries no bullet, so there is no instruction corpus to sweep.`,
|
|
@@ -573,6 +589,188 @@ export function register(program: Command): void {
|
|
|
573
589
|
.action((opts: RestatedOptions) => {
|
|
574
590
|
process.exitCode = runRestated(opts)
|
|
575
591
|
})
|
|
592
|
+
|
|
593
|
+
gov
|
|
594
|
+
.command('audit')
|
|
595
|
+
.description(
|
|
596
|
+
'Report every rule past a shape limit, and in this repository every outside-practice rule missing a source ledger',
|
|
597
|
+
)
|
|
598
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
599
|
+
.argument(
|
|
600
|
+
'[paths...]',
|
|
601
|
+
'Rule files or folders of rules to read, defaulting to the corpus',
|
|
602
|
+
)
|
|
603
|
+
.option('--root <path>', 'Tree to read, defaulting to the cwd')
|
|
604
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
605
|
+
.option(
|
|
606
|
+
'--gate',
|
|
607
|
+
'Set 2 only for a finding in a gated folder, which the push stage reads',
|
|
608
|
+
)
|
|
609
|
+
.addHelpText(
|
|
610
|
+
'after',
|
|
611
|
+
[
|
|
612
|
+
'',
|
|
613
|
+
'Limits, each a finding one past its number:',
|
|
614
|
+
` words ${RULE_LIMITS.words} words in the body`,
|
|
615
|
+
` bullets ${RULE_LIMITS.bullets} bullets in the file`,
|
|
616
|
+
` h2 ${RULE_LIMITS.h2} H2 headings`,
|
|
617
|
+
` perH2 ${RULE_LIMITS.perH2} bullets under one H2, the text above the first H2 a group`,
|
|
618
|
+
` chars ${RULE_LIMITS.chars} characters in one bullet, continuation lines folded in`,
|
|
619
|
+
` sentences ${RULE_LIMITS.sentences} sentences in one bullet`,
|
|
620
|
+
' emphasis any CRITICAL, IMPORTANT, MUST, ALWAYS, NEVER, or "you must"',
|
|
621
|
+
' outside a code span',
|
|
622
|
+
'',
|
|
623
|
+
'Corpus:',
|
|
624
|
+
' A tree holding governance/rules/ reads it and internal/rules/, and',
|
|
625
|
+
` asks a ledger of each rule under ${PRACTICE_FOLDERS.join(', ')}`,
|
|
626
|
+
' at governance/ledger/<folder>/<slug>.md, one table row per bullet',
|
|
627
|
+
' under a heading of the same name.',
|
|
628
|
+
' Any other tree reads .claude/rules/project/ alone and asks no ledger.',
|
|
629
|
+
' .claude/rules/canon/ is fixed at the toolkit source and never read',
|
|
630
|
+
' unless named. A named path resolves against --root and must sit',
|
|
631
|
+
' inside it.',
|
|
632
|
+
'',
|
|
633
|
+
`Gated folders: ${RULE_GATED_FOLDERS.length === 0 ? 'none yet' : RULE_GATED_FOLDERS.join(', ')}`,
|
|
634
|
+
'',
|
|
635
|
+
'Exit codes:',
|
|
636
|
+
' 0 no finding, or under --gate no finding in a gated folder',
|
|
637
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
638
|
+
' 2 at least one finding, or under --gate one in a gated folder',
|
|
639
|
+
'',
|
|
640
|
+
'Reasons:',
|
|
641
|
+
' no-rules the tree holds no rule corpus to read, or a named folder holds no rule',
|
|
642
|
+
' no-path a named path does not exist',
|
|
643
|
+
' outside-root a named path sits outside --root',
|
|
644
|
+
'',
|
|
645
|
+
'Examples:',
|
|
646
|
+
' canon gov audit',
|
|
647
|
+
' canon gov audit .claude/rules/project/api.md',
|
|
648
|
+
' canon gov audit --json',
|
|
649
|
+
'',
|
|
650
|
+
].join('\n'),
|
|
651
|
+
)
|
|
652
|
+
.action(async (paths: string[], opts: AuditOptions) => {
|
|
653
|
+
process.exitCode = await runAudit(paths, opts)
|
|
654
|
+
})
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Reports by default and gates only under `--gate`, and there only for the
|
|
659
|
+
* folders `RULE_GATED_FOLDERS` lists. The corpus starts far from zero, so the
|
|
660
|
+
* push stage fails on a folder once its rewrite has brought it there.
|
|
661
|
+
*/
|
|
662
|
+
async function runAudit(
|
|
663
|
+
paths: readonly string[],
|
|
664
|
+
opts: AuditOptions,
|
|
665
|
+
): Promise<number> {
|
|
666
|
+
const root = resolve(opts.root ?? process.cwd())
|
|
667
|
+
const report = await readRuleAudit(root, paths)
|
|
668
|
+
const emitJson = opts.json ?? false
|
|
669
|
+
|
|
670
|
+
if (report.kind === 'unreadable') {
|
|
671
|
+
intro('canon gov audit')
|
|
672
|
+
logStep('Refused')
|
|
673
|
+
logError(report.message)
|
|
674
|
+
outro()
|
|
675
|
+
|
|
676
|
+
if (emitJson) {
|
|
677
|
+
process.stdout.write(
|
|
678
|
+
`${JSON.stringify({ ok: false, reason: report.reason, message: report.message })}\n`,
|
|
679
|
+
)
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
return 1
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
reportAudit(report, root, paths.length > 0)
|
|
686
|
+
|
|
687
|
+
if (emitJson) {
|
|
688
|
+
process.stdout.write(
|
|
689
|
+
`${JSON.stringify({
|
|
690
|
+
ok: true,
|
|
691
|
+
root,
|
|
692
|
+
limits: RULE_LIMITS,
|
|
693
|
+
gated: RULE_GATED_FOLDERS,
|
|
694
|
+
...report,
|
|
695
|
+
})}\n`,
|
|
696
|
+
)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
return auditExit(report, {
|
|
700
|
+
gate: opts.gate ?? false,
|
|
701
|
+
gated: RULE_GATED_FOLDERS,
|
|
702
|
+
})
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function describeLedger(reading: RuleReading): string[] {
|
|
706
|
+
const { ledger } = reading
|
|
707
|
+
if (ledger.status === 'missing') {
|
|
708
|
+
return [`${reading.file}: no ledger at ${ledger.path}`]
|
|
709
|
+
}
|
|
710
|
+
if (ledger.status !== 'present') return []
|
|
711
|
+
return ledger.mismatches.map(
|
|
712
|
+
(mismatch) =>
|
|
713
|
+
`${ledger.path}: ${mismatch.heading ?? '(above the first H2)'} has ${mismatch.rows} row(s) for ${mismatch.bullets} bullet(s)`,
|
|
714
|
+
)
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function reportAudit(
|
|
718
|
+
report: Extract<RuleAuditReport, { kind: 'measured' }>,
|
|
719
|
+
root: string,
|
|
720
|
+
named: boolean,
|
|
721
|
+
): void {
|
|
722
|
+
intro('canon gov audit')
|
|
723
|
+
|
|
724
|
+
const shaped = report.rules.filter((reading) => reading.findings.length > 0)
|
|
725
|
+
const ledgers = report.rules.flatMap(describeLedger)
|
|
726
|
+
|
|
727
|
+
logStep('Corpus')
|
|
728
|
+
logInfo(
|
|
729
|
+
named
|
|
730
|
+
? `${report.rules.length} named rule(s) in ${root}`
|
|
731
|
+
: `${report.rules.length} rule(s) under ${report.corpus.join(' and ')} in ${root}`,
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
logStep('Shape')
|
|
735
|
+
if (shaped.length === 0) {
|
|
736
|
+
logInfo('every rule is within the six limits and carries no emphasis word')
|
|
737
|
+
} else {
|
|
738
|
+
for (const reading of shaped) {
|
|
739
|
+
for (const finding of reading.findings) {
|
|
740
|
+
const at =
|
|
741
|
+
finding.line === undefined
|
|
742
|
+
? reading.file
|
|
743
|
+
: `${reading.file}:${finding.line}`
|
|
744
|
+
logWarn(
|
|
745
|
+
finding.limit === 'emphasis'
|
|
746
|
+
? `${at}: emphasis word`
|
|
747
|
+
: `${at}: ${finding.limit} ${finding.count} past ${finding.max}`,
|
|
748
|
+
)
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
logStep('Ledgers')
|
|
754
|
+
if (report.scope === 'target') {
|
|
755
|
+
logInfo('not read, since ledgers live in the toolkit and never ship')
|
|
756
|
+
} else if (ledgers.length === 0) {
|
|
757
|
+
logInfo('every outside-practice rule has a ledger row per bullet')
|
|
758
|
+
} else {
|
|
759
|
+
for (const line of ledgers) logWarn(line)
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const failing = report.rules.filter(hasFinding).length
|
|
763
|
+
logStep('Summary')
|
|
764
|
+
logInfo(
|
|
765
|
+
`${failing} of ${report.rules.length} rule(s) carry a finding: ${shaped.length} with a shape finding, ${ledgers.length} ledger finding(s)`,
|
|
766
|
+
)
|
|
767
|
+
logInfo(
|
|
768
|
+
RULE_GATED_FOLDERS.length === 0
|
|
769
|
+
? 'no folder is gated yet, so --gate passes every finding'
|
|
770
|
+
: `gated: ${RULE_GATED_FOLDERS.join(', ')}`,
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
outro()
|
|
576
774
|
}
|
|
577
775
|
|
|
578
776
|
/**
|
package/src/commands/migrate.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
4
|
import { listRepositoryFiles } from '@/git/files'
|
|
5
5
|
import { indexSourceRules } from '@/gov/adapter'
|
|
@@ -25,6 +25,11 @@ import {
|
|
|
25
25
|
type RuleLayoutPlan,
|
|
26
26
|
walkFlatRules,
|
|
27
27
|
} from '@/migrate/rule-layout'
|
|
28
|
+
import {
|
|
29
|
+
applySessionDefaults,
|
|
30
|
+
readSessionDefaults,
|
|
31
|
+
type SessionDefaultsRead,
|
|
32
|
+
} from '@/migrate/session-defaults'
|
|
28
33
|
import {
|
|
29
34
|
SKILL_CITATION_MAP,
|
|
30
35
|
SKILL_NAME_MAP,
|
|
@@ -50,7 +55,7 @@ import {
|
|
|
50
55
|
} from '@/migrate/scratch-evidence'
|
|
51
56
|
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
|
|
52
57
|
import { readStamp, stampedHashes } from '@/sync/stamp'
|
|
53
|
-
import { logError, logInfo, logStep, logWarn, plural } from '@/cli/ui'
|
|
58
|
+
import { logAdd, logError, logInfo, logStep, logWarn, plural } from '@/cli/ui'
|
|
54
59
|
|
|
55
60
|
interface SweepOptions {
|
|
56
61
|
readonly json?: boolean
|
|
@@ -959,6 +964,81 @@ function toRuleLayoutRecord(
|
|
|
959
964
|
}
|
|
960
965
|
}
|
|
961
966
|
|
|
967
|
+
interface SessionDefaultsOptions {
|
|
968
|
+
readonly json?: boolean
|
|
969
|
+
readonly write?: boolean
|
|
970
|
+
readonly root?: string
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Adds the seeded session defaults to a target settings file that predates
|
|
975
|
+
* them. A missing file is nothing to do, since `canon claude init` seeds the
|
|
976
|
+
* whole file with the defaults already in it.
|
|
977
|
+
*/
|
|
978
|
+
async function runSessionDefaults(
|
|
979
|
+
opts: SessionDefaultsOptions,
|
|
980
|
+
): Promise<number> {
|
|
981
|
+
const root = resolve(opts.root ?? process.cwd())
|
|
982
|
+
|
|
983
|
+
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
984
|
+
if (mismatch !== undefined) logWarn(mismatch)
|
|
985
|
+
|
|
986
|
+
const read = await readSessionDefaults(root, PROJECT_ROOT)
|
|
987
|
+
|
|
988
|
+
if (opts.json) {
|
|
989
|
+
process.stdout.write(
|
|
990
|
+
`${JSON.stringify(toSessionDefaultsRecord(read, opts.write))}\n`,
|
|
991
|
+
)
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
if (read.kind === 'refused') {
|
|
995
|
+
logError(read.reason)
|
|
996
|
+
return 1
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (read.kind === 'absent') {
|
|
1000
|
+
logInfo(`No ${read.path}, so there is nothing to migrate.`)
|
|
1001
|
+
return 0
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
for (const key of read.plan.keys) {
|
|
1005
|
+
if (key.changed) logAdd(key.label)
|
|
1006
|
+
else logInfo(key.label)
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
if (!read.plan.changed) {
|
|
1010
|
+
logInfo('Session defaults already present.')
|
|
1011
|
+
return 0
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
if (!opts.write) {
|
|
1015
|
+
logWarn('Nothing was written. Pass --write to apply this plan.')
|
|
1016
|
+
return 2
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
await applySessionDefaults(read)
|
|
1020
|
+
logStep(`Wrote ${read.path}.`)
|
|
1021
|
+
return 0
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function toSessionDefaultsRecord(
|
|
1025
|
+
read: SessionDefaultsRead,
|
|
1026
|
+
wrote: boolean | undefined,
|
|
1027
|
+
): unknown {
|
|
1028
|
+
if (read.kind === 'refused') {
|
|
1029
|
+
return { ok: false, wrote: false, reason: read.reason }
|
|
1030
|
+
}
|
|
1031
|
+
if (read.kind === 'absent') {
|
|
1032
|
+
return { ok: true, wrote: false, path: read.path, keys: [] }
|
|
1033
|
+
}
|
|
1034
|
+
return {
|
|
1035
|
+
ok: true,
|
|
1036
|
+
wrote: wrote === true && read.plan.changed,
|
|
1037
|
+
path: read.path,
|
|
1038
|
+
keys: read.plan.keys,
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
962
1042
|
export function register(program: Command): void {
|
|
963
1043
|
const migrate = program
|
|
964
1044
|
.command('migrate')
|
|
@@ -1322,4 +1402,43 @@ export function register(program: Command): void {
|
|
|
1322
1402
|
.action(async (opts: RuleLayoutOptions) => {
|
|
1323
1403
|
process.exitCode = await runRuleLayout(opts)
|
|
1324
1404
|
})
|
|
1405
|
+
|
|
1406
|
+
migrate
|
|
1407
|
+
.command('session-defaults')
|
|
1408
|
+
.description('Add the seeded session defaults to a target settings file')
|
|
1409
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
1410
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
1411
|
+
.option('--write', 'Apply the plan rather than reporting it')
|
|
1412
|
+
.option(
|
|
1413
|
+
'--root <path>',
|
|
1414
|
+
'Project root, defaulting to the working directory',
|
|
1415
|
+
)
|
|
1416
|
+
.addHelpText(
|
|
1417
|
+
'after',
|
|
1418
|
+
[
|
|
1419
|
+
'',
|
|
1420
|
+
'Adds Agent and Task to permissions.deny in .claude/settings.json,',
|
|
1421
|
+
'appending to the list without reordering it, and sets',
|
|
1422
|
+
'autoMemoryEnabled to false only when the key is absent. A deny entry',
|
|
1423
|
+
'is never removed and an existing autoMemoryEnabled is never changed.',
|
|
1424
|
+
'',
|
|
1425
|
+
'A missing settings file is nothing to do. A file that is not valid',
|
|
1426
|
+
'JSON, or whose permissions or deny has an unexpected shape, is',
|
|
1427
|
+
'refused by name and never partly written.',
|
|
1428
|
+
'',
|
|
1429
|
+
'Exit codes:',
|
|
1430
|
+
' 0 nothing to add, or --write applied the plan',
|
|
1431
|
+
' 1 refused: the toolkit root, or an unreadable settings file',
|
|
1432
|
+
' 2 a plan exists and --write was not passed',
|
|
1433
|
+
'',
|
|
1434
|
+
'Examples:',
|
|
1435
|
+
' canon migrate session-defaults',
|
|
1436
|
+
' canon migrate session-defaults --write',
|
|
1437
|
+
' canon migrate session-defaults --json',
|
|
1438
|
+
'',
|
|
1439
|
+
].join('\n'),
|
|
1440
|
+
)
|
|
1441
|
+
.action(async (opts: SessionDefaultsOptions) => {
|
|
1442
|
+
process.exitCode = await runSessionDefaults(opts)
|
|
1443
|
+
})
|
|
1325
1444
|
}
|