@erclx/canon 5.63.0 → 5.64.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 (40) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/references/adopted.md +1 -1
  4. package/claude/skills/context-fold/REQUIREMENT.md +2 -3
  5. package/claude/skills/context-fold/SKILL.md +1 -11
  6. package/claude/skills/context-fold/references/completion-report.md +8 -9
  7. package/claude/skills/create-skill/references/skill-practice.md +16 -1
  8. package/claude/skills/design-taste/references/adopted.md +17 -97
  9. package/claude/skills/design-taste/references/tells.md +2 -2
  10. package/claude/skills/document-health/REQUIREMENT.md +1 -1
  11. package/claude/skills/document-health/SKILL.md +3 -14
  12. package/claude/skills/document-health/references/axes.md +14 -16
  13. package/docs/agents/audits.md +2 -6
  14. package/docs/agents/context-audit.md +1 -1
  15. package/governance/ledger/skills/api-design/SKILL.md +62 -0
  16. package/governance/ledger/skills/code-craft/SKILL.md +53 -0
  17. package/governance/ledger/skills/deprecation-migration/SKILL.md +68 -0
  18. package/governance/ledger/skills/design-taste/SKILL.md +64 -0
  19. package/governance/ledger/skills/design-taste/craft.md +66 -0
  20. package/governance/ledger/skills/design-taste/kinds.md +58 -0
  21. package/governance/ledger/skills/design-taste/systems.md +42 -0
  22. package/governance/ledger/skills/design-taste/tells.md +103 -0
  23. package/package.json +1 -1
  24. package/src/audits/catalog.ts +0 -52
  25. package/src/claude/skills-audit.ts +117 -41
  26. package/src/cli/retired.ts +8 -0
  27. package/src/commands/claude.ts +11 -2
  28. package/src/commands/context.ts +0 -425
  29. package/src/context/citations.ts +9 -3
  30. package/src/gate/measures.ts +1 -1
  31. package/src/git/files.ts +8 -3
  32. package/src/markdown/gated.ts +1 -1
  33. package/src/markdown/scope.ts +0 -1
  34. package/claude/skills/context-fold/references/classify.md +0 -87
  35. package/src/context/classify/extract.ts +0 -443
  36. package/src/context/classify/ollama.ts +0 -172
  37. package/src/context/classify/patterns.ts +0 -75
  38. package/src/context/classify/prompts.ts +0 -74
  39. package/src/context/classify/run.ts +0 -348
  40. package/src/context/classify/settings.ts +0 -196
@@ -1,74 +0,0 @@
1
- /**
2
- * System prompts and user-message builders for the model layer, ported
3
- * verbatim as data from the groundwork spike at
4
- * `.canon/groundwork/93-canonical-doc-drift/scripts/prompts/v3.md` (diff
5
- * mode) and `sweep-v1.md` (sweep mode). The diff prompt is verbatim. The sweep
6
- * prompt was tuned past `sweep-v1.md` with one decision check, measured
7
- * against the groundwork's labelled set in
8
- * `canon/context/context-model.md`.
9
- *
10
- * Each prompt already states its own rules per canonical doc type inside one
11
- * string, which is the shape the spike measured, so there is one prompt per
12
- * mode rather than one per doc type.
13
- */
14
-
15
- export const DIFF_SYSTEM_PROMPT = `You review one hunk just added to a project's canonical documentation. The docs state the project AS IT STANDS NOW. Judge only the ADDED text.
16
-
17
- File types and what they hold:
18
- - context/<domain>.md: one domain's structure, decisions (choice + the alternative that lost), gotchas. No history of how the domain got here, no change numbers, branch names, or dates attached to a change.
19
- - DESIGN.md: tokens and visual rules, current state only.
20
-
21
- Verdicts (pick exactly one):
22
- - KEEP: states current design, a rule, copy, a decision with its rejected alternative, or a live gotcha, on the right surface.
23
- - REPLACE: restates a fact or figure the existing section already carries ("moved again", "now", "the count moved", a newer number appended after an older one). The old statement should be rewritten in place, not appended to.
24
- - HISTORY: narrates how things got here: which branch/PR changed what, "closed on", "did not survive", review-pass stories, pick-by-pick rounds. The current state is what belongs; the trail goes to the PR.
25
- - MOVE: correct content on the wrong surface (a rule written into a context entry, or a measured result written into the requirements record).
26
-
27
- Return only JSON: {"verdict": "...", "quote": "<the shortest added phrase that decided it>", "reason": "<one sentence>"}
28
-
29
- Decision checks, in order:
30
- 1. If the hunk REMOVED text and the added text is the same statement rewritten (a table row, a figure, a sentence updated in place), that is the correct way to update: KEEP, unless the new wording itself narrates history.
31
- 2. A trailing "Measured at <sha> on <date>" or a branch name does not make a sentence current. If the section already carries an earlier figure or statement on the same subject and the hunk removed nothing, the answer is REPLACE.
32
- 3. Judge only what the added text says. Narration elsewhere in the section is not evidence about this hunk.
33
- `
34
-
35
- export const SWEEP_SYSTEM_PROMPT = `You review one whole section of a project's canonical documentation, as it stands today. The docs must state the project AS IT IS NOW, once, on the right surface.
36
-
37
- File types and what they hold:
38
- - context/<domain>.md: one domain's structure, decisions (choice + the alternative that lost), gotchas. No history of how the domain got here, no change numbers, branch names, or dates attached to a change.
39
- - DESIGN.md: tokens and visual rules, current state only.
40
- - REQUIREMENTS.md: problem, goals, non-goals, scope, constraints. Never measured results.
41
-
42
- Verdicts (pick exactly one):
43
- - KEEP: the section states the current design, rules, copy, or decisions once, on the right surface. A single commit anchor on a figure is fine.
44
- - REWRITE: the section carries its own history: a figure followed by a later corrected figure, "superseded", "now", "moved", "no longer", "reverses the earlier reading", branch or PR narration, pick-by-pick rounds, a heading that narrates an event. The subject belongs here, but it must be restated as the current state.
45
- - MOVE: the section, or most of it, belongs on another surface (results in requirements).
46
-
47
- Decision checks, in order:
48
- 1. Judge what the text says, not how long it is. A long section that states current design once is KEEP.
49
- 2. A trailing "Measured at <sha> on <date>" does not make a paragraph current if another paragraph in the section states an older value on the same subject.
50
- 3. Short rationale for a current rule ("the form checks length itself because...") is KEEP. A story of the rounds that produced the rule is REWRITE.
51
- 4. Rationale is KEEP even when it is told in the past tense. A reason naming why an alternative was left out, what an earlier approach got wrong, what a measurement caught, or what a past run cost is the evidence for the current rule, so a sentence such as "the first form timed out" or "two runs took 1281s and 1924s" or "reading figures from real data caught three wrong claims" supports a rule that stands and stays KEEP. Past tense alone is never the test.
52
- 5. REWRITE needs the section to restate its own subject as a sequence: a superseded figure kept beside its replacement, "now" or "no longer" marking a change, branch or PR narration, or rounds of candidates. A maintenance step or an operator decision stated as a rule is current, not history.
53
-
54
- Return only JSON: {"verdict": "...", "quotes": ["<up to three short phrases copied from the section that decided it>"], "reason": "<one sentence>"}
55
- `
56
-
57
- /** Ported from `classify.py`'s `_user_message`, the unmarked diff-mode branch. */
58
- export function diffUserMessage(opts: {
59
- readonly file: string
60
- readonly removed: string
61
- readonly added: string
62
- readonly sectionAfter: string
63
- }): string {
64
- const removed = opts.removed === '' ? '(nothing)' : opts.removed
65
- return `FILE: ${opts.file}\n\nREMOVED IN THIS HUNK:\n${removed}\n\nADDED IN THIS HUNK:\n${opts.added}\n\nTHE SECTION AFTER THE CHANGE (for context):\n${opts.sectionAfter}`
66
- }
67
-
68
- /** Ported from `classify.py`'s `_user_message`, the sweep-mode branch. */
69
- export function sweepUserMessage(opts: {
70
- readonly file: string
71
- readonly body: string
72
- }): string {
73
- return `FILE: ${opts.file}\n\nSECTION:\n${opts.body}`
74
- }
@@ -1,348 +0,0 @@
1
- import {
2
- type CanonicalDocType,
3
- extractDiffChunks,
4
- extractSweepSections,
5
- type ExtractRefusal,
6
- } from '@/context/classify/extract'
7
- import {
8
- chat as ollamaChat,
9
- type ChatOutcome,
10
- DEFAULT_OLLAMA_BASE_URL,
11
- probeOllama,
12
- } from '@/context/classify/ollama'
13
- import {
14
- diffPatternVerdict,
15
- type DiffVerdict,
16
- type PatternVerdict,
17
- sweepPatternVerdict,
18
- type SweepVerdict,
19
- } from '@/context/classify/patterns'
20
- import {
21
- DIFF_SYSTEM_PROMPT,
22
- diffUserMessage,
23
- SWEEP_SYSTEM_PROMPT,
24
- sweepUserMessage,
25
- } from '@/context/classify/prompts'
26
- import {
27
- type ClassifierBackend,
28
- type ClassifierFlags,
29
- type ClassifierResolution,
30
- resolveClassifier,
31
- type SettingSource,
32
- } from '@/context/classify/settings'
33
-
34
- const DIFF_VERDICTS: readonly DiffVerdict[] = [
35
- 'KEEP',
36
- 'REPLACE',
37
- 'HISTORY',
38
- 'MOVE',
39
- ]
40
- const SWEEP_VERDICTS: readonly SweepVerdict[] = ['KEEP', 'REWRITE', 'MOVE']
41
-
42
- function isDiffVerdict(value: string): value is DiffVerdict {
43
- return (DIFF_VERDICTS as readonly string[]).includes(value)
44
- }
45
-
46
- function isSweepVerdict(value: string): value is SweepVerdict {
47
- return (SWEEP_VERDICTS as readonly string[]).includes(value)
48
- }
49
-
50
- /**
51
- * A pattern verdict never carries a quote on KEEP, since nothing decided
52
- * against the text. The finding's `regex` field is a `LayerVerdict` rather
53
- * than a `PatternVerdict`, so it prints the same as a model layer verdict
54
- * without every reader having to branch on an absent field.
55
- */
56
- function withQuote<V extends string>(
57
- verdict: PatternVerdict<V>,
58
- ): LayerVerdict<V> {
59
- return {
60
- verdict: verdict.verdict,
61
- quote: verdict.quote ?? '',
62
- reason: verdict.reason,
63
- }
64
- }
65
-
66
- export interface LayerVerdict<V extends string> {
67
- readonly verdict: V
68
- readonly quote: string
69
- readonly reason: string
70
- }
71
-
72
- /**
73
- * Why the model layer did not decide a run, named alongside `'ran'` so a
74
- * caller reading the record never has to infer the reason from an absent
75
- * field. `'off'` and `'skipped-no-model'` come from settings resolution
76
- * alone; `'skipped-unreachable'` is the one state a probe call decides.
77
- */
78
- export type ModelLayerState =
79
- | 'off'
80
- | 'ran'
81
- | 'skipped-no-model'
82
- | 'skipped-unreachable'
83
-
84
- export interface DiffFinding {
85
- readonly file: string
86
- readonly docType: CanonicalDocType
87
- readonly regex: LayerVerdict<DiffVerdict>
88
- readonly model?: LayerVerdict<DiffVerdict>
89
- /** Set when the model ran but its reply carried no verdict this could read. */
90
- readonly modelUnparsed?: boolean
91
- readonly verdict: DiffVerdict
92
- readonly decidedBy: 'regex' | 'model'
93
- }
94
-
95
- export interface SweepFinding {
96
- readonly file: string
97
- readonly docType: CanonicalDocType
98
- readonly heading: string
99
- readonly regex: LayerVerdict<SweepVerdict>
100
- readonly model?: LayerVerdict<SweepVerdict>
101
- readonly modelUnparsed?: boolean
102
- readonly verdict: SweepVerdict
103
- readonly decidedBy: 'regex' | 'model'
104
- }
105
-
106
- interface ClassifyRecordBase {
107
- readonly backend: ClassifierBackend | undefined
108
- readonly model: string | undefined
109
- readonly modelLayer: ModelLayerState
110
- readonly settingsSource: SettingSource
111
- }
112
-
113
- export interface DiffRecord extends ClassifyRecordBase {
114
- readonly mode: 'diff'
115
- readonly findings: readonly DiffFinding[]
116
- }
117
-
118
- export interface SweepRecord extends ClassifyRecordBase {
119
- readonly mode: 'sweep'
120
- readonly findings: readonly SweepFinding[]
121
- }
122
-
123
- export type ClassifyRefusal = ExtractRefusal
124
-
125
- export type ClassifyOutcome<Record> =
126
- | { readonly kind: 'ok'; readonly record: Record }
127
- | {
128
- readonly kind: 'refused'
129
- readonly reason: ClassifyRefusal
130
- readonly message: string
131
- }
132
-
133
- /**
134
- * The network boundary, injected so a test drives the merge logic below
135
- * without a live Ollama. `src/version/skew.ts` takes the same shape for the
136
- * npm registry lookup it wraps.
137
- */
138
- export interface ModelClient {
139
- readonly probe: (baseUrl: string, timeoutMs?: number) => Promise<boolean>
140
- readonly chat: (opts: {
141
- readonly baseUrl: string
142
- readonly model: string
143
- readonly system: string
144
- readonly user: string
145
- readonly timeoutMs?: number
146
- }) => Promise<ChatOutcome>
147
- }
148
-
149
- const DEFAULT_CLIENT: ModelClient = { probe: probeOllama, chat: ollamaChat }
150
-
151
- export interface ClassifyOptions {
152
- readonly flags?: ClassifierFlags
153
- readonly docTypes?: readonly CanonicalDocType[]
154
- readonly baseUrl?: string
155
- readonly client?: ModelClient
156
- }
157
-
158
- interface ModelLayerPlan {
159
- readonly modelLayer: ModelLayerState
160
- readonly backend?: ClassifierBackend
161
- readonly model?: string
162
- }
163
-
164
- /**
165
- * Decides whether the model layer runs, probing reachability before any real
166
- * call. A configured-but-unreachable backend is a plan the caller reports and
167
- * falls back from, never a refusal: the groundwork decision reads "warn and
168
- * continue," and a refused run here would fail `context-fold` for a reason that
169
- * has nothing to do with the branch it is checking.
170
- */
171
- async function planModelLayer(
172
- resolution: ClassifierResolution,
173
- client: ModelClient,
174
- baseUrl: string,
175
- ): Promise<ModelLayerPlan> {
176
- if (resolution.kind === 'off') return { modelLayer: 'off' }
177
- if (resolution.kind === 'no-model') {
178
- return { modelLayer: 'skipped-no-model', backend: resolution.backend }
179
- }
180
-
181
- const reachable = await client.probe(baseUrl)
182
- if (!reachable) {
183
- return {
184
- modelLayer: 'skipped-unreachable',
185
- backend: resolution.backend,
186
- model: resolution.model,
187
- }
188
- }
189
-
190
- return {
191
- modelLayer: 'ran',
192
- backend: resolution.backend,
193
- model: resolution.model,
194
- }
195
- }
196
-
197
- /**
198
- * One chunk or section, one model call, matching the measured constraint that
199
- * batching multiple items into one prompt returned KEEP for everything. The
200
- * caller (`classifyDiff`/`classifySweep`) awaits this once per item rather
201
- * than issuing every call in parallel, since the items already share one
202
- * backend and the groundwork measurements are all serial per-call numbers.
203
- */
204
- async function modelVerdictFor<V extends string>(opts: {
205
- readonly plan: ModelLayerPlan
206
- readonly client: ModelClient
207
- readonly baseUrl: string
208
- readonly system: string
209
- readonly user: string
210
- readonly isVerdict: (value: string) => value is V
211
- }): Promise<{
212
- readonly verdict?: LayerVerdict<V>
213
- readonly unparsed: boolean
214
- }> {
215
- if (opts.plan.modelLayer !== 'ran' || !opts.plan.model) {
216
- return { unparsed: false }
217
- }
218
-
219
- const outcome = await opts.client.chat({
220
- baseUrl: opts.baseUrl,
221
- model: opts.plan.model,
222
- system: opts.system,
223
- user: opts.user,
224
- })
225
-
226
- if (outcome.kind !== 'ok' || !opts.isVerdict(outcome.parsed.verdict)) {
227
- return { unparsed: true }
228
- }
229
-
230
- return {
231
- verdict: {
232
- verdict: outcome.parsed.verdict,
233
- quote: outcome.parsed.quote,
234
- reason: outcome.parsed.reason,
235
- },
236
- unparsed: false,
237
- }
238
- }
239
-
240
- /**
241
- * Runs diff mode over one git range: the regex layer over every extracted
242
- * chunk, and the model layer over the same chunks when configured and
243
- * reachable.
244
- *
245
- * A finding's `verdict` takes the model's reading when the model ran and
246
- * parsed, and the regex reading otherwise, since the groundwork measurements
247
- * show the model catching what the regex layer structurally cannot
248
- * (REPLACE and MOVE). Both readings stay on the finding
249
- * regardless of which one decided, so a caller can see where they disagreed.
250
- */
251
- export async function classifyDiff(
252
- root: string,
253
- ref: string | undefined,
254
- opts: ClassifyOptions = {},
255
- ): Promise<ClassifyOutcome<DiffRecord>> {
256
- const extraction = await extractDiffChunks(root, ref, opts.docTypes)
257
- if (extraction.kind === 'refused') return extraction
258
-
259
- const resolution = resolveClassifier(root, opts.flags ?? {})
260
- const client = opts.client ?? DEFAULT_CLIENT
261
- const baseUrl = opts.baseUrl ?? DEFAULT_OLLAMA_BASE_URL
262
- const plan = await planModelLayer(resolution, client, baseUrl)
263
-
264
- const findings: DiffFinding[] = []
265
- for (const chunk of extraction.chunks) {
266
- const regex = withQuote(diffPatternVerdict(chunk.added))
267
- const model = await modelVerdictFor({
268
- plan,
269
- client,
270
- baseUrl,
271
- system: DIFF_SYSTEM_PROMPT,
272
- user: diffUserMessage(chunk),
273
- isVerdict: isDiffVerdict,
274
- })
275
-
276
- findings.push({
277
- file: chunk.file,
278
- docType: chunk.docType,
279
- regex,
280
- model: model.verdict,
281
- modelUnparsed: model.unparsed || undefined,
282
- verdict: model.verdict?.verdict ?? regex.verdict,
283
- decidedBy: model.verdict ? 'model' : 'regex',
284
- })
285
- }
286
-
287
- return {
288
- kind: 'ok',
289
- record: {
290
- mode: 'diff',
291
- backend: plan.backend,
292
- model: plan.model,
293
- modelLayer: plan.modelLayer,
294
- settingsSource: resolution.source,
295
- findings,
296
- },
297
- }
298
- }
299
-
300
- /** Runs sweep mode over every extracted section, mirroring `classifyDiff`. */
301
- export async function classifySweep(
302
- root: string,
303
- opts: ClassifyOptions = {},
304
- ): Promise<ClassifyOutcome<SweepRecord>> {
305
- const extraction = await extractSweepSections(root, opts.docTypes)
306
- if (extraction.kind === 'refused') return extraction
307
-
308
- const resolution = resolveClassifier(root, opts.flags ?? {})
309
- const client = opts.client ?? DEFAULT_CLIENT
310
- const baseUrl = opts.baseUrl ?? DEFAULT_OLLAMA_BASE_URL
311
- const plan = await planModelLayer(resolution, client, baseUrl)
312
-
313
- const findings: SweepFinding[] = []
314
- for (const section of extraction.sections) {
315
- const regex = withQuote(sweepPatternVerdict(section.body))
316
- const model = await modelVerdictFor({
317
- plan,
318
- client,
319
- baseUrl,
320
- system: SWEEP_SYSTEM_PROMPT,
321
- user: sweepUserMessage(section),
322
- isVerdict: isSweepVerdict,
323
- })
324
-
325
- findings.push({
326
- file: section.file,
327
- docType: section.docType,
328
- heading: section.heading,
329
- regex,
330
- model: model.verdict,
331
- modelUnparsed: model.unparsed || undefined,
332
- verdict: model.verdict?.verdict ?? regex.verdict,
333
- decidedBy: model.verdict ? 'model' : 'regex',
334
- })
335
- }
336
-
337
- return {
338
- kind: 'ok',
339
- record: {
340
- mode: 'sweep',
341
- backend: plan.backend,
342
- model: plan.model,
343
- modelLayer: plan.modelLayer,
344
- settingsSource: resolution.source,
345
- findings,
346
- },
347
- }
348
- }
@@ -1,196 +0,0 @@
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
- }