@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.
Files changed (80) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +3 -3
  3. package/claude/skills/ci-workflow/REQUIREMENT.md +2 -1
  4. package/claude/skills/ci-workflow/SKILL.md +12 -2
  5. package/claude/skills/ci-workflow/references/workflows.md +12 -9
  6. package/claude/skills/create-rule/SKILL.md +1 -0
  7. package/claude/skills/decision-escalate/SKILL.md +1 -2
  8. package/claude/skills/document-health/references/axes.md +2 -1
  9. package/claude/skills/draft-doc/references/wiki-kind.md +1 -1
  10. package/claude/skills/draft-doc/references/wiki.md +1 -1
  11. package/claude/skills/git-commit/references/commit.md +1 -5
  12. package/claude/skills/git-pr/references/pr.md +1 -0
  13. package/claude/skills/plan-feature/SKILL.md +1 -1
  14. package/claude/skills/plan-feature/references/plan.md +1 -1
  15. package/claude/skills/pr-evidence/references/evidence.md +1 -1
  16. package/claude/skills/role-orchestrator/SKILL.md +2 -0
  17. package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -0
  18. package/claude/skills/role-planner/REQUIREMENT.md +1 -1
  19. package/claude/skills/role-planner/SKILL.md +3 -3
  20. package/claude/skills/role-worker/REQUIREMENT.md +2 -0
  21. package/claude/skills/role-worker/SKILL.md +26 -2
  22. package/claude/skills/search-craft/REQUIREMENT.md +1 -1
  23. package/claude/skills/search-craft/SKILL.md +2 -2
  24. package/docs/agents/audits.md +1 -0
  25. package/docs/agents/migrate.md +11 -8
  26. package/docs/target/migrations.md +11 -0
  27. package/governance/detect.toml +4 -0
  28. package/governance/rules/canon/context.md +2 -1
  29. package/governance/rules/canon/plan.md +3 -5
  30. package/governance/rules/canon/scratch.md +3 -1
  31. package/governance/rules/canon/task-scope.md +2 -1
  32. package/governance/rules/canon/tasks.md +2 -1
  33. package/governance/rules/canon/worktrees.md +5 -2
  34. package/governance/rules/claude/output.md +7 -4
  35. package/governance/rules/claude/skill.md +6 -4
  36. package/governance/rules/tooling/ci-workflow.md +6 -39
  37. package/governance/rules/ui/design-reference.md +37 -0
  38. package/governance/rules/writing/markdown.md +2 -1
  39. package/package.json +6 -6
  40. package/src/audits/catalog.ts +42 -0
  41. package/src/claude/settings.ts +8 -4
  42. package/src/commands/claude.ts +11 -1
  43. package/src/commands/gov.ts +198 -0
  44. package/src/commands/migrate.ts +121 -2
  45. package/src/commands/pr.ts +4 -77
  46. package/src/commands/tooling.ts +3 -3
  47. package/src/commands/worktrees.ts +49 -0
  48. package/src/gate/stages.ts +15 -0
  49. package/src/gov/audit.ts +440 -0
  50. package/src/listeners/belongs.ts +21 -0
  51. package/src/listeners/machine.ts +93 -0
  52. package/src/listeners/parse.ts +41 -0
  53. package/src/migrate/session-defaults.ts +138 -0
  54. package/src/pr/local.ts +4 -69
  55. package/src/sync/check.ts +15 -0
  56. package/src/tooling/stamp.ts +56 -12
  57. package/src/worktrees/stop.ts +92 -0
  58. package/tooling/base/configs/.github/workflows/phase-label-gate.yml +6 -2
  59. package/tooling/base/configs/commitlint.config.js +0 -20
  60. package/tooling/base/reference.md +1 -1
  61. package/tooling/base/seeds/.github/workflows/verify.yml +4 -1
  62. package/tooling/claude/reference.md +13 -7
  63. package/tooling/claude/seeds/.claude/settings.json +4 -0
  64. package/tooling/claude/user/settings.template.json +0 -4
  65. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +5 -5
  66. package/tooling/public/configs/.github/dependabot.yml +12 -0
  67. package/tooling/public/configs/.github/workflows/release-please.yml +30 -0
  68. package/tooling/public/manifest.toml +8 -0
  69. package/tooling/public/reference.md +53 -0
  70. package/tooling/public/seeds/.github/ISSUE_TEMPLATE/bug.yml +48 -0
  71. package/tooling/public/seeds/.github/ISSUE_TEMPLATE/config.yml +5 -0
  72. package/tooling/public/seeds/.github/ISSUE_TEMPLATE/feature.yml +16 -0
  73. package/tooling/public/seeds/.release-please-manifest.json +3 -0
  74. package/tooling/public/seeds/CONTRIBUTING.md +50 -0
  75. package/tooling/public/seeds/LICENSE +21 -0
  76. package/tooling/public/seeds/SECURITY.md +13 -0
  77. package/tooling/public/seeds/release-please-config.json +12 -0
  78. package/tooling/shell/configs/.github/workflows/shell.yml +4 -1
  79. package/tooling/web/configs/.github/workflows/readme-screenshot.yml +6 -3
  80. 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`. It carries the skill structure, the requirement shape, and the path rules for a skill body. Report it rather than proceeding silently when the skill does not resolve. It ships with the plugin and this rule ships with the CLI, so a project that installed governance alone does not have it.
15
- - Confirm `canon:create-skill`'s two creation-time questions are answered before a new `SKILL.md` lands, whether drafted by hand, by another skill, or by `canon:create-skill` itself. Carry the third question into the sibling `REQUIREMENT.md`'s `Must not` section as a review criterion rather than a gate.
16
- - Re-read a skill body this session edited before invoking that skill again in the same session.
17
- - Do not read a resolved file path in a held body as evidence the body is current.
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: Enforce GitHub Actions job naming, granularity, dependency wiring, and bun pinning
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
- ## Triggers and pinning
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
- - Use `oven-sh/setup-bun@v2` with `bun-version: latest`.
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 the branch's own change moved it. Report the diff without committing when something outside the branch can move it, such as a count read at build time or a font that renders differently per machine, since that commit lands on a branch that never asked for it.
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
- ## Authority
20
+ ## Token scopes
54
21
 
55
- - Load the `canon:ci-workflow` skill for the workflow template and the per-project adaptation. Report it rather than proceeding silently when the skill does not resolve.
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, paragraph and list structure, code spans, the date form, punctuation, emphasis, file references, American spelling, and frontmatter wording. Report it rather than proceeding silently when the skill does not resolve.
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.42.0",
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.59.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.59.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.72.0",
77
- "oxlint": "1.87.0",
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.7.2",
80
+ "prettier-plugin-tailwindcss": "^0.8.1",
81
81
  "typescript": "^5.9.3",
82
82
  "vitest": "^4.1.5"
83
83
  }
@@ -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',
@@ -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
- const attribution = template.attribution
111
- const attributionChanged = !equals(next.attribution, attribution)
112
- if (attributionChanged) next.attribution = attribution
113
- keys.push({ label: 'attribution', changed: attributionChanged })
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 }
@@ -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, attribution)')
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)
@@ -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
  /**
@@ -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
  }