@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.
@@ -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
+ }
@@ -19,6 +19,7 @@ import { SURFACE_ROOTS } from '@/surface-root'
19
19
  */
20
20
  export const DEFAULT_FOLDERS: readonly string[] = [
21
21
  'context',
22
+ 'decisions',
22
23
  'diagrams',
23
24
  'wireframes',
24
25
  ]
@@ -43,6 +43,7 @@ export const SURFACE_ENTRIES: readonly string[] = [
43
43
  'DESIGN.md',
44
44
  'context',
45
45
  'wireframes',
46
+ 'decisions',
46
47
  'canon',
47
48
  ]
48
49
 
@@ -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
+ ```
@@ -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
@@ -0,0 +1,8 @@
1
+ ---
2
+ title: Decisions
3
+ subtitle: Decision history a project doc points at, never loaded eagerly
4
+ ---
5
+
6
+ # Decisions
7
+
8
+ Decision history a project doc points at, never loaded eagerly