@erclx/canon 4.81.0 → 4.83.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 (44) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/docs-fold/REQUIREMENT.md +13 -0
  3. package/claude/skills/docs-fold/SKILL.md +20 -0
  4. package/claude/skills/docs-fold/references/classify.md +78 -0
  5. package/claude/skills/draft-slides/SKILL.md +1 -1
  6. package/claude/skills/draft-wireframes/REQUIREMENT.md +1 -1
  7. package/claude/skills/draft-wireframes/SKILL.md +7 -4
  8. package/docs/agents/commands.md +8 -4
  9. package/docs/agents/context-audit-checks.md +22 -2
  10. package/docs/agents/context-audit.md +13 -3
  11. package/docs/agents/context-classify.md +99 -0
  12. package/docs/agents/index.md +1 -0
  13. package/docs/target-projects.md +15 -0
  14. package/docs/workflow/ai-workflow.md +4 -3
  15. package/docs/workflow/visual-design-workflow.md +1 -1
  16. package/governance/rules/claude/520-wireframes.md +1 -1
  17. package/governance/rules/claude/545-decisions.md +12 -0
  18. package/package.json +1 -1
  19. package/src/claude/seeds.ts +1 -0
  20. package/src/commands/claude.ts +26 -5
  21. package/src/commands/context.ts +496 -2
  22. package/src/context/architecture.ts +73 -0
  23. package/src/context/audit.ts +18 -2
  24. package/src/context/classify/extract.ts +450 -0
  25. package/src/context/classify/ollama.ts +172 -0
  26. package/src/context/classify/patterns.ts +114 -0
  27. package/src/context/classify/prompts.ts +73 -0
  28. package/src/context/classify/run.ts +348 -0
  29. package/src/context/classify/settings.ts +196 -0
  30. package/src/context/folders.ts +1 -0
  31. package/src/context/gate.ts +44 -6
  32. package/src/context/wireframe-states.ts +238 -0
  33. package/src/surface-root.ts +1 -0
  34. package/standards/architecture.md +11 -1
  35. package/standards/context.md +4 -1
  36. package/standards/decisions.md +100 -0
  37. package/standards/design.md +6 -0
  38. package/standards/index.md +1 -0
  39. package/standards/requirements.md +2 -1
  40. package/standards/wireframes.md +45 -29
  41. package/tooling/claude/reference.md +2 -1
  42. package/tooling/claude/seeds/CLAUDE.md +2 -1
  43. package/tooling/claude/seeds/canon/decisions/index.md +8 -0
  44. package/tooling/claude/seeds/canon/wireframes/index.md +2 -2
@@ -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[] = ['memory', 'tasks', 'wireframes']
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. The three seeded files and `wireframes` are tracked and stay
584
- // at `.claude/`, which the resolver answers for them anyway, since nothing
585
- // ever creates a second root copy for a name that does not move.
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(recordDir(resolved, name))) logInfo(`${name}/`)
613
+ if (isDirectory(seededDirPath(resolved, name))) logInfo(`${name}/`)
593
614
  else logWarn(`${name}/ missing. Run \`canon claude init\``)
594
615
  }
595
616
 
@@ -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,
@@ -39,6 +59,10 @@ import {
39
59
  PRONOUN_HEADING,
40
60
  VERB_HEADING,
41
61
  } from '@/context/narration'
62
+ import {
63
+ measureWireframeFolder,
64
+ type WireframeStatesReport,
65
+ } from '@/context/wireframe-states'
42
66
  import { RENDER_WIDTH } from '@/markdown/structure'
43
67
  import {
44
68
  frameError,
@@ -74,7 +98,7 @@ export function register(program: Command): void {
74
98
  context
75
99
  .command('audit')
76
100
  .description(
77
- 'Report required sections, entry length, citations, reference form, catalog tables, provenance, superseded-decision narration, index drift, and the architecture record against its own ceiling',
101
+ 'Report required sections, entry length, citations, reference form, catalog tables, provenance, superseded-decision narration, index drift, the architecture record against its own ceiling and its word weight, and wireframe states against their evidence folders',
78
102
  )
79
103
  .argument('[path]', 'Project root, defaulting to the current directory')
80
104
  .helpOption('-h, --help', 'Show this help message')
@@ -122,6 +146,411 @@ export function register(program: Command): void {
122
146
  .action(async (path: string | undefined, opts: AuditCommandOptions) => {
123
147
  process.exitCode = await runAudit(path, opts)
124
148
  })
149
+
150
+ const classify = context
151
+ .command('classify')
152
+ .description(
153
+ 'Classify canonical-doc content as keep, replace/rewrite, history, or move',
154
+ )
155
+ .helpOption('-h, --help', 'Show this help message')
156
+
157
+ classify
158
+ .command('diff')
159
+ .description(
160
+ 'Classify the chunks a git range changed, each with the section it landed in',
161
+ )
162
+ .argument('[path]', 'Project root, defaulting to the current directory')
163
+ .helpOption('-h, --help', 'Show this help message')
164
+ .option('--base <ref>', 'Far side of the range, defaulting to the trunk')
165
+ .option(
166
+ '--doc-types <list>',
167
+ `Comma-separated canonical doc types (default: all five: ${CANONICAL_DOC_TYPES.join(', ')})`,
168
+ )
169
+ .option(
170
+ '--backend <name>',
171
+ 'Override the resolved model backend for this run',
172
+ )
173
+ .option('--model <name>', 'Override the resolved model name for this run')
174
+ .option('--json', 'Add a machine-readable record on stdout')
175
+ .addHelpText(
176
+ 'after',
177
+ [
178
+ '',
179
+ 'The regex layer always runs. The model layer runs only when a',
180
+ 'backend resolves through `canon context classifier show`, and a',
181
+ 'configured-but-unreachable backend warns and falls back to regex',
182
+ 'rather than failing the run.',
183
+ '',
184
+ 'Exit codes:',
185
+ ' 0 the run completed, whatever the findings say',
186
+ ' 1 refused: a bad range or a file this could not read',
187
+ '',
188
+ 'Examples:',
189
+ ' canon context classify diff',
190
+ ' canon context classify diff --base origin/main --json',
191
+ ' canon context classify diff --doc-types context,wireframes',
192
+ '',
193
+ ].join('\n'),
194
+ )
195
+ .action(async (path: string | undefined, opts: ClassifyDiffOptions) => {
196
+ process.exitCode = await runClassifyDiff(path, opts)
197
+ })
198
+
199
+ classify
200
+ .command('sweep')
201
+ .description(
202
+ 'Classify every section of the five canonical doc types, split at H3',
203
+ )
204
+ .argument('[path]', 'Project root, defaulting to the current directory')
205
+ .helpOption('-h, --help', 'Show this help message')
206
+ .option(
207
+ '--doc-types <list>',
208
+ `Comma-separated canonical doc types (default: all five: ${CANONICAL_DOC_TYPES.join(', ')})`,
209
+ )
210
+ .option(
211
+ '--backend <name>',
212
+ 'Override the resolved model backend for this run',
213
+ )
214
+ .option('--model <name>', 'Override the resolved model name for this run')
215
+ .option('--json', 'Add a machine-readable record on stdout')
216
+ .addHelpText(
217
+ 'after',
218
+ [
219
+ '',
220
+ 'Exit codes:',
221
+ ' 0 the run completed, whatever the findings say',
222
+ ' 1 refused: a file this could not read',
223
+ '',
224
+ 'Examples:',
225
+ ' canon context classify sweep',
226
+ ' canon context classify sweep --doc-types design,requirements --json',
227
+ '',
228
+ ].join('\n'),
229
+ )
230
+ .action(async (path: string | undefined, opts: ClassifySweepOptions) => {
231
+ process.exitCode = await runClassifySweep(path, opts)
232
+ })
233
+
234
+ const classifier = context
235
+ .command('classifier')
236
+ .description('Read or write the project classifier setting')
237
+ .helpOption('-h, --help', 'Show this help message')
238
+
239
+ classifier
240
+ .command('show')
241
+ .description(
242
+ 'Report the backend and model that would run, and which source decided it',
243
+ )
244
+ .argument('[path]', 'Project root, defaulting to the current directory')
245
+ .helpOption('-h, --help', 'Show this help message')
246
+ .option('--json', 'Add a machine-readable record on stdout')
247
+ .action((path: string | undefined, opts: { json?: boolean }) => {
248
+ runClassifierShow(path, opts)
249
+ })
250
+
251
+ classifier
252
+ .command('set')
253
+ .description('Write the project classifier setting')
254
+ .argument('[path]', 'Project root, defaulting to the current directory')
255
+ .helpOption('-h, --help', 'Show this help message')
256
+ .requiredOption('--backend <name>', 'ollama or off')
257
+ .option('--model <name>', 'Model name, required when --backend is ollama')
258
+ .option('--json', 'Add a machine-readable record on stdout')
259
+ .action((path: string | undefined, opts: ClassifierSetOptions) => {
260
+ process.exitCode = runClassifierSet(path, opts)
261
+ })
262
+ }
263
+
264
+ interface ClassifyDiffOptions {
265
+ readonly json?: boolean
266
+ readonly base?: string
267
+ readonly docTypes?: string
268
+ readonly backend?: string
269
+ readonly model?: string
270
+ }
271
+
272
+ interface ClassifySweepOptions {
273
+ readonly json?: boolean
274
+ readonly docTypes?: string
275
+ readonly backend?: string
276
+ readonly model?: string
277
+ }
278
+
279
+ interface ClassifierSetOptions {
280
+ readonly json?: boolean
281
+ readonly backend: string
282
+ readonly model?: string
283
+ }
284
+
285
+ function parseDocTypes(
286
+ list: string | undefined,
287
+ ): readonly CanonicalDocType[] | string {
288
+ if (!list) return CANONICAL_DOC_TYPES
289
+
290
+ const names = list
291
+ .split(',')
292
+ .map((name) => name.trim())
293
+ .filter(Boolean)
294
+
295
+ const invalid = names.filter(
296
+ (name) => !CANONICAL_DOC_TYPES.includes(name as CanonicalDocType),
297
+ )
298
+ if (invalid.length > 0) {
299
+ return `--doc-types takes ${CANONICAL_DOC_TYPES.join(', ')}: ${invalid.join(', ')}`
300
+ }
301
+
302
+ return names as CanonicalDocType[]
303
+ }
304
+
305
+ /**
306
+ * Validates `--backend` up front, so a typo'd value refuses rather than
307
+ * falling through `resolveBackend`'s tier chain to the environment, the
308
+ * file, or the default, which reads as the flag having been ignored.
309
+ */
310
+ function parseBackendFlag(value: string | undefined): string | undefined {
311
+ if (value === undefined || value === 'ollama' || value === 'off') {
312
+ return undefined
313
+ }
314
+ return `--backend takes ollama or off: ${value}`
315
+ }
316
+
317
+ /** Widens the extraction refusals with the CLI's own argument-parsing failure. */
318
+ type CliRefusal = ClassifyRefusal | 'bad-flags'
319
+
320
+ function refuseClassify(
321
+ reason: CliRefusal,
322
+ message: string,
323
+ emitJson: boolean,
324
+ ): number {
325
+ intro('canon context classify')
326
+ logStep('Refused')
327
+ logWarn(message)
328
+ outro()
329
+
330
+ if (emitJson) {
331
+ process.stdout.write(
332
+ `${JSON.stringify({ decision: 'refused', reason, message })}\n`,
333
+ )
334
+ }
335
+ return 1
336
+ }
337
+
338
+ /** How each not-off model-layer state reads in the report. */
339
+ const MODEL_LAYER_LABEL: Record<string, string> = {
340
+ ran: 'ran',
341
+ 'skipped-no-model': 'skipped, no model resolved for the configured backend',
342
+ 'skipped-unreachable': 'skipped, the configured backend did not answer',
343
+ }
344
+
345
+ function reportLayers(record: DiffRecord | SweepRecord): void {
346
+ logStep('Layers')
347
+ logInfo('regex: ran')
348
+
349
+ if (record.modelLayer === 'off') {
350
+ logInfo('model: off, no backend configured')
351
+ return
352
+ }
353
+
354
+ logInfo(
355
+ `model: ${MODEL_LAYER_LABEL[record.modelLayer]} (${record.backend ?? 'none'}${record.model ? `, ${record.model}` : ''})`,
356
+ )
357
+ if (record.modelLayer !== 'ran') {
358
+ logWarn('Falling back to the regex layer alone for this run.')
359
+ }
360
+ }
361
+
362
+ function reportFindings(
363
+ findings: readonly (DiffFinding | SweepFinding)[],
364
+ label: (finding: DiffFinding | SweepFinding) => string,
365
+ ): void {
366
+ logStep('Findings')
367
+
368
+ if (findings.length === 0) {
369
+ logInfo('Nothing met the extraction floor under the requested doc types.')
370
+ return
371
+ }
372
+
373
+ const flagged = findings.filter((finding) => finding.verdict !== 'KEEP')
374
+ logInfo(
375
+ `${plural(findings.length, 'item')} read, ${plural(flagged.length, 'flagged')}`,
376
+ )
377
+
378
+ if (flagged.length === 0) return
379
+
380
+ pipeOutput(
381
+ flagged
382
+ .map((finding) => {
383
+ const chosen =
384
+ finding.decidedBy === 'model' ? finding.model : finding.regex
385
+ return `${label(finding)} ${finding.verdict} (${finding.decidedBy})\n ${chosen?.quote ? `"${chosen.quote}" ` : ''}${chosen?.reason ?? ''}`
386
+ })
387
+ .join('\n'),
388
+ )
389
+ }
390
+
391
+ async function runClassifyDiff(
392
+ path: string | undefined,
393
+ opts: ClassifyDiffOptions,
394
+ ): Promise<number> {
395
+ const root = resolve(path ?? process.cwd())
396
+ const emitJson = opts.json ?? false
397
+
398
+ const docTypes = parseDocTypes(opts.docTypes)
399
+ if (typeof docTypes === 'string') {
400
+ return refuseClassify('bad-flags', docTypes, emitJson)
401
+ }
402
+
403
+ const backendError = parseBackendFlag(opts.backend)
404
+ if (backendError !== undefined) {
405
+ return refuseClassify('bad-flags', backendError, emitJson)
406
+ }
407
+
408
+ const result: ClassifyOutcome<DiffRecord> = await classifyDiff(
409
+ root,
410
+ opts.base,
411
+ {
412
+ docTypes,
413
+ flags: { backend: opts.backend, model: opts.model },
414
+ },
415
+ )
416
+
417
+ if (result.kind === 'refused') {
418
+ return refuseClassify(result.reason, result.message, emitJson)
419
+ }
420
+
421
+ intro('canon context classify diff')
422
+ reportLayers(result.record)
423
+ reportFindings(result.record.findings, (finding) => finding.file)
424
+ outro()
425
+
426
+ if (emitJson) {
427
+ process.stdout.write(
428
+ `${JSON.stringify({ decision: 'ok', ...result.record })}\n`,
429
+ )
430
+ }
431
+
432
+ return 0
433
+ }
434
+
435
+ async function runClassifySweep(
436
+ path: string | undefined,
437
+ opts: ClassifySweepOptions,
438
+ ): Promise<number> {
439
+ const root = resolve(path ?? process.cwd())
440
+ const emitJson = opts.json ?? false
441
+
442
+ const docTypes = parseDocTypes(opts.docTypes)
443
+ if (typeof docTypes === 'string') {
444
+ return refuseClassify('bad-flags', docTypes, emitJson)
445
+ }
446
+
447
+ const backendError = parseBackendFlag(opts.backend)
448
+ if (backendError !== undefined) {
449
+ return refuseClassify('bad-flags', backendError, emitJson)
450
+ }
451
+
452
+ const result: ClassifyOutcome<SweepRecord> = await classifySweep(root, {
453
+ docTypes,
454
+ flags: { backend: opts.backend, model: opts.model },
455
+ })
456
+
457
+ if (result.kind === 'refused') {
458
+ return refuseClassify(result.reason, result.message, emitJson)
459
+ }
460
+
461
+ intro('canon context classify sweep')
462
+ reportLayers(result.record)
463
+ reportFindings(
464
+ result.record.findings,
465
+ (finding) => `${finding.file}:${(finding as SweepFinding).heading}`,
466
+ )
467
+ outro()
468
+
469
+ if (emitJson) {
470
+ process.stdout.write(
471
+ `${JSON.stringify({ decision: 'ok', ...result.record })}\n`,
472
+ )
473
+ }
474
+
475
+ return 0
476
+ }
477
+
478
+ function runClassifierShow(
479
+ path: string | undefined,
480
+ opts: { json?: boolean },
481
+ ): void {
482
+ const root = resolve(path ?? process.cwd())
483
+ const resolution = resolveClassifier(root, {})
484
+
485
+ intro('canon context classifier show')
486
+
487
+ if (resolution.kind === 'off') {
488
+ logInfo(
489
+ `Model layer off (source: ${resolution.source}). Regex layer always runs.`,
490
+ )
491
+ } else if (resolution.kind === 'no-model') {
492
+ logWarn(
493
+ `Backend ${resolution.backend} configured (source: ${resolution.source}) with no model name. \`classify\` runs regex only.`,
494
+ )
495
+ } else {
496
+ logInfo(
497
+ `Backend ${resolution.backend}, model ${resolution.model} (source: ${resolution.source}).`,
498
+ )
499
+ }
500
+
501
+ logInfo(
502
+ `Reads ${CLASSIFIER_CONFIG_REL} when neither a flag nor an environment variable decides.`,
503
+ )
504
+ outro()
505
+
506
+ if (opts.json) {
507
+ process.stdout.write(`${JSON.stringify(resolution)}\n`)
508
+ }
509
+ }
510
+
511
+ function runClassifierSet(
512
+ path: string | undefined,
513
+ opts: ClassifierSetOptions,
514
+ ): number {
515
+ const root = resolve(path ?? process.cwd())
516
+ const emitJson = opts.json ?? false
517
+
518
+ if (opts.backend !== 'ollama' && opts.backend !== 'off') {
519
+ return refuseClassify(
520
+ 'bad-flags',
521
+ '--backend takes ollama or off.',
522
+ emitJson,
523
+ )
524
+ }
525
+
526
+ if (opts.backend === 'ollama' && !opts.model) {
527
+ return refuseClassify(
528
+ 'bad-flags',
529
+ '--model is required when --backend is ollama.',
530
+ emitJson,
531
+ )
532
+ }
533
+
534
+ const backend: ClassifierBackend | 'off' = opts.backend
535
+ writeClassifierConfig(
536
+ root,
537
+ backend,
538
+ backend === 'off' ? undefined : opts.model,
539
+ )
540
+
541
+ intro('canon context classifier set')
542
+ logInfo(
543
+ `Wrote ${CLASSIFIER_CONFIG_REL}: backend ${opts.backend}${opts.model ? `, model ${opts.model}` : ''}.`,
544
+ )
545
+ outro()
546
+
547
+ if (emitJson) {
548
+ process.stdout.write(
549
+ `${JSON.stringify({ decision: 'ok', backend: opts.backend, model: opts.model })}\n`,
550
+ )
551
+ }
552
+
553
+ return 0
125
554
  }
126
555
 
127
556
  function parseFolders(list: string | undefined): string[] | string {
@@ -237,6 +666,15 @@ async function runAudit(
237
666
  // are different answers, and one value for both reports the second as the
238
667
  // first.
239
668
  const record = gateOnly ? undefined : await measureArchitecture(root)
669
+ const wireframes = gateOnly
670
+ ? []
671
+ : (
672
+ await Promise.all(
673
+ folders
674
+ .filter((folder) => folder.name === 'wireframes')
675
+ .map((folder) => measureWireframeFolder(root, folder)),
676
+ )
677
+ ).flat()
240
678
 
241
679
  if (gateOnly) {
242
680
  reportGate(citations)
@@ -252,6 +690,7 @@ async function runAudit(
252
690
  reportNarration(entries, folders, narration)
253
691
  reportDrift(drift)
254
692
  reportRecord(record, root)
693
+ reportWireframeStates(wireframes)
255
694
  outro()
256
695
  }
257
696
 
@@ -288,6 +727,11 @@ async function runAudit(
288
727
  // target that never wrote one is entitled to. Absent says the run
289
728
  // never looked, which is `--citations-only`.
290
729
  architecture: gateOnly ? undefined : (record ?? null),
730
+ // Absent under `--citations-only`, for the same reason as above. An
731
+ // empty array under the ordinary run says the project carries no
732
+ // wireframes folder or no entry carrying a States table, which is a
733
+ // fact rather than an unmeasured run.
734
+ wireframes: gateOnly ? undefined : wireframes,
291
735
  checkpoints: {
292
736
  lines: LENGTH_CHECKPOINT,
293
737
  renderWidth: RENDER_WIDTH,
@@ -317,6 +761,7 @@ async function runAudit(
317
761
  recordOverLength: record !== undefined && isOverLength(record),
318
762
  sections,
319
763
  drift,
764
+ wireframes,
320
765
  widened,
321
766
  })
322
767
 
@@ -777,6 +1222,15 @@ function reportRecord(
777
1222
  const decisions = report.decisions.length
778
1223
  const { allowances } = report
779
1224
 
1225
+ logInfo(
1226
+ `${plural(report.words, 'word')} across ${plural(report.lines, 'line')}, read alongside the weight judgment a session makes by reading the file. This never gates.`,
1227
+ )
1228
+ if (report.risksWords !== undefined) {
1229
+ logInfo(
1230
+ `\`## Risks / open questions\` holds ${plural(report.risksWords, 'word')}, weighed the same way and read alongside the same judgment.`,
1231
+ )
1232
+ }
1233
+
780
1234
  if (allowances === undefined) {
781
1235
  logInfo(
782
1236
  `Covers ${report.rel} alone. No standard sets a length rule for it and this record states none, so its ${plural(report.lines, 'line')} across ${plural(decisions, 'decision')} are reported and nothing is gated.`,
@@ -836,7 +1290,7 @@ function reportRecord(
836
1290
  entry.checks.length > 0
837
1291
  ? `\n checked by ${entry.checks.join(', ')}`
838
1292
  : ''
839
- return `${report.rel}:${entry.line} ${kind}${evidence}\n ${entry.heading}${checks}`
1293
+ return `${report.rel}:${entry.line} ${kind}${evidence} ${plural(entry.words, 'word')}\n ${entry.heading}${checks}`
840
1294
  })
841
1295
  .join('\n'),
842
1296
  )
@@ -858,3 +1312,43 @@ function reportDrift(drift: readonly FolderDrift[]): void {
858
1312
  logWarn(plural(lines.length, 'disagreement'))
859
1313
  pipeOutput(lines.join('\n'))
860
1314
  }
1315
+
1316
+ function reportWireframeStates(
1317
+ wireframes: readonly WireframeStatesReport[],
1318
+ ): void {
1319
+ logStep('Wireframe states')
1320
+
1321
+ const withRows = wireframes.filter((entry) => entry.rows.length > 0)
1322
+ if (withRows.length === 0) {
1323
+ logInfo(
1324
+ 'No wireframe carries a States table, so nothing was checked against its evidence folders.',
1325
+ )
1326
+ return
1327
+ }
1328
+
1329
+ const lines = withRows.flatMap((entry) => [
1330
+ ...entry.missingFolders.map(
1331
+ (finding) =>
1332
+ `${entry.rel}:${finding.line} ${finding.state} no folder at ${finding.path}`,
1333
+ ),
1334
+ ...entry.unlistedFolders.map(
1335
+ (finding) =>
1336
+ `${entry.rel} ${finding.root}/${finding.folder} named in no row`,
1337
+ ),
1338
+ ...(entry.sketchWithEvidence
1339
+ ? [
1340
+ `${entry.rel}:${entry.sketchLine} a plaintext sketch sits beside evidence that already exists`,
1341
+ ]
1342
+ : []),
1343
+ ])
1344
+
1345
+ if (lines.length === 0) {
1346
+ logInfo(
1347
+ `${plural(withRows.length, 'wireframe')} checked, every state matched one-to-one with its evidence folder.`,
1348
+ )
1349
+ return
1350
+ }
1351
+
1352
+ logWarn(plural(lines.length, 'finding'))
1353
+ pipeOutput(lines.join('\n'))
1354
+ }