copperhead 0.9.0 → 0.11.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 (142) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +47 -8
  3. package/dist/agent/context.js +2 -0
  4. package/dist/agent/context.js.map +1 -0
  5. package/dist/agent/dock-renderer.js +2 -2
  6. package/dist/agent/dock-renderer.js.map +1 -1
  7. package/dist/agent/envelope.js +105 -0
  8. package/dist/agent/envelope.js.map +1 -0
  9. package/dist/agent/filetools.js +24 -1
  10. package/dist/agent/filetools.js.map +1 -1
  11. package/dist/agent/ledger.js +24 -0
  12. package/dist/agent/ledger.js.map +1 -1
  13. package/dist/agent/loop.js +63 -72
  14. package/dist/agent/loop.js.map +1 -1
  15. package/dist/agent/prompts.js +4 -3
  16. package/dist/agent/prompts.js.map +1 -1
  17. package/dist/agent/providers/claude-code.js +17 -1
  18. package/dist/agent/providers/claude-code.js.map +1 -1
  19. package/dist/agent/providers/codex.js +84 -39
  20. package/dist/agent/providers/codex.js.map +1 -1
  21. package/dist/agent/providers/tool-protocol.js +21 -0
  22. package/dist/agent/providers/tool-protocol.js.map +1 -1
  23. package/dist/agent/recovery.js +186 -15
  24. package/dist/agent/recovery.js.map +1 -1
  25. package/dist/agent/registry.js +49 -0
  26. package/dist/agent/registry.js.map +1 -0
  27. package/dist/agent/render.js +2 -2
  28. package/dist/agent/render.js.map +1 -1
  29. package/dist/agent/theme.js +10 -5
  30. package/dist/agent/theme.js.map +1 -1
  31. package/dist/agent/tools.js +100 -586
  32. package/dist/agent/tools.js.map +1 -1
  33. package/dist/agent/transcript.js +2 -0
  34. package/dist/agent/transcript.js.map +1 -1
  35. package/dist/capabilities/define.js +35 -0
  36. package/dist/capabilities/define.js.map +1 -0
  37. package/dist/capabilities/handlers.js +744 -0
  38. package/dist/capabilities/handlers.js.map +1 -0
  39. package/dist/capabilities/helpers.js +39 -0
  40. package/dist/capabilities/helpers.js.map +1 -0
  41. package/dist/capabilities/index.js +50 -0
  42. package/dist/capabilities/index.js.map +1 -0
  43. package/dist/capabilities/skills/generate-report.js +23 -0
  44. package/dist/capabilities/skills/generate-report.js.map +1 -0
  45. package/dist/cli.js +158 -0
  46. package/dist/cli.js.map +1 -1
  47. package/dist/commands/check.js +33 -1
  48. package/dist/commands/check.js.map +1 -1
  49. package/dist/commands/create.js +181 -26
  50. package/dist/commands/create.js.map +1 -1
  51. package/dist/commands/doctor.js +83 -6
  52. package/dist/commands/doctor.js.map +1 -1
  53. package/dist/commands/skill.js +109 -0
  54. package/dist/commands/skill.js.map +1 -0
  55. package/dist/commands/sync.js +3 -1
  56. package/dist/commands/sync.js.map +1 -1
  57. package/dist/config.js +19 -6
  58. package/dist/config.js.map +1 -1
  59. package/dist/kicad/bootstrap.js +24 -3
  60. package/dist/kicad/bootstrap.js.map +1 -1
  61. package/dist/kicad/cli.js +106 -18
  62. package/dist/kicad/cli.js.map +1 -1
  63. package/dist/kicad/dossier.js +207 -0
  64. package/dist/kicad/dossier.js.map +1 -0
  65. package/dist/kicad/draft/draft.js +135 -0
  66. package/dist/kicad/draft/draft.js.map +1 -0
  67. package/dist/kicad/draft/engine.js +5310 -0
  68. package/dist/kicad/draft/engine.js.map +1 -0
  69. package/dist/kicad/draft/ir.js +368 -0
  70. package/dist/kicad/draft/ir.js.map +1 -0
  71. package/dist/kicad/draft/symsource.js +504 -0
  72. package/dist/kicad/draft/symsource.js.map +1 -0
  73. package/dist/kicad/emit.js +220 -0
  74. package/dist/kicad/emit.js.map +1 -0
  75. package/dist/kicad/fab.js +13 -0
  76. package/dist/kicad/fab.js.map +1 -1
  77. package/dist/kicad/legibility.js +608 -0
  78. package/dist/kicad/legibility.js.map +1 -0
  79. package/dist/kicad/score.js +431 -0
  80. package/dist/kicad/score.js.map +1 -0
  81. package/dist/kicad/sexp.js +265 -6
  82. package/dist/kicad/sexp.js.map +1 -1
  83. package/dist/kicad/symlib.js +346 -16
  84. package/dist/kicad/symlib.js.map +1 -1
  85. package/dist/mcp/server.js +485 -0
  86. package/dist/mcp/server.js.map +1 -0
  87. package/dist/memory/bom-table.js +75 -39
  88. package/dist/memory/bom-table.js.map +1 -1
  89. package/dist/memory/scaffold.js +14 -1
  90. package/dist/memory/scaffold.js.map +1 -1
  91. package/dist/util/redact.js +6 -0
  92. package/dist/util/redact.js.map +1 -1
  93. package/package.json +13 -8
  94. package/src/agent/context.ts +35 -0
  95. package/src/agent/dock-renderer.ts +3 -2
  96. package/src/agent/envelope.ts +124 -0
  97. package/src/agent/filetools.ts +26 -1
  98. package/src/agent/ledger.ts +24 -0
  99. package/src/agent/loop.ts +73 -78
  100. package/src/agent/prompts.ts +4 -3
  101. package/src/agent/providers/claude-code.ts +22 -1
  102. package/src/agent/providers/codex.ts +91 -42
  103. package/src/agent/providers/tool-protocol.ts +22 -0
  104. package/src/agent/recovery.ts +183 -13
  105. package/src/agent/registry.ts +58 -0
  106. package/src/agent/render.ts +4 -3
  107. package/src/agent/theme.ts +15 -5
  108. package/src/agent/tools.ts +124 -628
  109. package/src/agent/transcript.ts +6 -0
  110. package/src/agent/types.ts +10 -5
  111. package/src/capabilities/define.ts +88 -0
  112. package/src/capabilities/handlers.ts +769 -0
  113. package/src/capabilities/helpers.ts +37 -0
  114. package/src/capabilities/index.ts +53 -0
  115. package/src/capabilities/skills/generate-report.ts +25 -0
  116. package/src/cli.ts +154 -0
  117. package/src/commands/check.ts +51 -1
  118. package/src/commands/create.ts +183 -21
  119. package/src/commands/doctor.ts +85 -6
  120. package/src/commands/skill.ts +127 -0
  121. package/src/commands/sync.ts +5 -3
  122. package/src/config.ts +56 -8
  123. package/src/kicad/bootstrap.ts +24 -3
  124. package/src/kicad/cli.ts +129 -18
  125. package/src/kicad/dossier.ts +217 -0
  126. package/src/kicad/draft/draft.ts +173 -0
  127. package/src/kicad/draft/engine.ts +5274 -0
  128. package/src/kicad/draft/ir.ts +416 -0
  129. package/src/kicad/draft/symsource.ts +549 -0
  130. package/src/kicad/emit.ts +300 -0
  131. package/src/kicad/fab.ts +15 -0
  132. package/src/kicad/legibility.ts +695 -0
  133. package/src/kicad/score.ts +502 -0
  134. package/src/kicad/sexp.ts +346 -6
  135. package/src/kicad/symlib.ts +364 -18
  136. package/src/mcp/server.ts +560 -0
  137. package/src/memory/bom-table.ts +85 -38
  138. package/src/memory/scaffold.ts +14 -1
  139. package/src/util/redact.ts +6 -0
  140. package/dist/memory/synap.js +0 -152
  141. package/dist/memory/synap.js.map +0 -1
  142. package/src/memory/synap.ts +0 -217
@@ -0,0 +1,37 @@
1
+ import { isKicadFile } from '../util/paths.js';
2
+ import type { RunContext } from '../agent/context.js';
3
+
4
+ export const str = (args: Record<string, unknown>, key: string): string => {
5
+ const v = args[key];
6
+ if (typeof v !== 'string' || v === '') throw new Error(`missing required string arg "${key}"`);
7
+ return v;
8
+ };
9
+
10
+ // U+FFFD (the Unicode replacement character) is what a byte sequence becomes
11
+ // when UTF-8 decoding fails — most often a multibyte glyph (Ω, µ, ±, °) split
12
+ // across a streaming chunk boundary and decoded per-chunk upstream in the
13
+ // provider SDK (I2). It never appears in a legitimately authored PCB doc, so
14
+ // its presence in a content-bearing tool arg means the value arrived corrupted.
15
+ // Reject the call before it lands on disk so the model re-emits; the corruption
16
+ // is nondeterministic (it depends on where a chunk boundary fell), so the retry
17
+ // almost always comes through clean — far cheaper than shipping a mangled value
18
+ // like "5.1kΩ" → "5.1k�" into DECISIONS.md and only noticing on review.
19
+ const REPLACEMENT_CHAR = '�';
20
+ export function corruptionError(fields: Record<string, unknown>): string | null {
21
+ const bad = Object.entries(fields)
22
+ .filter(([, v]) => typeof v === 'string' && v.includes(REPLACEMENT_CHAR))
23
+ .map(([k]) => k);
24
+ if (!bad.length) return null;
25
+ return `rejected: the ${bad.join(', ')} value contains U+FFFD (�), the replacement character that signals a UTF-8 decoding error — a special character (e.g. Ω, µ, ±, °) was likely mangled in transit. Re-send this exact call with the intended character written correctly, or spell it in ASCII (e.g. "ohm", "uF", "+/-", "deg").`;
26
+ }
27
+
28
+ export function markTouched(ctx: RunContext, rel: string): void {
29
+ ctx.filesTouched.add(rel);
30
+ if (isKicadFile(rel)) {
31
+ ctx.ledger.onKicadEdit(rel);
32
+ if (rel.endsWith('.kicad_sch')) ctx.lastErc = null;
33
+ if (rel.endsWith('.kicad_pcb')) ctx.lastDrc = null;
34
+ } else if (rel.endsWith('.md')) {
35
+ ctx.ledger.onDocEdit(rel);
36
+ }
37
+ }
@@ -0,0 +1,53 @@
1
+ import { outcomeResult, textResult, type ViewHint } from '../agent/envelope.js';
2
+ import { defineTool, type CatalogEntry, type CatalogTool } from './define.js';
3
+ import { HANDLERS } from './handlers.js';
4
+ import generateReport from './skills/generate-report.js';
5
+
6
+ export type { CatalogEntry, CatalogSkill, CatalogTool } from './define.js';
7
+ export { defineTool, defineSkill } from './define.js';
8
+
9
+ const HINT: Record<string, ViewHint> = {
10
+ read_file: 'query',
11
+ search: 'query',
12
+ list_symbols: 'query',
13
+ list_nets: 'query',
14
+ propose_change: 'mutation',
15
+ validate_change: 'diagnostic',
16
+ edit_file: 'mutation',
17
+ write_file: 'mutation',
18
+ run_erc: 'diagnostic',
19
+ search_symbols: 'query',
20
+ symbol_pins: 'query',
21
+ verify_symbols: 'diagnostic',
22
+ draft_schematic: 'mutation',
23
+ score_schematic: 'diagnostic',
24
+ check_legibility: 'diagnostic',
25
+ run_drc: 'diagnostic',
26
+ export_svg: 'export',
27
+ export_outputs: 'export',
28
+ check_drift: 'diagnostic',
29
+ record_constraint: 'mutation',
30
+ resolve_affected: 'mutation',
31
+ record_decision: 'mutation',
32
+ finish: 'diagnostic',
33
+ };
34
+
35
+ function wrap(def: (typeof HANDLERS)[number]): CatalogTool {
36
+ const viewHint = HINT[def.schema.name];
37
+ if (!viewHint) throw new Error(`missing viewHint for ${def.schema.name}`);
38
+ return defineTool({
39
+ schema: def.schema,
40
+ version: 1,
41
+ viewHint,
42
+ gate: def.requiresUnlock ? (ctx) => ctx.editsUnlocked : () => true,
43
+ handler: async (ctx, args) => {
44
+ const result = await def.handler(ctx, args);
45
+ return typeof result === 'string'
46
+ ? textResult(result, viewHint)
47
+ : outcomeResult(result.text, result.ok, viewHint);
48
+ },
49
+ });
50
+ }
51
+
52
+ /** Tools from HANDLERS + every skill module imported below (conformance checks skills/). */
53
+ export const catalog: CatalogEntry[] = [...HANDLERS.map(wrap), generateReport];
@@ -0,0 +1,25 @@
1
+ import { defineSkill } from '../define.js';
2
+
3
+ export default defineSkill({
4
+ schema: {
5
+ name: 'generate_report',
6
+ description:
7
+ 'Read-only report of the current design: ERC, DRC (if a board exists), drift, nets, and an optional SVG path. Does not edit files.',
8
+ parameters: {
9
+ type: 'object',
10
+ properties: { scope: { type: 'string', enum: ['power', 'all'], description: 'Report scope (default all)' } },
11
+ required: [],
12
+ },
13
+ },
14
+ version: 1,
15
+ viewHint: 'diagnostic',
16
+ tools: ['read_file', 'search', 'list_nets', 'run_erc', 'run_drc', 'export_svg', 'check_drift'],
17
+ maxTurns: 8,
18
+ prompt: (_ctx, args) => `You are generating a read-only design report (scope: ${args.scope === 'power' ? 'power' : 'all'}).
19
+ Call the available tools to gather ERC, DRC (only if a board is configured; otherwise skip), drift, and the net list. Optionally export an SVG of the schematic.
20
+ Do not edit any file. Do not call finish. When you have those results, stop calling tools.`,
21
+ isComplete: (ctx) =>
22
+ (!ctx.config.schematic || ctx.lastErc !== null) &&
23
+ (!ctx.config.board || ctx.lastDrc !== null) &&
24
+ (!ctx.config.schematic || ctx.lastDrift != null),
25
+ });
package/src/cli.ts CHANGED
@@ -179,6 +179,83 @@ program
179
179
  .description('ERC + DRC + doc-drift + spec validation; no LLM calls; CI-safe')
180
180
  .action(checkAction);
181
181
 
182
+ // `draft` and `score` are command groups taking the artifact as a noun
183
+ // (`draft schematic` today, `draft pcb` when layout drafting exists), so the
184
+ // verb alone never has to guess what it applies to.
185
+ const draftGroup = program
186
+ .command('draft')
187
+ .description('deterministically draft an artifact from its declared intent; no LLM, no network');
188
+ draftGroup
189
+ .command('schematic')
190
+ .description('draft the schematic from schematic.intent.json')
191
+ .option('--intent <path>', 'repo-relative intent file (default: schematic.intent.json beside the schematic)')
192
+ .action(async (opts: { intent?: string }) => {
193
+ const repo = repoOf(program.opts());
194
+ const json = Boolean(program.opts().json);
195
+ try {
196
+ const { loadConfig } = await import('./config.js');
197
+ const { draftSchematic, defaultIntentPath, formatSchematicDraftReport } = await import('./kicad/draft/draft.js');
198
+ const config = await loadConfig(repo);
199
+ if (!config.schematic) {
200
+ console.error('no schematic configured in .copperhead/config.json');
201
+ process.exit(1);
202
+ }
203
+ const res = await draftSchematic({
204
+ repoRoot: repo,
205
+ schematic: config.schematic,
206
+ intentPath: opts.intent ?? defaultIntentPath(config.schematic),
207
+ docsDir: config.docs,
208
+ });
209
+ if (!res.ok) {
210
+ if (json) console.log(JSON.stringify({ ok: false, findings: res.findings }, null, 2));
211
+ else console.error(res.message);
212
+ process.exit(1);
213
+ }
214
+ if (json) console.log(JSON.stringify({ ok: true, report: res.report }, null, 2));
215
+ else console.log(formatSchematicDraftReport(res.report));
216
+ process.exit(0);
217
+ } catch (err) {
218
+ console.error((err as Error).message);
219
+ process.exit(1);
220
+ }
221
+ });
222
+
223
+ const scoreGroup = program
224
+ .command('score')
225
+ .description('quantitative quality score for an artifact; advisory exit code; no LLM, no network');
226
+ scoreGroup
227
+ .command('schematic')
228
+ .description('legibility and layout score for the schematic')
229
+ .option('--file <path>', 'score this .kicad_sch instead of the configured schematic (any sheet, no repo needed)')
230
+ .action(async (opts: { file?: string }) => {
231
+ const repo = repoOf(program.opts());
232
+ const json = Boolean(program.opts().json);
233
+ try {
234
+ const { loadConfig } = await import('./config.js');
235
+ const { scoreSchematic, formatScore } = await import('./kicad/score.js');
236
+ const path = await import('node:path');
237
+ if (opts.file) {
238
+ const report = await scoreSchematic(path.resolve(opts.file), { docsDir: null });
239
+ console.log(json ? JSON.stringify(report, null, 2) : formatScore(report));
240
+ process.exit(0);
241
+ }
242
+ const config = await loadConfig(repo);
243
+ if (!config.schematic) {
244
+ console.error('no schematic configured in .copperhead/config.json');
245
+ process.exit(1);
246
+ }
247
+ const report = await scoreSchematic(path.join(repo, config.schematic), {
248
+ docsDir: path.join(repo, config.docs),
249
+ ...(config.legibility ? { config: config.legibility } : {}),
250
+ });
251
+ console.log(json ? JSON.stringify(report, null, 2) : formatScore(report));
252
+ process.exit(0); // the exit code never depends on the composite (AC-16.26 family)
253
+ } catch (err) {
254
+ console.error((err as Error).message);
255
+ process.exit(1);
256
+ }
257
+ });
258
+
182
259
  program
183
260
  .command('doctor')
184
261
  .description('env preflight: kicad-cli, git, node, and the model provider credential; no LLM, no network')
@@ -240,6 +317,60 @@ program
240
317
  },
241
318
  );
242
319
 
320
+ const skillCmd = program.command('skill').description('run a registered skill (nested tool loop; no git commit)');
321
+
322
+ skillCmd
323
+ .command('list')
324
+ .description('list registered skills (LLM-free, network-free)')
325
+ .action(async () => {
326
+ const repo = repoOf(program.opts());
327
+ try {
328
+ const { listSkills } = await import('./commands/skill.js');
329
+ const skills = await listSkills(repo);
330
+ if (program.opts().json) console.log(JSON.stringify(skills, null, 2));
331
+ else {
332
+ for (const s of skills) {
333
+ console.log(`${s.available ? '·' : '×'} ${s.name.replaceAll('_', '-')} ${s.description.split('\n')[0]}`);
334
+ }
335
+ }
336
+ process.exit(0);
337
+ } catch (err) {
338
+ console.error((err as Error).message);
339
+ process.exit(1);
340
+ }
341
+ });
342
+
343
+ skillCmd
344
+ .command('run')
345
+ .description('run a skill by name (generate-report)')
346
+ .argument('<name>', 'skill name (kebab or underscore)')
347
+ .option('--model <model>', 'codex | cursor | gpt-5 | claude | claude-code | compat:<id>')
348
+ .option('--scope <scope>', 'generate-report scope: power | all', 'all')
349
+ .action(async (name: string, opts: { model?: string; scope?: string }) => {
350
+ const repo = repoOf(program.opts());
351
+ const json = Boolean(program.opts().json);
352
+ // `process.exit` skips pending finally blocks, so the provider close has to
353
+ // finish before the exit call — hence the code is carried out, not exited on.
354
+ let code = 1;
355
+ try {
356
+ const { runSkillCli, providerForSkillRun } = await import('./commands/skill.js');
357
+ const { provider } = await providerForSkillRun(repo, opts.model);
358
+ const res = await runSkillCli({
359
+ repoRoot: repo,
360
+ name,
361
+ args: { scope: opts.scope === 'power' ? 'power' : 'all' },
362
+ provider,
363
+ json,
364
+ });
365
+ console.log(res.text);
366
+ code = res.code;
367
+ } catch (err) {
368
+ console.error((err as Error).message);
369
+ code = 1;
370
+ }
371
+ process.exit(code);
372
+ });
373
+
243
374
  program
244
375
  .command('sync')
245
376
  .description('verify the whole design state for inconsistencies and resolve drift')
@@ -276,6 +407,29 @@ program
276
407
  }
277
408
  });
278
409
 
410
+ program
411
+ .command('mcp')
412
+ .description('EXPERIMENTAL: serve the gated pipeline to MCP hosts over stdio (unstable surface)')
413
+ // `--repo` is also a global flag, but a host config reads as
414
+ // `args: ["mcp", "--repo", "/path"]`, so the command accepts it locally too.
415
+ .option('--repo <path>', 'target repository (default: cwd)')
416
+ .action(async (opts: { repo?: string }) => {
417
+ const repo = repoOf(opts.repo ? { repo: opts.repo } : program.opts());
418
+ try {
419
+ // Imported lazily, like `skill`: the MCP SDK and zod are a ~150ms load
420
+ // that every other command — including the pre-commit `check` — would
421
+ // otherwise pay, and a resolution failure here would take down the whole
422
+ // CLI rather than just this command.
423
+ const { startMcpServer } = await import('./mcp/server.js');
424
+ // Never returns until the host closes stdio. Nothing is printed to
425
+ // stdout here or anywhere downstream: it carries JSON-RPC alone.
426
+ await startMcpServer({ repoRoot: repo });
427
+ } catch (err) {
428
+ console.error((err as Error).message);
429
+ process.exit(1);
430
+ }
431
+ });
432
+
279
433
  program
280
434
  .command('demo')
281
435
  .description('tour of what copperhead does, or run the USB-C breakout create pipeline')
@@ -5,7 +5,9 @@ import { runErc, runDrc } from '../kicad/cli.js';
5
5
  import { formatViolations, type CheckReport } from '../kicad/report.js';
6
6
  import { checkDrift, emptySchematicWarning, type DriftMismatch } from '../memory/drift.js';
7
7
  import { loadConstraints, checkForbiddenPins, type ConstraintViolation } from '../memory/constraints.js';
8
- import { pinNets } from '../kicad/sexp.js';
8
+ import { pinNets, readSheetGeometry } from '../kicad/sexp.js';
9
+ import { scoreFromGeometry, type ScoreReport } from '../kicad/score.js';
10
+ import { checkLegibility, formatLegibility, LEGIBILITY_FAMILIES, type LegibilityFinding } from '../kicad/legibility.js';
9
11
  import { openspecValidate } from '../openspec/cli.js';
10
12
 
11
13
  /**
@@ -19,6 +21,20 @@ export interface CheckResult {
19
21
  drift: { ok: boolean; mismatches: DriftMismatch[]; warning?: string };
20
22
  openspec: { ok: boolean; detail: string } | null;
21
23
  constraints: { ok: boolean; violations: ConstraintViolation[] };
24
+ /**
25
+ * Advisory at every severity (design C6): findings inform, the exit code
26
+ * never depends on them, so existing repos gain information, not failures.
27
+ * Always present — all families skipped when no schematic is configured.
28
+ */
29
+ legibility: {
30
+ findings: LegibilityFinding[];
31
+ counts: { error: number; advisory: number };
32
+ skipped: { family: string; reason: string }[];
33
+ disabled: string[];
34
+ suppressed: { family: string; sheet: string; count: number }[];
35
+ /** Advisory quantitative score; null when no schematic is configured. */
36
+ score: ScoreReport | null;
37
+ };
22
38
  }
23
39
 
24
40
  export async function runCheck(repoRoot: string, log: (s: string) => void): Promise<CheckResult> {
@@ -59,6 +75,39 @@ export async function runCheck(repoRoot: string, log: (s: string) => void): Prom
59
75
  log(res.ok ? 'openspec ✓' : `openspec: ${res.output}`);
60
76
  }
61
77
 
78
+ let legibility: CheckResult['legibility'];
79
+ if (config.schematic && existsSync(path.join(repoRoot, config.schematic))) {
80
+ const report = await checkLegibility(path.join(repoRoot, config.schematic), {
81
+ docsDir: path.join(repoRoot, config.docs),
82
+ ...(config.legibility ? { config: config.legibility } : {}),
83
+ });
84
+ const score = scoreFromGeometry(
85
+ await readSheetGeometry(path.join(repoRoot, config.schematic)),
86
+ report,
87
+ config.legibility,
88
+ );
89
+ legibility = {
90
+ findings: report.findings,
91
+ counts: report.counts,
92
+ skipped: report.skipped,
93
+ disabled: report.disabled,
94
+ suppressed: report.suppressed,
95
+ score,
96
+ };
97
+ log(formatLegibility(report));
98
+ log(`legibility score: ${score.composite}/100${score.cap ? ` (capped: ${score.cap.reason})` : ''}`);
99
+ } else {
100
+ legibility = {
101
+ findings: [],
102
+ counts: { error: 0, advisory: 0 },
103
+ skipped: LEGIBILITY_FAMILIES.map((family) => ({ family, reason: 'no schematic configured' })),
104
+ disabled: [],
105
+ suppressed: [],
106
+ score: null,
107
+ };
108
+ log('legibility skipped (no schematic configured)');
109
+ }
110
+
62
111
  let constraintViolations: ConstraintViolation[] = [];
63
112
  if (config.schematic && existsSync(path.join(repoRoot, config.schematic))) {
64
113
  const registry = await loadConstraints(repoRoot);
@@ -87,5 +136,6 @@ export async function runCheck(repoRoot: string, log: (s: string) => void): Prom
87
136
  drift: { ok: drift.length === 0, mismatches: drift, ...(driftWarning ? { warning: driftWarning } : {}) },
88
137
  openspec,
89
138
  constraints: { ok: constraintViolations.length === 0, violations: constraintViolations },
139
+ legibility,
90
140
  };
91
141
  }