@erclx/canon 4.35.0 → 4.36.1
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/draft-and-pick/SKILL.md +2 -2
- package/claude/skills/migration-standards-drop/SKILL.md +1 -1
- package/docs/agents/commands.md +1 -1
- package/docs/agents/install-and-sync.md +27 -21
- package/docs/agents/routing.md +1 -1
- package/docs/agents/test-order.md +1 -1
- package/docs/target-projects.md +1 -1
- package/governance/rules/lib/305-e2e-reliability.md +1 -1
- package/governance/rules/lib/306-test-scope.md +2 -2
- package/package.json +1 -1
- package/scripts/core/install-check.sh +1 -1
- package/src/commands/feedback.ts +1 -1
- package/src/commands/gov.ts +5 -4
- package/src/commands/migrate.ts +6 -4
- package/src/gate/measures.ts +1 -1
- package/src/github-format.ts +4 -2
- package/src/gov/adapter.ts +10 -9
- package/src/gov/consumed.ts +9 -5
- package/src/gov/install.ts +26 -5
- package/src/gov/restated.ts +1 -1
- package/src/migrate/records.ts +11 -4
- package/src/slides/layouts.ts +81 -0
- package/src/sync/check.ts +1 -1
- package/standards/publish.md +3 -1
- package/standards/rule.md +11 -6
- package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +1 -1
- /package/governance/rules/snippets/{505-at-references.md → 600-at-references.md} +0 -0
|
@@ -16,7 +16,7 @@ Some decisions are settled by looking rather than by reasoning, and no draft is
|
|
|
16
16
|
## Step 1: name the decision and the arms
|
|
17
17
|
|
|
18
18
|
1. State the decision in one sentence, naming what changes between arms and what stays fixed.
|
|
19
|
-
2. Derive a kebab slug from that sentence. Every file this run writes goes to `.canon/tmp/<slug>/`, per `.claude/rules/core/055-scratch.md`.
|
|
19
|
+
2. Derive a kebab slug from that sentence. Every file this run writes goes to `.canon/tmp/<slug>/`, per `.claude/rules/canon/core/055-scratch.md`.
|
|
20
20
|
3. Write one arm per candidate, each carrying an id, a label, and what the arm costs. An arm with no stated cost is not an option.
|
|
21
21
|
4. Make the current state arm `0`, so the baseline is a candidate rather than an absence. A decision with nothing shipped yet says so and starts at arm `1`.
|
|
22
22
|
5. Stop at three to five arms. Two is a comparison the operator can hold in prose, and past five the pick stops being a look and becomes a sort.
|
|
@@ -47,7 +47,7 @@ canon capture .canon/tmp/<slug>/candidates.html --selector <element>
|
|
|
47
47
|
|
|
48
48
|
## Step 4: take the pick
|
|
49
49
|
|
|
50
|
-
Put the choice to the operator through the structured question surface, per `.claude/rules/core/005-behavior.md`.
|
|
50
|
+
Put the choice to the operator through the structured question surface, per `.claude/rules/canon/core/005-behavior.md`.
|
|
51
51
|
|
|
52
52
|
- One option per arm, labeled with the arm's id and carrying its cost as the description.
|
|
53
53
|
- Rank the recommendation first and mark it `(Recommended)`.
|
|
@@ -86,7 +86,7 @@ Print the blocks below, omitting any that is empty.
|
|
|
86
86
|
## Citations to repoint
|
|
87
87
|
|
|
88
88
|
- `CLAUDE.md:42` → `canon standards markdown`
|
|
89
|
-
- `.claude/rules/core/<n>-<slug>.md:8` → `canon standards skill`
|
|
89
|
+
- `.claude/rules/canon/core/<n>-<slug>.md:8` → `canon standards skill`
|
|
90
90
|
|
|
91
91
|
## You supply
|
|
92
92
|
|
package/docs/agents/commands.md
CHANGED
|
@@ -149,7 +149,7 @@ Common patterns:
|
|
|
149
149
|
|
|
150
150
|
`migrate records` moves a project's session records from `.claude/` to `.canon/` and rewrites every tracked file that cites one. It reports until `--write` is passed, and refuses outright when the project does not already ignore `.canon/`, since every folder it relocates is ignored where it stands and landing one under a tracked root commits the memory pen. Take the ignore entry with `canon tooling sync --write` first.
|
|
151
151
|
|
|
152
|
-
A record folder already present at the destination is a refusal rather than a merge, and a line carrying `canon-keep-record-root`, or the line below it, keeps the old spelling for prose that dates a decision. The records themselves are never swept: everything under `.canon/` and every `.claude/` record folder is passed over and reported as a count on its own line, which is what keeps the run that follows the ignore collapse touching the same files as one before it. Running it twice rewrites nothing, which is the check that the exclusions, the markers, and that skip all fired.
|
|
152
|
+
A record folder already present at the destination is a refusal rather than a merge, and a line carrying `canon-keep-record-root`, or the nearest non-blank line below it, keeps the old spelling for prose that dates a decision. The records themselves are never swept: everything under `.canon/` and every `.claude/` record folder is passed over and reported as a count on its own line, which is what keeps the run that follows the ignore collapse touching the same files as one before it. Running it twice rewrites nothing, which is the check that the exclusions, the markers, and that skip all fired.
|
|
153
153
|
|
|
154
154
|
`migrate record-tree` is what reaches the records the sweep above passes over, and the two share no scope. That one enumerates through git, so it sees every tracked file and none of the records, which are gitignored by construction. This one walks `.canon/` itself and is scoped to the folders a session still follows a path into: `diagrams`, `memory`, `plans`, `proposals`, `review`, `tasks`, and `teach`, each minus its own `archive/` subtree. A closed groundwork or intake trail, the scratch folder, and the backup history are reported as counts and never rewritten, because a path inside a closed trail sits in a sentence about work that already ended. Run it after the move, since there is no new root to walk before one.
|
|
155
155
|
|
|
@@ -9,22 +9,25 @@ The behavior notes behind the verbs listed in `commands.md`. Each one records wh
|
|
|
9
9
|
|
|
10
10
|
## Domain sync
|
|
11
11
|
|
|
12
|
-
`canon gov sync` updates only rules already present under
|
|
13
|
-
never adds new ones.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
from the retired build. Use
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
project
|
|
12
|
+
`canon gov sync` updates only rules already present under
|
|
13
|
+
`.claude/rules/canon/` and never adds new ones. That wrapper marks the
|
|
14
|
+
toolkit-owned half of the tree, and it is the whole of what the walk reaches:
|
|
15
|
+
`.claude/rules/project/` sits outside it by location and is never read,
|
|
16
|
+
matched, or reported on, which is how project-authored rules survive. It also
|
|
17
|
+
removes a stale `.claude/GOV.md` from the retired build. Use
|
|
18
|
+
`canon gov install` to add rules.
|
|
19
|
+
|
|
20
|
+
A rule under `.claude/rules/canon/` that the toolkit finds no source for is
|
|
21
|
+
reported orphaned with no destination offered. The toolkit cannot tell a rule
|
|
22
|
+
a project dropped there from one it shipped and later renamed, and a
|
|
23
|
+
destination nested inside `canon/` would be wrong for the first case
|
|
24
|
+
regardless, since that folder is replaced wholesale on sync.
|
|
25
|
+
|
|
26
|
+
Nothing is moved either way, because a rule's installed path is one the
|
|
27
|
+
project's own rules, skills, and docs may cite. `canon standards rule` carries
|
|
28
|
+
the reserved number bands behind the three-way split, where `900-999` under
|
|
29
|
+
`.claude/rules/project/` is what a project-authored rule takes and everything
|
|
30
|
+
under `.claude/rules/canon/` belongs to the toolkit.
|
|
28
31
|
|
|
29
32
|
`canon sync --check` does not report an orphaned entry. It skips every one, so
|
|
30
33
|
the destination reaches `canon gov sync` alone among the two per-file domain
|
|
@@ -52,8 +55,8 @@ without governance has yet to install what every project carries, while one
|
|
|
52
55
|
without design chose that.
|
|
53
56
|
|
|
54
57
|
When the target's install recorded a stack, `canon gov sync` also reports a
|
|
55
|
-
rule that stack lists and `.claude/rules/` does not hold, as a `missing`
|
|
56
|
-
carrying no change. This is what makes a target whose recorded sync point
|
|
58
|
+
rule that stack lists and `.claude/rules/canon/` does not hold, as a `missing`
|
|
59
|
+
entry carrying no change. This is what makes a target whose recorded sync point
|
|
57
60
|
postdates a rule joining its stack still see that rule: the report reads the
|
|
58
61
|
target's current entitlement against its current tree rather than diffing
|
|
59
62
|
from an anchor a later sync could advance past the rule's own commit. A
|
|
@@ -109,9 +112,12 @@ runs the verb again for that name.
|
|
|
109
112
|
|
|
110
113
|
`canon gov regen` is the one governance verb that runs against the toolkit root,
|
|
111
114
|
because the `.claude/rules/` it writes there is produced output rather than an
|
|
112
|
-
operator's working copy. It reads the stack recorded in `internal/governance.toml
|
|
113
|
-
|
|
114
|
-
|
|
115
|
+
operator's working copy. It reads the stack recorded in `internal/governance.toml`
|
|
116
|
+
into `.claude/rules/canon/`, installs anything under `internal/rules/` into a
|
|
117
|
+
separate `.claude/rules/internal/`, and clears both destinations first so a
|
|
118
|
+
rule the record stopped naming disappears. `internal/` is what this repository
|
|
119
|
+
alone carries: no `canon gov install` or `canon gov sync` target ever writes it,
|
|
120
|
+
since a target has no rule source of its own that ships nowhere.
|
|
115
121
|
|
|
116
122
|
It takes `--root <path>` and defaults to the toolkit root, prints nothing on success, and
|
|
117
123
|
reports the reason on stderr with exit 1 when the record names a stack or rule
|
package/docs/agents/routing.md
CHANGED
|
@@ -5,7 +5,7 @@ description: Reading per CLAUDE.md section how many bullets name a path, what co
|
|
|
5
5
|
|
|
6
6
|
# Routing report
|
|
7
7
|
|
|
8
|
-
`canon claude routing` reports, per `CLAUDE.md` section, how many top-level bullets name a path and how many of those a path-scoped rule already covers. It answers the firing axis of the tier test in `.claude/rules/claude/592-claude-md.md`, whether a fact applies every session or fires on one path, and which until this verb existed was a judgment nothing counted. `.claude/ARCHITECTURE.md` names two further axes, conditional presence and updatability, that this verb does not check.
|
|
8
|
+
`canon claude routing` reports, per `CLAUDE.md` section, how many top-level bullets name a path and how many of those a path-scoped rule already covers. It answers the firing axis of the tier test in `.claude/rules/canon/claude/592-claude-md.md`, whether a fact applies every session or fires on one path, and which until this verb existed was a judgment nothing counted. `.claude/ARCHITECTURE.md` names two further axes, conditional presence and updatability, that this verb does not check.
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
11
|
canon claude routing
|
|
@@ -5,7 +5,7 @@ description: Reading where an implementation reached history ahead of its test,
|
|
|
5
5
|
|
|
6
6
|
# Test order
|
|
7
7
|
|
|
8
|
-
`canon gov test-order` reports where an implementation reached a commit ahead of the test covering the same behavior. It answers the rule in `.claude/rules/core/070-planning.md` that asks for the test first, which until this verb existed was prose loaded on a glob match with nothing measuring it.
|
|
8
|
+
`canon gov test-order` reports where an implementation reached a commit ahead of the test covering the same behavior. It answers the rule in `.claude/rules/canon/core/070-planning.md` that asks for the test first, which until this verb existed was prose loaded on a glob match with nothing measuring it.
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
11
|
canon gov test-order
|
package/docs/target-projects.md
CHANGED
|
@@ -142,7 +142,7 @@ Order matters between the first line and the two under it. The sync prunes the t
|
|
|
142
142
|
|
|
143
143
|
Read the `ok` field out of the `--json` record rather than the exit code. A shell profile that wraps `canon` in a function takes its status from whatever the function runs last, so an absent subcommand and a clean run can both exit 0, and a reader watching the exit alone concludes the move happened.
|
|
144
144
|
|
|
145
|
-
A tracked file that names an old record path on purpose, such as prose dating a decision, keeps it by carrying `canon-keep-record-root` on that line or the
|
|
145
|
+
A tracked file that names an old record path on purpose, such as prose dating a decision, keeps it by carrying `canon-keep-record-root` on that line or the nearest non-blank line above. The report pass prints every file it would rewrite, which is where to catch one before `--write` runs.
|
|
146
146
|
|
|
147
147
|
Until the move runs, the project is exposed. The shipped ignore set no longer names the old record paths, so a project holding records at `.claude/` stops ignoring them on its next `canon tooling sync`, and the first sign is a memory file or a task board appearing in a commit. Every command reads either root, so nothing else breaks in the meantime, and running the move is what closes it.
|
|
148
148
|
|
|
@@ -21,5 +21,5 @@ paths:
|
|
|
21
21
|
|
|
22
22
|
- Answer a question about the running page with a script against the dev server rather than with the suite.
|
|
23
23
|
- Do not enable `fullyParallel` in `playwright.config.ts`.
|
|
24
|
-
- Follow `.claude/rules/ui/440-surface-capture.md` for capture scope.
|
|
25
|
-
- Follow `.claude/rules/lib/305-e2e-reliability.md` for waits and guards.
|
|
24
|
+
- Follow `.claude/rules/canon/ui/440-surface-capture.md` for capture scope.
|
|
25
|
+
- Follow `.claude/rules/canon/lib/305-e2e-reliability.md` for waits and guards.
|
package/package.json
CHANGED
|
@@ -97,7 +97,7 @@ log_step "Assert scaffold"
|
|
|
97
97
|
# folder-whole entry the same way it does `core` and `claude`.
|
|
98
98
|
for path in "CLAUDE.md" ".claude/wiki/index.md" ".claude" ".claude/context/index.md" ".claude/wireframes/index.md" ".canon/diagrams/index.md" \
|
|
99
99
|
".prettierrc" ".editorconfig" ".lintstagedrc" ".husky/pre-commit" ".github/workflows/verify.yml" "scripts/verify.sh" \
|
|
100
|
-
".claude/rules/core/000-constitution.md" ".claude/rules/snippets/
|
|
100
|
+
".claude/rules/canon/core/000-constitution.md" ".claude/rules/canon/snippets/600-at-references.md"; do
|
|
101
101
|
if [ ! -e "$TARGET_DIR/$path" ]; then
|
|
102
102
|
log_error "Missing after canon init: $path"
|
|
103
103
|
fi
|
package/src/commands/feedback.ts
CHANGED
|
@@ -106,7 +106,7 @@ export function register(program: Command): void {
|
|
|
106
106
|
}
|
|
107
107
|
const reason = issueFailureMessage(result)
|
|
108
108
|
if (!isToolkitSource()) {
|
|
109
|
-
frameError(
|
|
109
|
+
frameError(reason)
|
|
110
110
|
process.exitCode = 1
|
|
111
111
|
return
|
|
112
112
|
}
|
package/src/commands/gov.ts
CHANGED
|
@@ -78,6 +78,7 @@ function payloadRel(root: string): string {
|
|
|
78
78
|
return creationRel(root, SCRATCH, 'gov', 'rules.md')
|
|
79
79
|
}
|
|
80
80
|
const RULES_REL = join('.claude', 'rules')
|
|
81
|
+
const CANON_RULES_REL = join('.claude', 'rules', 'canon')
|
|
81
82
|
|
|
82
83
|
interface InstallOptions {
|
|
83
84
|
readonly add?: string
|
|
@@ -139,7 +140,7 @@ export function register(program: Command): void {
|
|
|
139
140
|
|
|
140
141
|
gov
|
|
141
142
|
.command('install')
|
|
142
|
-
.description('Install a governance stack into .claude/rules/')
|
|
143
|
+
.description('Install a governance stack into .claude/rules/canon/')
|
|
143
144
|
.argument('[stack]', 'Stack name (e.g. base, node, react)')
|
|
144
145
|
.argument('[target]', 'Target directory', '.')
|
|
145
146
|
.helpOption('-h, --help', 'Show this help message')
|
|
@@ -167,7 +168,7 @@ export function register(program: Command): void {
|
|
|
167
168
|
|
|
168
169
|
gov
|
|
169
170
|
.command('sync')
|
|
170
|
-
.description('Update rules already installed under .claude/rules/')
|
|
171
|
+
.description('Update rules already installed under .claude/rules/canon/')
|
|
171
172
|
.argument('[target]', 'Target directory', '.')
|
|
172
173
|
.helpOption('-h, --help', 'Show this help message')
|
|
173
174
|
.action(async (target: string) => {
|
|
@@ -230,7 +231,7 @@ export function register(program: Command): void {
|
|
|
230
231
|
'after',
|
|
231
232
|
[
|
|
232
233
|
'',
|
|
233
|
-
'Measures the rule in .claude/rules/core/070-planning.md that asks for',
|
|
234
|
+
'Measures the rule in .claude/rules/canon/core/070-planning.md that asks for',
|
|
234
235
|
'the test before the code. It reports and never gates, because pairing',
|
|
235
236
|
'a test to an implementation is a judgment.',
|
|
236
237
|
'',
|
|
@@ -1191,7 +1192,7 @@ async function runInstall(
|
|
|
1191
1192
|
for (const source of found) logInfo(source.rule)
|
|
1192
1193
|
|
|
1193
1194
|
const shouldInstall = await select({
|
|
1194
|
-
message: `Install ${found.length} rules to ${displayPath(target,
|
|
1195
|
+
message: `Install ${found.length} rules to ${displayPath(target, CANON_RULES_REL)}?`,
|
|
1195
1196
|
options: [
|
|
1196
1197
|
{ value: true, label: 'Yes' },
|
|
1197
1198
|
{ value: false, label: 'No' },
|
package/src/commands/migrate.ts
CHANGED
|
@@ -435,8 +435,9 @@ export function register(program: Command): void {
|
|
|
435
435
|
'this relocates is ignored where it stands, and landing one under a root',
|
|
436
436
|
'the project tracks publishes the memory pen into the next commit.',
|
|
437
437
|
'',
|
|
438
|
-
'A line carrying canon-keep-record-root, or the
|
|
439
|
-
'old root. Prose that dates a decision needs it; a
|
|
438
|
+
'A line carrying canon-keep-record-root, or the nearest non-blank line',
|
|
439
|
+
'below it, keeps the old root. Prose that dates a decision needs it; a',
|
|
440
|
+
'live path does not.',
|
|
440
441
|
'',
|
|
441
442
|
'The records themselves are never swept. Everything under .canon/ and',
|
|
442
443
|
'every .claude/ record folder is left alone and reported as a count, so',
|
|
@@ -487,8 +488,9 @@ export function register(program: Command): void {
|
|
|
487
488
|
'The records are untracked and unbacked, so a wrong rewrite has no undo',
|
|
488
489
|
'and the report is what a reader judges before passing --write.',
|
|
489
490
|
'',
|
|
490
|
-
'A line carrying canon-keep-record-root, or the
|
|
491
|
-
'old root. Prose that dates a decision needs it; a
|
|
491
|
+
'A line carrying canon-keep-record-root, or the nearest non-blank line',
|
|
492
|
+
'below it, keeps the old root. Prose that dates a decision needs it; a',
|
|
493
|
+
'live path does not.',
|
|
492
494
|
'',
|
|
493
495
|
'Examples:',
|
|
494
496
|
' canon migrate record-tree',
|
package/src/gate/measures.ts
CHANGED
|
@@ -85,7 +85,7 @@ const output = (text: string): Emission => ({ kind: 'output', text })
|
|
|
85
85
|
/**
|
|
86
86
|
* Rules no stack reaches, sorted the way `canon gov list` emits them.
|
|
87
87
|
* `260-shadcn` and `320-tanstack-query` are opt-in libraries a project may not
|
|
88
|
-
* want. `
|
|
88
|
+
* want. `600-at-references` used to sit here too, shipping with no stack on
|
|
89
89
|
* purpose since a rule under `claude/` would reach every base consumer through
|
|
90
90
|
* the folder-whole entry there. Its own install channel, `canon snippets
|
|
91
91
|
* install`, retired with nothing left to deliver it, so `base` now carries
|
package/src/github-format.ts
CHANGED
|
@@ -25,13 +25,15 @@ export interface IssueSuccess {
|
|
|
25
25
|
|
|
26
26
|
export type CreateIssueResult = IssueSuccess | IssueFailure
|
|
27
27
|
|
|
28
|
-
const
|
|
28
|
+
const REPO = 'erclx/canon'
|
|
29
|
+
const ISSUE_URL = `https://github.com/${REPO}/issues/new`
|
|
29
30
|
|
|
30
31
|
export function buildIssueArgs(opts: CreateIssueOptions): string[] {
|
|
31
32
|
const args = ['issue', 'create', '--title', opts.title, '--body', opts.body]
|
|
32
33
|
for (const label of opts.labels ?? []) {
|
|
33
34
|
args.push('--label', label)
|
|
34
35
|
}
|
|
36
|
+
args.push('--repo', REPO)
|
|
35
37
|
return args
|
|
36
38
|
}
|
|
37
39
|
|
|
@@ -71,5 +73,5 @@ export function issueFailureMessage(failure: IssueFailure): string {
|
|
|
71
73
|
if (failure.reason === 'missing-binary') {
|
|
72
74
|
return `gh is not installed, so no issue was filed. Install gh, or file it at ${ISSUE_URL}`
|
|
73
75
|
}
|
|
74
|
-
return `gh could not file the issue: ${failure.detail ?? 'no diagnostic on stderr'}
|
|
76
|
+
return `gh could not file the issue: ${failure.detail ?? 'no diagnostic on stderr'}. File it manually at ${ISSUE_URL} instead.`
|
|
75
77
|
}
|
package/src/gov/adapter.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { basename, join, relative, resolve } from 'node:path'
|
|
3
|
+
import { canonRulesDir } from '@/gov/install'
|
|
3
4
|
import { resolveMissingRules } from '@/gov/stacks'
|
|
4
5
|
import type { InstalledFile, RetiredSurface, SyncAdapter } from '@/sync/engine'
|
|
5
6
|
import { readStamp, stampedChain } from '@/sync/stamp'
|
|
@@ -42,6 +43,13 @@ export function indexSourceRules(root: string): Map<string, string> {
|
|
|
42
43
|
* Matches installed rules to sources by rule name rather than by relative
|
|
43
44
|
* path, so a rule that moved between subdirectories in the toolkit still
|
|
44
45
|
* syncs into the subdirectory the target already uses.
|
|
46
|
+
*
|
|
47
|
+
* Declares no `projectSubdir`. That exemption existed to keep a project's
|
|
48
|
+
* `.claude/rules/project/` folder out of the walk by location before any
|
|
49
|
+
* name lookup ran, and `installedRoot` narrowing to `.claude/rules/canon/`
|
|
50
|
+
* already leaves `project/` outside the walked root, so a second exemption
|
|
51
|
+
* computing a stale `canon/project/` destination would be wrong rather than
|
|
52
|
+
* merely redundant.
|
|
45
53
|
*/
|
|
46
54
|
export function createGovAdapter(root: string): SyncAdapter {
|
|
47
55
|
const index = indexSourceRules(root)
|
|
@@ -52,12 +60,11 @@ export function createGovAdapter(root: string): SyncAdapter {
|
|
|
52
60
|
missingMessage:
|
|
53
61
|
"No governance surfaces found in target. Run 'canon gov install' first.",
|
|
54
62
|
unit: 'changes',
|
|
55
|
-
installedRoot:
|
|
63
|
+
installedRoot: canonRulesDir,
|
|
56
64
|
locateSource: (file: InstalledFile) =>
|
|
57
65
|
index.get(basename(file.path, '.md')),
|
|
58
66
|
collectRetired: (target: string) => collectRetiredGov(target),
|
|
59
67
|
collectMissing: (target: string) => collectMissingGov(root, target),
|
|
60
|
-
projectSubdir: 'project',
|
|
61
68
|
stamp: { domain: 'governance', toolkitRoot: root },
|
|
62
69
|
}
|
|
63
70
|
}
|
|
@@ -72,13 +79,7 @@ function collectMissingGov(root: string, target: string): RetiredSurface[] {
|
|
|
72
79
|
const chain = stampedChain(readStamp(target), 'governance')
|
|
73
80
|
|
|
74
81
|
return resolveMissingRules(root, target, chain).map((source) => {
|
|
75
|
-
const dest = join(
|
|
76
|
-
target,
|
|
77
|
-
'.claude',
|
|
78
|
-
'rules',
|
|
79
|
-
source.subdir,
|
|
80
|
-
`${source.rule}.md`,
|
|
81
|
-
)
|
|
82
|
+
const dest = join(canonRulesDir(target), source.subdir, `${source.rule}.md`)
|
|
82
83
|
const rel = relative(target, dest)
|
|
83
84
|
return {
|
|
84
85
|
path: dest,
|
package/src/gov/consumed.ts
CHANGED
|
@@ -2,8 +2,9 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
2
2
|
import { rm } from 'node:fs/promises'
|
|
3
3
|
import { basename, join } from 'node:path'
|
|
4
4
|
import {
|
|
5
|
+
canonRulesDir,
|
|
6
|
+
installedInternalRulesDir,
|
|
5
7
|
installRules,
|
|
6
|
-
installedRulesDir,
|
|
7
8
|
lookupRules,
|
|
8
9
|
type RuleSource,
|
|
9
10
|
ruleSubdir,
|
|
@@ -117,12 +118,15 @@ export async function regenConsumedRules(
|
|
|
117
118
|
|
|
118
119
|
// Clearing first is what makes a rule the record stopped naming disappear.
|
|
119
120
|
// Copying over the destination would leave it behind as an unsourced file,
|
|
120
|
-
// which is the state this producer exists to end.
|
|
121
|
-
|
|
121
|
+
// which is the state this producer exists to end. Each subtree clears on
|
|
122
|
+
// its own rather than through the shared `.claude/rules/` parent, so a
|
|
123
|
+
// `project/` folder landing beside them later is never in the blast radius.
|
|
124
|
+
await rm(canonRulesDir(root), { recursive: true, force: true })
|
|
125
|
+
await rm(installedInternalRulesDir(root), { recursive: true, force: true })
|
|
122
126
|
|
|
123
127
|
const installed = [
|
|
124
|
-
...(await installRules(found, root)),
|
|
125
|
-
...(await installRules(internal, root)),
|
|
128
|
+
...(await installRules(found, root, 'canon')),
|
|
129
|
+
...(await installRules(internal, root, 'internal')),
|
|
126
130
|
]
|
|
127
131
|
|
|
128
132
|
return { ok: true, installed: installed.sort() }
|
package/src/gov/install.ts
CHANGED
|
@@ -21,13 +21,26 @@ export function installedRulesDir(target: string): string {
|
|
|
21
21
|
return join(target, '.claude', 'rules')
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** Toolkit-shipped stack rules, wrapped so their source reads from location. */
|
|
25
|
+
export function canonRulesDir(target: string): string {
|
|
26
|
+
return join(installedRulesDir(target), 'canon')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* This repository's own toolkit-only rules, installed beside `canon/` rather
|
|
31
|
+
* than interleaved into its bands. No target ever holds this folder.
|
|
32
|
+
*/
|
|
33
|
+
export function installedInternalRulesDir(target: string): string {
|
|
34
|
+
return join(installedRulesDir(target), 'internal')
|
|
35
|
+
}
|
|
36
|
+
|
|
24
37
|
/**
|
|
25
|
-
* Rule names a target already holds, read off the installed tree by
|
|
26
|
-
* rather than off a recorded stack, since a target may hold rules
|
|
27
|
-
* layered on that no stack lists.
|
|
38
|
+
* Rule names a target already holds, read off the installed `canon/` tree by
|
|
39
|
+
* basename rather than off a recorded stack, since a target may hold rules
|
|
40
|
+
* `--add` layered on that no stack lists.
|
|
28
41
|
*/
|
|
29
42
|
export function installedRuleNames(target: string): Set<string> {
|
|
30
|
-
const dir =
|
|
43
|
+
const dir = canonRulesDir(target)
|
|
31
44
|
const names = new Set<string>()
|
|
32
45
|
if (!existsSync(dir)) return names
|
|
33
46
|
|
|
@@ -114,12 +127,20 @@ export function lookupRules(
|
|
|
114
127
|
* Copies each rule into the subdirectory it was authored in, so the installed
|
|
115
128
|
* tree keeps the band structure `governance/rules/` carries. Returns the
|
|
116
129
|
* target-relative paths the timeline prints.
|
|
130
|
+
*
|
|
131
|
+
* `destSubdir` picks which wrapper the rule lands under: `canon` for every
|
|
132
|
+
* target-facing install, and `internal` only for this repository's own
|
|
133
|
+
* toolkit-only rules, which `regenConsumedRules` installs separately.
|
|
117
134
|
*/
|
|
118
135
|
export async function installRules(
|
|
119
136
|
sources: readonly RuleSource[],
|
|
120
137
|
target: string,
|
|
138
|
+
destSubdir: 'canon' | 'internal' = 'canon',
|
|
121
139
|
): Promise<string[]> {
|
|
122
|
-
const rulesDir =
|
|
140
|
+
const rulesDir =
|
|
141
|
+
destSubdir === 'canon'
|
|
142
|
+
? canonRulesDir(target)
|
|
143
|
+
: installedInternalRulesDir(target)
|
|
123
144
|
const installed: string[] = []
|
|
124
145
|
|
|
125
146
|
for (const source of sources) {
|
package/src/gov/restated.ts
CHANGED
|
@@ -699,7 +699,7 @@ export function readRestated(root: string): RestatedReport {
|
|
|
699
699
|
// Every candidate's rare set is invariant across the subject loop, so it is
|
|
700
700
|
// built once here rather than per pair. The corpora multiply out to hundreds
|
|
701
701
|
// of thousands of pairings, and rebuilding a set inside that is the shape
|
|
702
|
-
// `.claude/rules/core/040-performance.md` names.
|
|
702
|
+
// `.claude/rules/canon/core/040-performance.md` names.
|
|
703
703
|
const rareCandidates = candidates.map((candidate) => ({
|
|
704
704
|
...candidate,
|
|
705
705
|
rare: distinctive(candidate.analysis),
|
package/src/migrate/records.ts
CHANGED
|
@@ -134,7 +134,7 @@ export const RECORD_ONLY_ROOTS: readonly RecordRoot[] = RECORD_ROOTS.filter(
|
|
|
134
134
|
*
|
|
135
135
|
* The asymmetry is the point. A whole-root prefix is correct for the new root
|
|
136
136
|
* and wrong for the old one, which is mixed: this repository tracks 163 files
|
|
137
|
-
* under `.claude/`, and a target's installed `.claude/rules/core/035-tasks.md`
|
|
137
|
+
* under `.claude/`, and a target's installed `.claude/rules/canon/core/035-tasks.md`
|
|
138
138
|
* is the file the sweep exists to repoint, so a bare `.claude/` prefix strands
|
|
139
139
|
* it silently. The old root is therefore entry-scoped, through `spell` so the
|
|
140
140
|
* one naming variant stays decided in `record-root.ts`.
|
|
@@ -180,14 +180,21 @@ export function isRecordArtifact(path: string): boolean {
|
|
|
180
180
|
* Prose that dates a decision, records where a defect landed, or names the
|
|
181
181
|
* fallback a target still resolves through all have to keep saying `.claude/`,
|
|
182
182
|
* and a sweep cannot tell those from a live path. The marker sits on the line
|
|
183
|
-
* itself or on the
|
|
184
|
-
*
|
|
183
|
+
* itself or on the nearest non-blank line above it, walking past a run of
|
|
184
|
+
* blank markdown lines to reach a marker written on its own line above the
|
|
185
|
+
* paragraph it protects. `canon-keep-retired` and `canon-allow-superseded`
|
|
186
|
+
* mark code instead, where no blank line ever falls between the marker and
|
|
187
|
+
* the line it protects, so neither needs the walk.
|
|
185
188
|
*/
|
|
186
189
|
const KEEP_MARKER = 'canon-keep-record-root'
|
|
187
190
|
|
|
188
191
|
function isKept(lines: readonly string[], index: number): boolean {
|
|
189
192
|
if (lines[index]?.includes(KEEP_MARKER)) return true
|
|
190
|
-
|
|
193
|
+
|
|
194
|
+
let above = index - 1
|
|
195
|
+
while (above >= 0 && lines[above]?.trim() === '') above -= 1
|
|
196
|
+
|
|
197
|
+
return above >= 0 && (lines[above]?.includes(KEEP_MARKER) ?? false)
|
|
191
198
|
}
|
|
192
199
|
|
|
193
200
|
/** Rewrites every unmarked citation into a moved entry. */
|
package/src/slides/layouts.ts
CHANGED
|
@@ -30,6 +30,11 @@ export const LAYOUTS: LayoutInfo[] = [
|
|
|
30
30
|
{ name: 'stat-callout', description: 'Row of large stats with captions' },
|
|
31
31
|
{ name: 'grid', description: 'Two-by-two cards, each a term and detail' },
|
|
32
32
|
{ name: 'quote', description: 'Large pull quote with attribution' },
|
|
33
|
+
{
|
|
34
|
+
name: 'freeform',
|
|
35
|
+
description:
|
|
36
|
+
'Explicit shapes at a declared position and size, for a figure the other layouts cannot draw',
|
|
37
|
+
},
|
|
33
38
|
]
|
|
34
39
|
|
|
35
40
|
const LAYOUT_NAMES = new Set(LAYOUTS.map((layout) => layout.name))
|
|
@@ -55,6 +60,7 @@ const RENDERERS: Record<
|
|
|
55
60
|
'stat-callout': renderStatCallout,
|
|
56
61
|
grid: renderGrid,
|
|
57
62
|
quote: renderQuote,
|
|
63
|
+
freeform: renderFreeform,
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
export function renderDeckSlide(
|
|
@@ -346,6 +352,81 @@ function renderQuote(s: PptxSlide, slide: Slide, theme: Theme): void {
|
|
|
346
352
|
}
|
|
347
353
|
}
|
|
348
354
|
|
|
355
|
+
const SHAPE_KEYS = new Set(['x', 'y', 'w', 'h', 'color'])
|
|
356
|
+
const COLOR_ROLES: (keyof Theme)[] = [
|
|
357
|
+
'background',
|
|
358
|
+
'surface',
|
|
359
|
+
'ink',
|
|
360
|
+
'muted',
|
|
361
|
+
'accent',
|
|
362
|
+
]
|
|
363
|
+
|
|
364
|
+
function renderFreeform(s: PptxSlide, slide: Slide, theme: Theme): void {
|
|
365
|
+
addTitle(s, slide.title, theme)
|
|
366
|
+
for (const line of slide.content) {
|
|
367
|
+
if (!line.trim().startsWith('-')) throw malformedShape(slide.title, line)
|
|
368
|
+
renderShapeLine(s, line, slide.title, theme)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function malformedShape(title: string, line: string): Error {
|
|
373
|
+
return new Error(
|
|
374
|
+
`Malformed freeform shape on slide "${title}": ${line.trim()}`,
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function renderShapeLine(
|
|
379
|
+
s: PptxSlide,
|
|
380
|
+
line: string,
|
|
381
|
+
title: string,
|
|
382
|
+
theme: Theme,
|
|
383
|
+
): void {
|
|
384
|
+
const stripped = line.replace(/^\s*-\s*/, '')
|
|
385
|
+
const colonIndex = stripped.indexOf(':')
|
|
386
|
+
const head = (
|
|
387
|
+
colonIndex === -1 ? stripped : stripped.slice(0, colonIndex)
|
|
388
|
+
).trim()
|
|
389
|
+
const text = colonIndex === -1 ? '' : stripped.slice(colonIndex + 1).trim()
|
|
390
|
+
|
|
391
|
+
const tokens = head.split(/\s+/).filter(Boolean)
|
|
392
|
+
const kind = tokens.shift()
|
|
393
|
+
if (kind !== 'text' && kind !== 'rect') throw malformedShape(title, line)
|
|
394
|
+
if (kind === 'text' && colonIndex === -1) throw malformedShape(title, line)
|
|
395
|
+
|
|
396
|
+
const attrs: Record<string, string> = {}
|
|
397
|
+
for (const token of tokens) {
|
|
398
|
+
const match = token.match(/^(\w+)=(.+)$/)
|
|
399
|
+
if (!match) throw malformedShape(title, line)
|
|
400
|
+
if (!SHAPE_KEYS.has(match[1])) throw malformedShape(title, line)
|
|
401
|
+
attrs[match[1]] = match[2]
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const x = Number(attrs.x)
|
|
405
|
+
const y = Number(attrs.y)
|
|
406
|
+
const w = Number(attrs.w)
|
|
407
|
+
const h = Number(attrs.h)
|
|
408
|
+
if (![x, y, w, h].every(Number.isFinite)) throw malformedShape(title, line)
|
|
409
|
+
|
|
410
|
+
const role = attrs.color as keyof Theme
|
|
411
|
+
if (!COLOR_ROLES.includes(role)) throw malformedShape(title, line)
|
|
412
|
+
const color = theme[role]
|
|
413
|
+
|
|
414
|
+
if (kind === 'text') {
|
|
415
|
+
s.addText(clean(text), {
|
|
416
|
+
x,
|
|
417
|
+
y,
|
|
418
|
+
w,
|
|
419
|
+
h,
|
|
420
|
+
fontFace: FONTS.body,
|
|
421
|
+
fontSize: TYPE.body,
|
|
422
|
+
color,
|
|
423
|
+
valign: 'top',
|
|
424
|
+
})
|
|
425
|
+
} else {
|
|
426
|
+
s.addShape('rect', { x, y, w, h, fill: { color } })
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
349
430
|
function addTitle(s: PptxSlide, title: string, theme: Theme): void {
|
|
350
431
|
if (!title) return
|
|
351
432
|
s.addText(clean(title), {
|
package/src/sync/check.ts
CHANGED
|
@@ -67,7 +67,7 @@ const ADAPTERS: Record<ScannedDomain, (root: string) => SyncAdapter> = {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
const INSTALL_MARKERS: Record<ScannedDomain, readonly string[]> = {
|
|
70
|
-
governance: ['.claude', 'rules'],
|
|
70
|
+
governance: ['.claude', 'rules', 'canon'],
|
|
71
71
|
design: DESIGN_INSTALL_DIR.split(sep),
|
|
72
72
|
}
|
|
73
73
|
|
package/standards/publish.md
CHANGED
|
@@ -40,7 +40,9 @@ This check is one of the two the destination rule above scopes. The reader insid
|
|
|
40
40
|
|
|
41
41
|
A phase label is one way text names the board, and a path under a record root is the other. Both resolve for a reader holding this checkout and neither resolves for anyone else, so this check is the second one the destination rule scopes.
|
|
42
42
|
|
|
43
|
-
Two shapes get past a reader scanning for a bare label. A code span quoting a label is still the label, so read a span whose whole content is one as a hit and leave a longer token inside a span alone, which is a fixture name rather than a reference. The second shape is a path under a record root, gitignored and therefore absent from every clone, so `.canon/review/feedback/` names a folder the remote's reader cannot open.
|
|
43
|
+
Two shapes get past a reader scanning for a bare label. A code span quoting a label is still the label, so read a span whose whole content is one as a hit and leave a longer token inside a span alone, which is a fixture name rather than a reference. The second shape is a path under a record root, gitignored and therefore absent from every clone, so `.canon/review/feedback/` names a folder the remote's reader cannot open.
|
|
44
|
+
|
|
45
|
+
Under `.claude/`'s own tracked folders there is no hit, since `.claude/context/governance/rules.md` resolves everywhere. `.canon/` carries no such carve-out: one ignore line covers the root whole, so every path beneath it is a hit regardless of which folder names it.
|
|
44
46
|
|
|
45
47
|
Rewrite a hit to name what the reader can reach rather than deleting it. A row's subject stated plainly replaces its label, and what a record folder holds, said in a sentence, replaces its path.
|
|
46
48
|
|
package/standards/rule.md
CHANGED
|
@@ -31,27 +31,32 @@ Write both when both apply. A rule stating the directive and a skill stating how
|
|
|
31
31
|
|
|
32
32
|
## Location
|
|
33
33
|
|
|
34
|
-
-
|
|
34
|
+
- A toolkit-shipped rule lives at `.claude/rules/canon/<subdirectory>/<n>-<slug>.md`. The wrapper marks the file as toolkit-owned and replaced on sync, so a reader unfamiliar with the numbering convention still knows not to edit it.
|
|
35
35
|
- Subdirectories group by domain: `core/`, `lang/`, `framework/`, `lib/`, `ui/`, `claude/`, `snippets/`, `ci/`
|
|
36
36
|
- `<n>` is a number from the band reserved for the rule's source and `<slug>` is a one-to-three-word kebab topic
|
|
37
|
-
- A rule the project authored itself lives at `.claude/rules/project/<subdirectory>/<n>-<slug>.md`, keeping the same subdirectory names
|
|
37
|
+
- A rule the project authored itself lives at `.claude/rules/project/<subdirectory>/<n>-<slug>.md`, a sibling of `canon/` rather than nested inside it, keeping the same subdirectory names
|
|
38
|
+
- This repository's own toolkit-only rules, which never ship to a target, live at `.claude/rules/internal/<subdirectory>/<n>-<slug>.md`, a second sibling of `canon/`. See "Two sources numbering into one folder" below.
|
|
38
39
|
- Give every rule a numeric prefix. A bare-word filename reads as a folder name where a stack names its rules, so a rule without one is unreachable from a stack entry.
|
|
39
40
|
|
|
40
41
|
## Reserved numbers
|
|
41
42
|
|
|
42
|
-
`000-899` is reserved for a rule set that ships to targets. `000-599` is what the first six subdirectories above already divide, at 100 per subdirectory. `ci/` was the first to draw from the headroom past that, taking `700-799
|
|
43
|
+
`000-899` is reserved for a rule set that ships to targets. `000-599` is what the first six subdirectories above already divide, at 100 per subdirectory. `ci/` was the first to draw from the headroom past that, taking `700-799`.
|
|
44
|
+
|
|
45
|
+
`snippets/` holds one rule rather than a subdirectory's worth of them, and draws a single number, `600`, from the nearer overflow band rather than claiming a hundred for one file. The rest of `600-699`, along with `800-899`, is what remains for a subdirectory a shipped set has not added yet.
|
|
43
46
|
|
|
44
47
|
A project-authored rule takes `900-999`, one sequence across every subdirectory under `.claude/rules/project/`. Scanning for a free number instead is what fails, because a shipped set fills its own band release by release, so what reads as free today is what a later release lands on. One target authored `claude/561-self-check.md` on a day nothing shipped at 561, met `claude/561-teach.md` on its next install, and now reads two numbers differently from everywhere else.
|
|
45
48
|
|
|
46
49
|
The cost is that a project-authored rule's leading digit stops naming its domain. Its subdirectory names it instead, and install preserves that either way. What a shared band costs is worse: a session that loads two rules reading as one number, with nothing in the folder to say which is which.
|
|
47
50
|
|
|
48
|
-
`canon gov sync`
|
|
51
|
+
`canon gov sync` walks only `.claude/rules/canon/`, so `.claude/rules/project/` sits outside the walk by location and is never matched, read, or reported on. A file under `.claude/rules/canon/` itself that no toolkit source names is still reported orphaned, but with no destination offered: the toolkit cannot tell a rule the project dropped there from one it shipped and later renamed, and a destination nested inside `canon/` would be wrong for the first case regardless, since that folder is toolkit-owned and replaced on sync. Nothing is moved either way, since a rule's installed path is one the project's own rules, skills, and docs may cite.
|
|
49
52
|
|
|
50
53
|
## Two sources numbering into one folder
|
|
51
54
|
|
|
52
|
-
The reservation above divides one pair, being a shipped set against the rules a project wrote for itself. A third source
|
|
55
|
+
The reservation above divides one pair, being a shipped set against the rules a project wrote for itself. A third source, a rule set held back from targets and installed only where it was authored, needs its own division too. This repository is the one place all three sources exist at once, and it divides the third pair two ways at once rather than one: `canon/` and `internal/` separate a shipped rule from an internal one by location, the same way `project/` separates a project-authored one, and within each subdirectory the numbering still divides by source as well, one source taking the top of the band and the other the gaps between the tens.
|
|
56
|
+
|
|
57
|
+
The numbering half stays because location alone does not carry to a reader who only sees the number, such as one comparing `core/070-planning.md` against a citation written before the rules moved into `canon/`.
|
|
53
58
|
|
|
54
|
-
The collision this prevents is silent. Two rules that resolve to the same `<n>-<slug>` path leave one file in the installed folder, and neither the install nor the session that reads it reports which source lost. Nothing checks a division outside the reserved bands, so it holds only while both sources follow it.
|
|
59
|
+
The collision this prevents is silent. Two rules that resolve to the same `<n>-<slug>` path leave one file in the installed folder, and neither the install nor the session that reads it reports which source lost. Nothing checks a division outside the reserved bands, so it holds only while both sources follow it. Separating `canon/` from `internal/` by folder removes the filename-collision case specifically, since the two no longer install to the same directory, but the number still carries the source signal for a reader who has only the number in view.
|
|
55
60
|
|
|
56
61
|
State that division where the rule sources are described, not inside the rules it divides. A rule states its own topic, and a numbering convention spanning two sources belongs to whatever documents the pair.
|
|
57
62
|
|
|
@@ -57,5 +57,5 @@ marker="$marker_dir/$key"
|
|
|
57
57
|
mkdir -p "$marker_dir"
|
|
58
58
|
: >"$marker"
|
|
59
59
|
|
|
60
|
-
msg='Temporary file write outside the project scratch folder. Write temp files to .canon/tmp/<slug>/ in the project root, or .claude/.tmp/<slug>/ where the project carries no .canon/ root, not system temp. See the Scratch rule
|
|
60
|
+
msg='Temporary file write outside the project scratch folder. Write temp files to .canon/tmp/<slug>/ in the project root, or .claude/.tmp/<slug>/ where the project carries no .canon/ root, not system temp. See the Scratch rule (055-scratch, under core/ in your installed governance rules).'
|
|
61
61
|
jq -nc --arg msg "$msg" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$msg}}'
|
|
File without changes
|