@erclx/canon 4.80.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/claude/skills/design-extract/SKILL.md +5 -1
- package/claude/skills/sketch-design/REQUIREMENT.md +38 -0
- package/claude/skills/sketch-design/SKILL.md +92 -0
- package/claude/skills/teach-workspace/SKILL.md +23 -1
- package/claude/skills/teach-workspace/references/lesson-craft.md +11 -0
- package/docs/agents/commands.md +11 -5
- package/docs/agents/context-audit.md +1 -1
- package/docs/agents/context-classify.md +99 -0
- package/docs/agents/design-board.md +13 -11
- package/docs/agents/index.md +2 -1
- package/docs/agents/teach.md +16 -0
- package/docs/target-projects.md +15 -0
- package/docs/workflow/ai-workflow.md +4 -2
- package/docs/workflow/visual-design-workflow.md +1 -0
- package/governance/rules/claude/545-decisions.md +12 -0
- package/package.json +1 -1
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/seeds.ts +1 -0
- package/src/commands/claude.ts +26 -5
- package/src/commands/context.ts +425 -0
- package/src/commands/design.ts +24 -8
- package/src/commands/teach.ts +118 -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/design/board.ts +130 -47
- package/src/project-root.ts +16 -0
- package/src/surface-root.ts +1 -0
- package/src/teach/render.ts +126 -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
package/src/claude/cases/misc.ts
CHANGED
|
@@ -17,6 +17,11 @@ export const MISC_CASES: readonly SkillCase[] = [
|
|
|
17
17
|
'This project has no logo yet. Draft one and give me a social card to go with it.',
|
|
18
18
|
expect: 'draft-identity',
|
|
19
19
|
},
|
|
20
|
+
{
|
|
21
|
+
prompt:
|
|
22
|
+
"I found two sites whose look I want us to draw from. Put them side by side and let's settle which one the design should follow before anyone writes a token.",
|
|
23
|
+
expect: 'sketch-design',
|
|
24
|
+
},
|
|
20
25
|
{
|
|
21
26
|
prompt: 'Fire up the dev server the way this project documents it.',
|
|
22
27
|
expect: 'project-commands',
|
package/src/claude/seeds.ts
CHANGED
package/src/commands/claude.ts
CHANGED
|
@@ -61,6 +61,7 @@ import { copyPreservingMode } from '@/copy'
|
|
|
61
61
|
import { execScript } from '@/exec'
|
|
62
62
|
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
63
63
|
import { recordDir } from '@/record-root'
|
|
64
|
+
import { SURFACE_ENTRIES, surfaceDir } from '@/surface-root'
|
|
64
65
|
import { isDirectory, resolveTarget } from '@/target'
|
|
65
66
|
import { injectGitignore, pruneGitignore } from '@/tooling/inject'
|
|
66
67
|
import {
|
|
@@ -148,7 +149,27 @@ const SEEDED_FILES: readonly string[] = [
|
|
|
148
149
|
'REQUIREMENTS.md',
|
|
149
150
|
'DESIGN.md',
|
|
150
151
|
]
|
|
151
|
-
const SEEDED_DIRS: readonly string[] = [
|
|
152
|
+
const SEEDED_DIRS: readonly string[] = [
|
|
153
|
+
'decisions',
|
|
154
|
+
'memory',
|
|
155
|
+
'tasks',
|
|
156
|
+
'wireframes',
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Where a `SEEDED_DIRS` entry resolves for the sync presence check.
|
|
161
|
+
*
|
|
162
|
+
* `wireframes` and `decisions` are tracked surface entries, resolved under
|
|
163
|
+
* `canon/` ahead of `.claude/`, while `memory` and `tasks` are session records
|
|
164
|
+
* resolved under `.canon/` ahead of `.claude/`. Routing every name through
|
|
165
|
+
* `recordDir` reported a migrated target's `canon/wireframes/` or
|
|
166
|
+
* `canon/decisions/` as missing, since that resolver never checks `canon/`.
|
|
167
|
+
*/
|
|
168
|
+
export function seededDirPath(resolved: string, name: string): string {
|
|
169
|
+
return SURFACE_ENTRIES.includes(name)
|
|
170
|
+
? surfaceDir(resolved, name)
|
|
171
|
+
: recordDir(resolved, name)
|
|
172
|
+
}
|
|
152
173
|
const USER_DIR = join('tooling', 'claude', 'user')
|
|
153
174
|
const STATUSLINE = 'statusline-command.sh'
|
|
154
175
|
const PLUGIN_MANIFEST = join('claude', '.claude-plugin', 'plugin.json')
|
|
@@ -580,16 +601,16 @@ async function runSync(target: string): Promise<number> {
|
|
|
580
601
|
|
|
581
602
|
// A record folder resolves at either root, so a migrated target is reported as
|
|
582
603
|
// seeded rather than sent to `canon claude init` to re-create records it
|
|
583
|
-
// already holds.
|
|
584
|
-
//
|
|
585
|
-
//
|
|
604
|
+
// already holds. `wireframes` and `decisions` are tracked surface entries
|
|
605
|
+
// instead, resolved through `surfaceDir`, since a migrated target holds them
|
|
606
|
+
// at `canon/` rather than under either record root.
|
|
586
607
|
logStep('Seeded')
|
|
587
608
|
for (const name of SEEDED_FILES) {
|
|
588
609
|
if (existsSync(join(resolved, '.claude', name))) logInfo(name)
|
|
589
610
|
else logWarn(`${name} missing. Run \`canon claude init\``)
|
|
590
611
|
}
|
|
591
612
|
for (const name of SEEDED_DIRS) {
|
|
592
|
-
if (isDirectory(
|
|
613
|
+
if (isDirectory(seededDirPath(resolved, name))) logInfo(`${name}/`)
|
|
593
614
|
else logWarn(`${name}/ missing. Run \`canon claude init\``)
|
|
594
615
|
}
|
|
595
616
|
|
package/src/commands/context.ts
CHANGED
|
@@ -25,6 +25,26 @@ import {
|
|
|
25
25
|
testableCount,
|
|
26
26
|
} from '@/context/architecture'
|
|
27
27
|
import { auditCitations, type CitationReport } from '@/context/citations'
|
|
28
|
+
import {
|
|
29
|
+
CANONICAL_DOC_TYPES,
|
|
30
|
+
type CanonicalDocType,
|
|
31
|
+
} from '@/context/classify/extract'
|
|
32
|
+
import {
|
|
33
|
+
classifyDiff,
|
|
34
|
+
classifySweep,
|
|
35
|
+
type ClassifyOutcome,
|
|
36
|
+
type ClassifyRefusal,
|
|
37
|
+
type DiffFinding,
|
|
38
|
+
type DiffRecord,
|
|
39
|
+
type SweepFinding,
|
|
40
|
+
type SweepRecord,
|
|
41
|
+
} from '@/context/classify/run'
|
|
42
|
+
import {
|
|
43
|
+
CLASSIFIER_CONFIG_REL,
|
|
44
|
+
type ClassifierBackend,
|
|
45
|
+
resolveClassifier,
|
|
46
|
+
writeClassifierConfig,
|
|
47
|
+
} from '@/context/classify/settings'
|
|
28
48
|
import {
|
|
29
49
|
type AuditedFolder,
|
|
30
50
|
DEFAULT_FOLDERS,
|
|
@@ -122,6 +142,411 @@ export function register(program: Command): void {
|
|
|
122
142
|
.action(async (path: string | undefined, opts: AuditCommandOptions) => {
|
|
123
143
|
process.exitCode = await runAudit(path, opts)
|
|
124
144
|
})
|
|
145
|
+
|
|
146
|
+
const classify = context
|
|
147
|
+
.command('classify')
|
|
148
|
+
.description(
|
|
149
|
+
'Classify canonical-doc content as keep, replace/rewrite, history, or move',
|
|
150
|
+
)
|
|
151
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
152
|
+
|
|
153
|
+
classify
|
|
154
|
+
.command('diff')
|
|
155
|
+
.description(
|
|
156
|
+
'Classify the chunks a git range changed, each with the section it landed in',
|
|
157
|
+
)
|
|
158
|
+
.argument('[path]', 'Project root, defaulting to the current directory')
|
|
159
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
160
|
+
.option('--base <ref>', 'Far side of the range, defaulting to the trunk')
|
|
161
|
+
.option(
|
|
162
|
+
'--doc-types <list>',
|
|
163
|
+
`Comma-separated canonical doc types (default: all five: ${CANONICAL_DOC_TYPES.join(', ')})`,
|
|
164
|
+
)
|
|
165
|
+
.option(
|
|
166
|
+
'--backend <name>',
|
|
167
|
+
'Override the resolved model backend for this run',
|
|
168
|
+
)
|
|
169
|
+
.option('--model <name>', 'Override the resolved model name for this run')
|
|
170
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
171
|
+
.addHelpText(
|
|
172
|
+
'after',
|
|
173
|
+
[
|
|
174
|
+
'',
|
|
175
|
+
'The regex layer always runs. The model layer runs only when a',
|
|
176
|
+
'backend resolves through `canon context classifier show`, and a',
|
|
177
|
+
'configured-but-unreachable backend warns and falls back to regex',
|
|
178
|
+
'rather than failing the run.',
|
|
179
|
+
'',
|
|
180
|
+
'Exit codes:',
|
|
181
|
+
' 0 the run completed, whatever the findings say',
|
|
182
|
+
' 1 refused: a bad range or a file this could not read',
|
|
183
|
+
'',
|
|
184
|
+
'Examples:',
|
|
185
|
+
' canon context classify diff',
|
|
186
|
+
' canon context classify diff --base origin/main --json',
|
|
187
|
+
' canon context classify diff --doc-types context,wireframes',
|
|
188
|
+
'',
|
|
189
|
+
].join('\n'),
|
|
190
|
+
)
|
|
191
|
+
.action(async (path: string | undefined, opts: ClassifyDiffOptions) => {
|
|
192
|
+
process.exitCode = await runClassifyDiff(path, opts)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
classify
|
|
196
|
+
.command('sweep')
|
|
197
|
+
.description(
|
|
198
|
+
'Classify every section of the five canonical doc types, split at H3',
|
|
199
|
+
)
|
|
200
|
+
.argument('[path]', 'Project root, defaulting to the current directory')
|
|
201
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
202
|
+
.option(
|
|
203
|
+
'--doc-types <list>',
|
|
204
|
+
`Comma-separated canonical doc types (default: all five: ${CANONICAL_DOC_TYPES.join(', ')})`,
|
|
205
|
+
)
|
|
206
|
+
.option(
|
|
207
|
+
'--backend <name>',
|
|
208
|
+
'Override the resolved model backend for this run',
|
|
209
|
+
)
|
|
210
|
+
.option('--model <name>', 'Override the resolved model name for this run')
|
|
211
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
212
|
+
.addHelpText(
|
|
213
|
+
'after',
|
|
214
|
+
[
|
|
215
|
+
'',
|
|
216
|
+
'Exit codes:',
|
|
217
|
+
' 0 the run completed, whatever the findings say',
|
|
218
|
+
' 1 refused: a file this could not read',
|
|
219
|
+
'',
|
|
220
|
+
'Examples:',
|
|
221
|
+
' canon context classify sweep',
|
|
222
|
+
' canon context classify sweep --doc-types design,requirements --json',
|
|
223
|
+
'',
|
|
224
|
+
].join('\n'),
|
|
225
|
+
)
|
|
226
|
+
.action(async (path: string | undefined, opts: ClassifySweepOptions) => {
|
|
227
|
+
process.exitCode = await runClassifySweep(path, opts)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
const classifier = context
|
|
231
|
+
.command('classifier')
|
|
232
|
+
.description('Read or write the project classifier setting')
|
|
233
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
234
|
+
|
|
235
|
+
classifier
|
|
236
|
+
.command('show')
|
|
237
|
+
.description(
|
|
238
|
+
'Report the backend and model that would run, and which source decided it',
|
|
239
|
+
)
|
|
240
|
+
.argument('[path]', 'Project root, defaulting to the current directory')
|
|
241
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
242
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
243
|
+
.action((path: string | undefined, opts: { json?: boolean }) => {
|
|
244
|
+
runClassifierShow(path, opts)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
classifier
|
|
248
|
+
.command('set')
|
|
249
|
+
.description('Write the project classifier setting')
|
|
250
|
+
.argument('[path]', 'Project root, defaulting to the current directory')
|
|
251
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
252
|
+
.requiredOption('--backend <name>', 'ollama or off')
|
|
253
|
+
.option('--model <name>', 'Model name, required when --backend is ollama')
|
|
254
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
255
|
+
.action((path: string | undefined, opts: ClassifierSetOptions) => {
|
|
256
|
+
process.exitCode = runClassifierSet(path, opts)
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
interface ClassifyDiffOptions {
|
|
261
|
+
readonly json?: boolean
|
|
262
|
+
readonly base?: string
|
|
263
|
+
readonly docTypes?: string
|
|
264
|
+
readonly backend?: string
|
|
265
|
+
readonly model?: string
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
interface ClassifySweepOptions {
|
|
269
|
+
readonly json?: boolean
|
|
270
|
+
readonly docTypes?: string
|
|
271
|
+
readonly backend?: string
|
|
272
|
+
readonly model?: string
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
interface ClassifierSetOptions {
|
|
276
|
+
readonly json?: boolean
|
|
277
|
+
readonly backend: string
|
|
278
|
+
readonly model?: string
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function parseDocTypes(
|
|
282
|
+
list: string | undefined,
|
|
283
|
+
): readonly CanonicalDocType[] | string {
|
|
284
|
+
if (!list) return CANONICAL_DOC_TYPES
|
|
285
|
+
|
|
286
|
+
const names = list
|
|
287
|
+
.split(',')
|
|
288
|
+
.map((name) => name.trim())
|
|
289
|
+
.filter(Boolean)
|
|
290
|
+
|
|
291
|
+
const invalid = names.filter(
|
|
292
|
+
(name) => !CANONICAL_DOC_TYPES.includes(name as CanonicalDocType),
|
|
293
|
+
)
|
|
294
|
+
if (invalid.length > 0) {
|
|
295
|
+
return `--doc-types takes ${CANONICAL_DOC_TYPES.join(', ')}: ${invalid.join(', ')}`
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return names as CanonicalDocType[]
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Validates `--backend` up front, so a typo'd value refuses rather than
|
|
303
|
+
* falling through `resolveBackend`'s tier chain to the environment, the
|
|
304
|
+
* file, or the default, which reads as the flag having been ignored.
|
|
305
|
+
*/
|
|
306
|
+
function parseBackendFlag(value: string | undefined): string | undefined {
|
|
307
|
+
if (value === undefined || value === 'ollama' || value === 'off') {
|
|
308
|
+
return undefined
|
|
309
|
+
}
|
|
310
|
+
return `--backend takes ollama or off: ${value}`
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Widens the extraction refusals with the CLI's own argument-parsing failure. */
|
|
314
|
+
type CliRefusal = ClassifyRefusal | 'bad-flags'
|
|
315
|
+
|
|
316
|
+
function refuseClassify(
|
|
317
|
+
reason: CliRefusal,
|
|
318
|
+
message: string,
|
|
319
|
+
emitJson: boolean,
|
|
320
|
+
): number {
|
|
321
|
+
intro('canon context classify')
|
|
322
|
+
logStep('Refused')
|
|
323
|
+
logWarn(message)
|
|
324
|
+
outro()
|
|
325
|
+
|
|
326
|
+
if (emitJson) {
|
|
327
|
+
process.stdout.write(
|
|
328
|
+
`${JSON.stringify({ decision: 'refused', reason, message })}\n`,
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
return 1
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** How each not-off model-layer state reads in the report. */
|
|
335
|
+
const MODEL_LAYER_LABEL: Record<string, string> = {
|
|
336
|
+
ran: 'ran',
|
|
337
|
+
'skipped-no-model': 'skipped, no model resolved for the configured backend',
|
|
338
|
+
'skipped-unreachable': 'skipped, the configured backend did not answer',
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function reportLayers(record: DiffRecord | SweepRecord): void {
|
|
342
|
+
logStep('Layers')
|
|
343
|
+
logInfo('regex: ran')
|
|
344
|
+
|
|
345
|
+
if (record.modelLayer === 'off') {
|
|
346
|
+
logInfo('model: off, no backend configured')
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
logInfo(
|
|
351
|
+
`model: ${MODEL_LAYER_LABEL[record.modelLayer]} (${record.backend ?? 'none'}${record.model ? `, ${record.model}` : ''})`,
|
|
352
|
+
)
|
|
353
|
+
if (record.modelLayer !== 'ran') {
|
|
354
|
+
logWarn('Falling back to the regex layer alone for this run.')
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function reportFindings(
|
|
359
|
+
findings: readonly (DiffFinding | SweepFinding)[],
|
|
360
|
+
label: (finding: DiffFinding | SweepFinding) => string,
|
|
361
|
+
): void {
|
|
362
|
+
logStep('Findings')
|
|
363
|
+
|
|
364
|
+
if (findings.length === 0) {
|
|
365
|
+
logInfo('Nothing met the extraction floor under the requested doc types.')
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const flagged = findings.filter((finding) => finding.verdict !== 'KEEP')
|
|
370
|
+
logInfo(
|
|
371
|
+
`${plural(findings.length, 'item')} read, ${plural(flagged.length, 'flagged')}`,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
if (flagged.length === 0) return
|
|
375
|
+
|
|
376
|
+
pipeOutput(
|
|
377
|
+
flagged
|
|
378
|
+
.map((finding) => {
|
|
379
|
+
const chosen =
|
|
380
|
+
finding.decidedBy === 'model' ? finding.model : finding.regex
|
|
381
|
+
return `${label(finding)} ${finding.verdict} (${finding.decidedBy})\n ${chosen?.quote ? `"${chosen.quote}" ` : ''}${chosen?.reason ?? ''}`
|
|
382
|
+
})
|
|
383
|
+
.join('\n'),
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function runClassifyDiff(
|
|
388
|
+
path: string | undefined,
|
|
389
|
+
opts: ClassifyDiffOptions,
|
|
390
|
+
): Promise<number> {
|
|
391
|
+
const root = resolve(path ?? process.cwd())
|
|
392
|
+
const emitJson = opts.json ?? false
|
|
393
|
+
|
|
394
|
+
const docTypes = parseDocTypes(opts.docTypes)
|
|
395
|
+
if (typeof docTypes === 'string') {
|
|
396
|
+
return refuseClassify('bad-flags', docTypes, emitJson)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const backendError = parseBackendFlag(opts.backend)
|
|
400
|
+
if (backendError !== undefined) {
|
|
401
|
+
return refuseClassify('bad-flags', backendError, emitJson)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const result: ClassifyOutcome<DiffRecord> = await classifyDiff(
|
|
405
|
+
root,
|
|
406
|
+
opts.base,
|
|
407
|
+
{
|
|
408
|
+
docTypes,
|
|
409
|
+
flags: { backend: opts.backend, model: opts.model },
|
|
410
|
+
},
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
if (result.kind === 'refused') {
|
|
414
|
+
return refuseClassify(result.reason, result.message, emitJson)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
intro('canon context classify diff')
|
|
418
|
+
reportLayers(result.record)
|
|
419
|
+
reportFindings(result.record.findings, (finding) => finding.file)
|
|
420
|
+
outro()
|
|
421
|
+
|
|
422
|
+
if (emitJson) {
|
|
423
|
+
process.stdout.write(
|
|
424
|
+
`${JSON.stringify({ decision: 'ok', ...result.record })}\n`,
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return 0
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function runClassifySweep(
|
|
432
|
+
path: string | undefined,
|
|
433
|
+
opts: ClassifySweepOptions,
|
|
434
|
+
): Promise<number> {
|
|
435
|
+
const root = resolve(path ?? process.cwd())
|
|
436
|
+
const emitJson = opts.json ?? false
|
|
437
|
+
|
|
438
|
+
const docTypes = parseDocTypes(opts.docTypes)
|
|
439
|
+
if (typeof docTypes === 'string') {
|
|
440
|
+
return refuseClassify('bad-flags', docTypes, emitJson)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const backendError = parseBackendFlag(opts.backend)
|
|
444
|
+
if (backendError !== undefined) {
|
|
445
|
+
return refuseClassify('bad-flags', backendError, emitJson)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const result: ClassifyOutcome<SweepRecord> = await classifySweep(root, {
|
|
449
|
+
docTypes,
|
|
450
|
+
flags: { backend: opts.backend, model: opts.model },
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
if (result.kind === 'refused') {
|
|
454
|
+
return refuseClassify(result.reason, result.message, emitJson)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
intro('canon context classify sweep')
|
|
458
|
+
reportLayers(result.record)
|
|
459
|
+
reportFindings(
|
|
460
|
+
result.record.findings,
|
|
461
|
+
(finding) => `${finding.file}:${(finding as SweepFinding).heading}`,
|
|
462
|
+
)
|
|
463
|
+
outro()
|
|
464
|
+
|
|
465
|
+
if (emitJson) {
|
|
466
|
+
process.stdout.write(
|
|
467
|
+
`${JSON.stringify({ decision: 'ok', ...result.record })}\n`,
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return 0
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function runClassifierShow(
|
|
475
|
+
path: string | undefined,
|
|
476
|
+
opts: { json?: boolean },
|
|
477
|
+
): void {
|
|
478
|
+
const root = resolve(path ?? process.cwd())
|
|
479
|
+
const resolution = resolveClassifier(root, {})
|
|
480
|
+
|
|
481
|
+
intro('canon context classifier show')
|
|
482
|
+
|
|
483
|
+
if (resolution.kind === 'off') {
|
|
484
|
+
logInfo(
|
|
485
|
+
`Model layer off (source: ${resolution.source}). Regex layer always runs.`,
|
|
486
|
+
)
|
|
487
|
+
} else if (resolution.kind === 'no-model') {
|
|
488
|
+
logWarn(
|
|
489
|
+
`Backend ${resolution.backend} configured (source: ${resolution.source}) with no model name. \`classify\` runs regex only.`,
|
|
490
|
+
)
|
|
491
|
+
} else {
|
|
492
|
+
logInfo(
|
|
493
|
+
`Backend ${resolution.backend}, model ${resolution.model} (source: ${resolution.source}).`,
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
logInfo(
|
|
498
|
+
`Reads ${CLASSIFIER_CONFIG_REL} when neither a flag nor an environment variable decides.`,
|
|
499
|
+
)
|
|
500
|
+
outro()
|
|
501
|
+
|
|
502
|
+
if (opts.json) {
|
|
503
|
+
process.stdout.write(`${JSON.stringify(resolution)}\n`)
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function runClassifierSet(
|
|
508
|
+
path: string | undefined,
|
|
509
|
+
opts: ClassifierSetOptions,
|
|
510
|
+
): number {
|
|
511
|
+
const root = resolve(path ?? process.cwd())
|
|
512
|
+
const emitJson = opts.json ?? false
|
|
513
|
+
|
|
514
|
+
if (opts.backend !== 'ollama' && opts.backend !== 'off') {
|
|
515
|
+
return refuseClassify(
|
|
516
|
+
'bad-flags',
|
|
517
|
+
'--backend takes ollama or off.',
|
|
518
|
+
emitJson,
|
|
519
|
+
)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (opts.backend === 'ollama' && !opts.model) {
|
|
523
|
+
return refuseClassify(
|
|
524
|
+
'bad-flags',
|
|
525
|
+
'--model is required when --backend is ollama.',
|
|
526
|
+
emitJson,
|
|
527
|
+
)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const backend: ClassifierBackend | 'off' = opts.backend
|
|
531
|
+
writeClassifierConfig(
|
|
532
|
+
root,
|
|
533
|
+
backend,
|
|
534
|
+
backend === 'off' ? undefined : opts.model,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
intro('canon context classifier set')
|
|
538
|
+
logInfo(
|
|
539
|
+
`Wrote ${CLASSIFIER_CONFIG_REL}: backend ${opts.backend}${opts.model ? `, model ${opts.model}` : ''}.`,
|
|
540
|
+
)
|
|
541
|
+
outro()
|
|
542
|
+
|
|
543
|
+
if (emitJson) {
|
|
544
|
+
process.stdout.write(
|
|
545
|
+
`${JSON.stringify({ decision: 'ok', backend: opts.backend, model: opts.model })}\n`,
|
|
546
|
+
)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return 0
|
|
125
550
|
}
|
|
126
551
|
|
|
127
552
|
function parseFolders(list: string | undefined): string[] | string {
|
package/src/commands/design.ts
CHANGED
|
@@ -11,12 +11,17 @@ import { buildDesignCss } from '@/design/css'
|
|
|
11
11
|
import { HAND_DRAWN_FONT_FACES } from '@/design/fonts'
|
|
12
12
|
import { renderDesignDoc } from '@/design/render'
|
|
13
13
|
import { DESIGN_BASE_CSS, DESIGN_DOCUMENT, regenDesign } from '@/design/regen'
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
checkoutMismatchWarning,
|
|
16
|
+
isOwnCheckout,
|
|
17
|
+
PROJECT_ROOT,
|
|
18
|
+
} from '@/project-root'
|
|
15
19
|
import { creationRel } from '@/record-root'
|
|
16
20
|
import { surfaceDir } from '@/surface-root'
|
|
17
21
|
import { recordStamp, runDomainSync } from '@/sync/engine'
|
|
18
22
|
import { resolveTarget } from '@/target'
|
|
19
23
|
import { intro, logAdd, logError, logInfo, logWarn, outro, palette } from '@/ui'
|
|
24
|
+
import { mainWorktreeRoot } from '@/worktree'
|
|
20
25
|
|
|
21
26
|
export function register(program: Command): void {
|
|
22
27
|
const design = program
|
|
@@ -133,33 +138,44 @@ export function register(program: Command): void {
|
|
|
133
138
|
design
|
|
134
139
|
.command('board')
|
|
135
140
|
.description(
|
|
136
|
-
'Generate the design board, an index over
|
|
141
|
+
'Generate the design board, an index over a project’s design surfaces',
|
|
137
142
|
)
|
|
138
143
|
.option(
|
|
139
144
|
'-o, --out <path>',
|
|
140
145
|
'Output directory',
|
|
141
146
|
creationRel(process.cwd(), 'review', 'board'),
|
|
142
147
|
)
|
|
148
|
+
.option('--root <path>', 'Project root, defaulting to the main worktree')
|
|
143
149
|
.addHelpText(
|
|
144
150
|
'after',
|
|
145
151
|
[
|
|
146
152
|
'',
|
|
147
|
-
'
|
|
148
|
-
'
|
|
149
|
-
'
|
|
150
|
-
'
|
|
153
|
+
'Reads its sources from --root, defaulting to the main worktree of',
|
|
154
|
+
'whatever project the caller stands in, and writes a static page',
|
|
155
|
+
'set, never installed or synced. The surfaces panel’s landing-page',
|
|
156
|
+
'half and the whole components panel render only when that root is',
|
|
157
|
+
'this toolkit’s own checkout. Open the result with canon serve <out>.',
|
|
151
158
|
'',
|
|
152
159
|
].join('\n'),
|
|
153
160
|
)
|
|
154
|
-
.action((opts: { out: string }) => {
|
|
161
|
+
.action(async (opts: { out: string; root?: string }) => {
|
|
155
162
|
const outDir = resolve(process.cwd(), opts.out)
|
|
163
|
+
const root = opts.root
|
|
164
|
+
? resolve(process.cwd(), opts.root)
|
|
165
|
+
: await mainWorktreeRoot()
|
|
166
|
+
const isToolkitCheckout = isOwnCheckout(root)
|
|
156
167
|
const { GREEN, GREY, NC, RED, WHITE } = palette(process.stderr)
|
|
157
168
|
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
158
169
|
process.stderr.write(
|
|
159
170
|
`${GREY}┌${NC}\n${GREY}│${NC} ${WHITE}Generate design board${NC}\n`,
|
|
160
171
|
)
|
|
161
172
|
if (mismatch !== undefined) logWarn(mismatch)
|
|
162
|
-
const result = generateBoard(
|
|
173
|
+
const result = generateBoard(
|
|
174
|
+
root,
|
|
175
|
+
outDir,
|
|
176
|
+
process.cwd(),
|
|
177
|
+
isToolkitCheckout,
|
|
178
|
+
)
|
|
163
179
|
if (!result.ok) {
|
|
164
180
|
process.stderr.write(
|
|
165
181
|
`${GREY}│${NC} ${RED}✗${NC} ${result.detail}\n${GREY}└${NC}\n`,
|