@erclx/canon 4.81.0 → 4.82.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/docs/agents/commands.md +7 -3
- package/docs/agents/context-audit.md +1 -1
- package/docs/agents/context-classify.md +99 -0
- package/docs/agents/index.md +1 -0
- package/docs/target-projects.md +15 -0
- package/docs/workflow/ai-workflow.md +3 -2
- package/governance/rules/claude/545-decisions.md +12 -0
- package/package.json +1 -1
- package/src/claude/seeds.ts +1 -0
- package/src/commands/claude.ts +26 -5
- package/src/commands/context.ts +425 -0
- package/src/context/classify/extract.ts +450 -0
- package/src/context/classify/ollama.ts +172 -0
- package/src/context/classify/patterns.ts +114 -0
- package/src/context/classify/prompts.ts +73 -0
- package/src/context/classify/run.ts +348 -0
- package/src/context/classify/settings.ts +196 -0
- package/src/context/folders.ts +1 -0
- package/src/surface-root.ts +1 -0
- package/standards/decisions.md +100 -0
- package/standards/index.md +1 -0
- package/tooling/claude/reference.md +1 -0
- package/tooling/claude/seeds/CLAUDE.md +1 -0
- package/tooling/claude/seeds/canon/decisions/index.md +8 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
statSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Ollama is the only backend. The groundwork decision explicitly deferred a
|
|
13
|
+
* Haiku or Claude API backend, so this union has one member rather than
|
|
14
|
+
* carrying an unused second case a switch would have to handle.
|
|
15
|
+
*/
|
|
16
|
+
export type ClassifierBackend = 'ollama'
|
|
17
|
+
|
|
18
|
+
/** Where `set` writes and `classify`/`classifier show` read the project setting. */
|
|
19
|
+
export const CLASSIFIER_CONFIG_REL = join('canon', 'config', 'classifier.toml')
|
|
20
|
+
|
|
21
|
+
export type SettingSource = 'flag' | 'env' | 'file' | 'default'
|
|
22
|
+
|
|
23
|
+
export interface ClassifierFlags {
|
|
24
|
+
readonly backend?: string
|
|
25
|
+
readonly model?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface FileSettings {
|
|
29
|
+
readonly backend?: string
|
|
30
|
+
readonly model?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readFileSettings(root: string): FileSettings {
|
|
34
|
+
const path = join(root, CLASSIFIER_CONFIG_REL)
|
|
35
|
+
if (!existsSync(path)) return {}
|
|
36
|
+
|
|
37
|
+
let parsed: Record<string, unknown>
|
|
38
|
+
try {
|
|
39
|
+
parsed = Bun.TOML.parse(readFileSync(path, 'utf8')) as Record<
|
|
40
|
+
string,
|
|
41
|
+
unknown
|
|
42
|
+
>
|
|
43
|
+
} catch {
|
|
44
|
+
return {}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const table = parsed.classifier
|
|
48
|
+
if (typeof table !== 'object' || table === null) return {}
|
|
49
|
+
const { backend, model } = table as Record<string, unknown>
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
backend: typeof backend === 'string' ? backend : undefined,
|
|
53
|
+
model: typeof model === 'string' ? model : undefined,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isBackend(value: string): value is ClassifierBackend {
|
|
58
|
+
return value === 'ollama'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolves the backend through the stated precedence: flag, then the
|
|
63
|
+
* `CANON_CLASSIFIER_BACKEND` environment variable, then the project config
|
|
64
|
+
* file, then off. `off` is a value in its own right rather than an absence,
|
|
65
|
+
* since a project's config file setting `backend = "off"` states the same
|
|
66
|
+
* fact the missing-file default does, and the source line should say which
|
|
67
|
+
* one decided it.
|
|
68
|
+
*
|
|
69
|
+
* An unrecognized value at any tier is read the same as an absent one, and
|
|
70
|
+
* falls through to the next tier rather than refusing. That keeps a typo'd
|
|
71
|
+
* environment variable from silently making the whole precedence chain
|
|
72
|
+
* unreachable underneath it.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveBackend(
|
|
75
|
+
root: string,
|
|
76
|
+
flags: ClassifierFlags,
|
|
77
|
+
): {
|
|
78
|
+
readonly value: ClassifierBackend | 'off'
|
|
79
|
+
readonly source: SettingSource
|
|
80
|
+
} {
|
|
81
|
+
if (flags.backend !== undefined) {
|
|
82
|
+
if (flags.backend === 'off') return { value: 'off', source: 'flag' }
|
|
83
|
+
if (isBackend(flags.backend))
|
|
84
|
+
return { value: flags.backend, source: 'flag' }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const envBackend = process.env.CANON_CLASSIFIER_BACKEND
|
|
88
|
+
if (envBackend !== undefined) {
|
|
89
|
+
if (envBackend === 'off') return { value: 'off', source: 'env' }
|
|
90
|
+
if (isBackend(envBackend)) return { value: envBackend, source: 'env' }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const fileBackend = readFileSettings(root).backend
|
|
94
|
+
if (fileBackend !== undefined) {
|
|
95
|
+
if (fileBackend === 'off') return { value: 'off', source: 'file' }
|
|
96
|
+
if (isBackend(fileBackend)) return { value: fileBackend, source: 'file' }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { value: 'off', source: 'default' }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolves the model name through the same precedence as the backend, kept as
|
|
104
|
+
* a separate function since a caller can name a backend without a model (the
|
|
105
|
+
* `no-model` state `resolveClassifier` reports) or a model without a backend
|
|
106
|
+
* (which resolves nothing, since there is nothing to run it against).
|
|
107
|
+
*/
|
|
108
|
+
export function resolveModel(
|
|
109
|
+
root: string,
|
|
110
|
+
flags: ClassifierFlags,
|
|
111
|
+
): { readonly value: string; readonly source: SettingSource } | undefined {
|
|
112
|
+
if (flags.model !== undefined && flags.model !== '') {
|
|
113
|
+
return { value: flags.model, source: 'flag' }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const envModel = process.env.CANON_CLASSIFIER_MODEL
|
|
117
|
+
if (envModel !== undefined && envModel !== '') {
|
|
118
|
+
return { value: envModel, source: 'env' }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const fileModel = readFileSettings(root).model
|
|
122
|
+
if (fileModel !== undefined && fileModel !== '') {
|
|
123
|
+
return { value: fileModel, source: 'file' }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type ClassifierResolution =
|
|
130
|
+
| { readonly kind: 'off'; readonly source: SettingSource }
|
|
131
|
+
| {
|
|
132
|
+
readonly kind: 'no-model'
|
|
133
|
+
readonly backend: ClassifierBackend
|
|
134
|
+
readonly source: SettingSource
|
|
135
|
+
}
|
|
136
|
+
| {
|
|
137
|
+
readonly kind: 'configured'
|
|
138
|
+
readonly backend: ClassifierBackend
|
|
139
|
+
readonly model: string
|
|
140
|
+
/** The source that decided the backend, which decided the model layer runs at all. */
|
|
141
|
+
readonly source: SettingSource
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Combines the two precedence reads into the one answer `classify` and
|
|
146
|
+
* `classifier show` both need: whether the model layer runs, and why.
|
|
147
|
+
*
|
|
148
|
+
* No default model name exists on purpose. The groundwork decision reads: a
|
|
149
|
+
* model name on one machine means nothing on another, so `show` reports the
|
|
150
|
+
* gap and `classify` warns and falls back to the regex layer rather than
|
|
151
|
+
* guessing a name that may not be pulled.
|
|
152
|
+
*/
|
|
153
|
+
export function resolveClassifier(
|
|
154
|
+
root: string,
|
|
155
|
+
flags: ClassifierFlags = {},
|
|
156
|
+
): ClassifierResolution {
|
|
157
|
+
const backend = resolveBackend(root, flags)
|
|
158
|
+
if (backend.value === 'off') return { kind: 'off', source: backend.source }
|
|
159
|
+
|
|
160
|
+
const model = resolveModel(root, flags)
|
|
161
|
+
if (model === undefined) {
|
|
162
|
+
return { kind: 'no-model', backend: backend.value, source: backend.source }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
kind: 'configured',
|
|
167
|
+
backend: backend.value,
|
|
168
|
+
model: model.value,
|
|
169
|
+
source: backend.source,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Writes the project's classifier setting, creating `canon/config/` when a
|
|
175
|
+
* project does not carry it yet. `pr-labels.toml` is the one precedent for a
|
|
176
|
+
* `canon/config/` file and it is hand-authored and read-only to the CLI, so
|
|
177
|
+
* this is the first write path into that folder and the first TOML file this
|
|
178
|
+
* CLI generates rather than parses.
|
|
179
|
+
*
|
|
180
|
+
* Preserves an existing file's mode per the operator-file convention, so a
|
|
181
|
+
* destination an operator locked down keeps its permissions across a rewrite.
|
|
182
|
+
*/
|
|
183
|
+
export function writeClassifierConfig(
|
|
184
|
+
root: string,
|
|
185
|
+
backend: ClassifierBackend | 'off',
|
|
186
|
+
model?: string,
|
|
187
|
+
): void {
|
|
188
|
+
const path = join(root, CLASSIFIER_CONFIG_REL)
|
|
189
|
+
const mode = existsSync(path) ? statSync(path).mode : undefined
|
|
190
|
+
|
|
191
|
+
const modelLine = model ? `model = "${model}"\n` : ''
|
|
192
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
193
|
+
writeFileSync(path, `[classifier]\nbackend = "${backend}"\n${modelLine}`)
|
|
194
|
+
|
|
195
|
+
if (mode !== undefined) chmodSync(path, mode)
|
|
196
|
+
}
|
package/src/context/folders.ts
CHANGED
package/src/surface-root.ts
CHANGED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Decisions reference
|
|
3
|
+
description: Folder layout, ordinal filename, frontmatter, record sections, and the append-only lifecycle for canon/decisions/
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Decisions reference
|
|
7
|
+
|
|
8
|
+
Applies to `canon/decisions/`. Holds the history a canonical doc used to carry itself: pick rounds, superseded figures, and a decision's rejected alternatives, in a tracked folder nothing loads eagerly. A canonical doc points at a record from its own history section rather than restating it.
|
|
9
|
+
|
|
10
|
+
## Scope
|
|
11
|
+
|
|
12
|
+
Governs `canon/decisions/`: folder layout, the ordinal filename, frontmatter, record sections, and the append-only lifecycle.
|
|
13
|
+
|
|
14
|
+
Does not govern:
|
|
15
|
+
|
|
16
|
+
- Which canonical doc points here and when, and what stays behind in that doc's own body: `architecture.md`, `context.md`, `wireframes.md`, `design.md`, `requirements.md`, once each states its own retirement rule
|
|
17
|
+
- Voice, rhythm, and sentence construction: the `write-human` skill
|
|
18
|
+
- Headings, punctuation, word choice, and file references: `markdown.md`
|
|
19
|
+
|
|
20
|
+
## What a working record looks like
|
|
21
|
+
|
|
22
|
+
A record works when a reader who has never opened the project can follow it from the file alone:
|
|
23
|
+
|
|
24
|
+
- What was decided, stated once, without needing the canonical doc that points here
|
|
25
|
+
- What else was considered, and why each alternative lost
|
|
26
|
+
- Which claim rests on a measurement, and what commit that measurement was read against
|
|
27
|
+
|
|
28
|
+
A record failing these is non-conforming even when it satisfies every shape rule below.
|
|
29
|
+
|
|
30
|
+
## Folder name
|
|
31
|
+
|
|
32
|
+
- `canon/decisions/`, resolved the way every tracked surface is: at `canon/decisions/` in a project that has moved, at `.claude/decisions/` in one that has not.
|
|
33
|
+
- Never add `canon/decisions/index.md` to a `CLAUDE.md` `@` import. A log that loads eagerly rebuilds the bloat it exists to absorb. A canonical doc's own pointer is how a reader reaches a record, one file at a time.
|
|
34
|
+
|
|
35
|
+
## Record filename
|
|
36
|
+
|
|
37
|
+
- Name each record `<nn>-<slug>.md`, a two-digit zero-padded ordinal followed by a kebab-case slug, the same shape a groundwork track's folder takes.
|
|
38
|
+
- The ordinal is the order the record was written in, which is what lets a listing sort by when a decision landed rather than alphabetically by subject.
|
|
39
|
+
- Never renumber an existing record. A later reader cites it by that name, and a record whose number moved is a record a stale citation can no longer find.
|
|
40
|
+
|
|
41
|
+
## Frontmatter
|
|
42
|
+
|
|
43
|
+
- `title` (required): the decision in sentence case
|
|
44
|
+
- `description` (required): one line naming what was decided
|
|
45
|
+
|
|
46
|
+
## Sections
|
|
47
|
+
|
|
48
|
+
Use `## Context`, `## Decision`, `## Alternatives`, and `## Measurements`.
|
|
49
|
+
|
|
50
|
+
- `## Context`: the problem as it stood, stated so a reader needs nothing else open. Restate a fact rather than pointing at where it was found.
|
|
51
|
+
- `## Decision`: what was chosen, and why, in enough detail that a reader can tell it apart from an alternative that sounds similar.
|
|
52
|
+
- `## Alternatives`: each one considered and dropped, with the reason it lost. An alternative with no stated reason reads as a claim nobody checked.
|
|
53
|
+
- `## Measurements`: skip when the decision cites no number. When it does, state the number and close with the commit it was read against, per Verification anchors below.
|
|
54
|
+
|
|
55
|
+
## Verification anchors
|
|
56
|
+
|
|
57
|
+
A record's reasoning stays correct while the numbers it cites move. The anchor records what a measured claim was read against, so a reader can tell a number that was checked and held from one nobody has looked at since.
|
|
58
|
+
|
|
59
|
+
- Close a `## Measurements` section with a trailing sentence naming the short commit SHA and the ISO date that number was read: `Measured at <short-sha> on <YYYY-MM-DD>.`
|
|
60
|
+
- Anchor on the number alone. A record citing none carries no `## Measurements` section at all.
|
|
61
|
+
- Read an absent section as unchecked rather than as current. A record with no measurements has nothing due a re-read.
|
|
62
|
+
|
|
63
|
+
## Lifecycle
|
|
64
|
+
|
|
65
|
+
- Append-only. A written record is never edited to reflect a later reversal.
|
|
66
|
+
- Write a new record when a later decision supersedes an earlier one, naming the record it supersedes. The old record stays as it was written, since it is history rather than a live statement of the current shape.
|
|
67
|
+
- Never auto-loaded. A canonical doc's own history section links to a record by relative path, and a reader reaches it by following that link, not by the folder loading with the session.
|
|
68
|
+
|
|
69
|
+
## Citation
|
|
70
|
+
|
|
71
|
+
- Never cite `.canon/`. A record restates what it needs, since a clone without the gitignored records folder resolves nothing there.
|
|
72
|
+
- Cite a same-repository pull request or commit the way `publish.md` fixes for any tracked document.
|
|
73
|
+
|
|
74
|
+
## Template
|
|
75
|
+
|
|
76
|
+
```markdown
|
|
77
|
+
---
|
|
78
|
+
title: <Decision, in sentence case>
|
|
79
|
+
description: <one line naming what was decided>
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
# <Decision title>
|
|
83
|
+
|
|
84
|
+
## Context
|
|
85
|
+
|
|
86
|
+
<The problem as it stood, self-contained.>
|
|
87
|
+
|
|
88
|
+
## Decision
|
|
89
|
+
|
|
90
|
+
<What was chosen, and why.>
|
|
91
|
+
|
|
92
|
+
## Alternatives
|
|
93
|
+
|
|
94
|
+
- **<Alternative>.** <Why it lost.>
|
|
95
|
+
- **<Alternative>.** <Why it lost.>
|
|
96
|
+
|
|
97
|
+
## Measurements
|
|
98
|
+
|
|
99
|
+
<The number the decision rests on.> Measured at <short-sha> on <YYYY-MM-DD>.
|
|
100
|
+
```
|
package/standards/index.md
CHANGED
|
@@ -11,6 +11,7 @@ Reference docs for consistent authoring across the toolkit and target projects.
|
|
|
11
11
|
- [Branch reference](branch.md): Branch naming format and type conventions
|
|
12
12
|
- [Commit reference](commit.md): Commit message format and type conventions
|
|
13
13
|
- [Context entry reference](context.md): Shape and content rules for canon/context/<domain>.md entries
|
|
14
|
+
- [Decisions reference](decisions.md): Folder layout, ordinal filename, frontmatter, record sections, and the append-only lifecycle for canon/decisions/
|
|
14
15
|
- [Design reference](design.md): Shape and content rules for canon/DESIGN.md
|
|
15
16
|
- [Diagram reference](diagrams.md): Shape and content rules for .canon/diagrams/<kind>.md files
|
|
16
17
|
- [Docs reference](docs.md): Reader and jurisdiction, frontmatter, page structure, what a page links out to, the diagram permission, and when a category earns a subfolder
|
|
@@ -14,6 +14,7 @@ canon/
|
|
|
14
14
|
├── ARCHITECTURE.md ← seeded. Technical design decisions and open questions
|
|
15
15
|
├── DESIGN.md ← seeded. Visual intent and the decisions behind it
|
|
16
16
|
├── context/ ← seeded. Per-domain narrative. `index.md` is the discovery anchor.
|
|
17
|
+
├── decisions/ ← seeded. Decision history a canonical doc points at, never loaded eagerly. `<nn>-<slug>.md` records.
|
|
17
18
|
└── wireframes/ ← seeded. Per-surface ASCII layouts. `index.md` is the discovery anchor; `<surface>.md` files hold the sketches and behavior bullets.
|
|
18
19
|
|
|
19
20
|
.claude/
|
|
@@ -22,3 +22,4 @@
|
|
|
22
22
|
- `canon/DESIGN.md`: design tokens and the visual system
|
|
23
23
|
- `canon/context/`: per-domain narrative (how a domain is structured, decisions, gotchas), indexed via `canon/context/index.md`
|
|
24
24
|
- `canon/wireframes/`: per-surface ASCII layouts loaded on demand, indexed via `canon/wireframes/index.md`
|
|
25
|
+
- `canon/decisions/`: decision history a project doc points at, never loaded eagerly
|