copperhead 0.7.0 → 0.8.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 (79) hide show
  1. package/README.md +36 -4
  2. package/dist/agent/animate.js +76 -0
  3. package/dist/agent/animate.js.map +1 -0
  4. package/dist/agent/box.js +89 -0
  5. package/dist/agent/box.js.map +1 -0
  6. package/dist/agent/dock-renderer.js +173 -0
  7. package/dist/agent/dock-renderer.js.map +1 -0
  8. package/dist/agent/logo.js +21 -0
  9. package/dist/agent/logo.js.map +1 -0
  10. package/dist/agent/loop.js +15 -5
  11. package/dist/agent/loop.js.map +1 -1
  12. package/dist/agent/providers/claude-code.js +1 -212
  13. package/dist/agent/providers/claude-code.js.map +1 -1
  14. package/dist/agent/providers/cursor.js +317 -0
  15. package/dist/agent/providers/cursor.js.map +1 -0
  16. package/dist/agent/providers/tool-protocol.js +205 -0
  17. package/dist/agent/providers/tool-protocol.js.map +1 -0
  18. package/dist/agent/render.js +32 -15
  19. package/dist/agent/render.js.map +1 -1
  20. package/dist/agent/runmeta.js +4 -5
  21. package/dist/agent/runmeta.js.map +1 -1
  22. package/dist/agent/theme.js +84 -0
  23. package/dist/agent/theme.js.map +1 -0
  24. package/dist/cli.js +134 -13
  25. package/dist/cli.js.map +1 -1
  26. package/dist/commands/create.js +41 -32
  27. package/dist/commands/create.js.map +1 -1
  28. package/dist/commands/demo.js +146 -0
  29. package/dist/commands/demo.js.map +1 -0
  30. package/dist/commands/doctor.js +240 -0
  31. package/dist/commands/doctor.js.map +1 -0
  32. package/dist/commands/repl-inspect.js +342 -0
  33. package/dist/commands/repl-inspect.js.map +1 -0
  34. package/dist/commands/repl.js +618 -0
  35. package/dist/commands/repl.js.map +1 -0
  36. package/dist/config.js +5 -2
  37. package/dist/config.js.map +1 -1
  38. package/dist/kicad/cli.js +126 -6
  39. package/dist/kicad/cli.js.map +1 -1
  40. package/dist/util/cli-args.js +35 -0
  41. package/dist/util/cli-args.js.map +1 -0
  42. package/dist/util/dock.js +155 -0
  43. package/dist/util/dock.js.map +1 -0
  44. package/dist/util/git.js +129 -4
  45. package/dist/util/git.js.map +1 -1
  46. package/dist/util/live-prompt.js +542 -0
  47. package/dist/util/live-prompt.js.map +1 -0
  48. package/dist/util/paths.js +9 -0
  49. package/dist/util/paths.js.map +1 -1
  50. package/dist/util/select.js +172 -0
  51. package/dist/util/select.js.map +1 -0
  52. package/package.json +3 -2
  53. package/src/agent/animate.ts +90 -0
  54. package/src/agent/box.ts +99 -0
  55. package/src/agent/dock-renderer.ts +181 -0
  56. package/src/agent/logo.ts +23 -0
  57. package/src/agent/loop.ts +15 -5
  58. package/src/agent/providers/claude-code.ts +2 -216
  59. package/src/agent/providers/cursor.ts +364 -0
  60. package/src/agent/providers/tool-protocol.ts +212 -0
  61. package/src/agent/render.ts +33 -16
  62. package/src/agent/runmeta.ts +6 -7
  63. package/src/agent/theme.ts +91 -0
  64. package/src/cli.ts +139 -15
  65. package/src/commands/create.ts +81 -30
  66. package/src/commands/demo.ts +184 -0
  67. package/src/commands/doctor.ts +289 -0
  68. package/src/commands/repl-inspect.ts +353 -0
  69. package/src/commands/repl.ts +685 -0
  70. package/src/config.ts +6 -3
  71. package/src/kicad/cli.ts +132 -7
  72. package/src/layout/claude-ui-layout.md +72 -0
  73. package/src/layout/repl-ui-layout.md +139 -0
  74. package/src/util/cli-args.ts +42 -0
  75. package/src/util/dock.ts +161 -0
  76. package/src/util/git.ts +140 -4
  77. package/src/util/live-prompt.ts +595 -0
  78. package/src/util/paths.ts +10 -0
  79. package/src/util/select.ts +192 -0
@@ -0,0 +1,289 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { existsSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { DEFAULTS, loadConfig, resolveModel, type CopperheadConfig } from '../config.js';
6
+ import { kicadCliVersion } from '../kicad/cli.js';
7
+ import { redactSecrets } from '../util/redact.js';
8
+
9
+ const execFileP = promisify(execFile);
10
+
11
+ /**
12
+ * `copperhead doctor` (env preflight): a fast, LLM-free, network-free check of
13
+ * whether this machine can actually run a copperhead command — the gap `check`
14
+ * leaves (it verifies kicad-cli but is contractually LLM-free, so it never looks
15
+ * at the model provider). Each probe fails soft: a missing tool is a reported
16
+ * `fail`, never a thrown error, so `doctor` still prints the rest of the report.
17
+ */
18
+ export type DoctorStatus = 'ok' | 'fail' | 'info';
19
+
20
+ export interface DoctorCheck {
21
+ name: string;
22
+ status: DoctorStatus;
23
+ detail: string;
24
+ hint?: string;
25
+ }
26
+
27
+ export interface DoctorReport {
28
+ /** true when no *critical* check failed (info-only checks never block). */
29
+ ok: boolean;
30
+ checks: DoctorCheck[];
31
+ }
32
+
33
+ /** Probes are injectable so tests never depend on the host's tools. */
34
+ export interface DoctorDeps {
35
+ nodeVersion: string;
36
+ kicadVersion: () => Promise<string>;
37
+ gitVersion: () => Promise<string>;
38
+ env: NodeJS.ProcessEnv;
39
+ }
40
+
41
+ function defaultDeps(): DoctorDeps {
42
+ return {
43
+ nodeVersion: process.version,
44
+ kicadVersion: kicadCliVersion,
45
+ // `git --version` prints "git version 2.34.1"; keep only the number, the
46
+ // report already labels the row "git".
47
+ gitVersion: async () => (await execFileP('git', ['--version'])).stdout.trim().replace(/^git version\s+/, ''),
48
+ env: process.env,
49
+ };
50
+ }
51
+
52
+ const MIN_NODE_MAJOR = 20; // package.json engines: ">=20"
53
+
54
+ function nodeCheck(version: string): DoctorCheck {
55
+ const major = Number(version.replace(/^v/, '').split('.')[0]);
56
+ if (Number.isFinite(major) && major >= MIN_NODE_MAJOR) {
57
+ return { name: 'node', status: 'ok', detail: `${version} (>= ${MIN_NODE_MAJOR})` };
58
+ }
59
+ return {
60
+ name: 'node',
61
+ status: 'fail',
62
+ detail: `${version} (< ${MIN_NODE_MAJOR})`,
63
+ hint: `copperhead needs Node >= ${MIN_NODE_MAJOR}; upgrade Node.`,
64
+ };
65
+ }
66
+
67
+ async function kicadCheck(probe: () => Promise<string>): Promise<DoctorCheck> {
68
+ try {
69
+ return { name: 'kicad-cli', status: 'ok', detail: await probe() };
70
+ } catch {
71
+ return {
72
+ name: 'kicad-cli',
73
+ status: 'fail',
74
+ detail: 'not found on PATH',
75
+ hint: 'install KiCad >= 9 (bundles kicad-cli); ERC/DRC gates need it.',
76
+ };
77
+ }
78
+ }
79
+
80
+ async function gitCheck(probe: () => Promise<string>): Promise<DoctorCheck> {
81
+ try {
82
+ return { name: 'git', status: 'ok', detail: await probe() };
83
+ } catch {
84
+ return {
85
+ name: 'git',
86
+ status: 'fail',
87
+ detail: 'not found on PATH',
88
+ hint: 'install git; copperhead snapshots and commits its work.',
89
+ };
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Map a resolved model to the credential its provider needs, mirroring
95
+ * makeProvider's prefix routing (agent/loop.ts). Presence-only: it checks that a
96
+ * required API key is set, never that it authenticates (that would need network).
97
+ * Saved-login providers (codex, claude-code) need no key and can't be verified
98
+ * offline, so they report `info` (which does not block `ok`).
99
+ */
100
+ export function checkCredential(model: string, env: NodeJS.ProcessEnv): DoctorCheck {
101
+ // A pasted API key can end up as the model value (--model sk-..., a stray
102
+ // COPPERHEAD_MODEL); redact it before it reaches the report, same policy as
103
+ // transcripts (AC-4.1). Routing below still uses the raw value.
104
+ const shown = redactSecrets(model);
105
+ const savedLogin: Record<string, string> = {
106
+ codex: 'uses local Codex login',
107
+ 'claude-code': 'uses Claude Code login',
108
+ cursor: 'uses Cursor Agent CLI login',
109
+ };
110
+ for (const [prefix, how] of Object.entries(savedLogin)) {
111
+ if (model === prefix || model.startsWith(`${prefix}:`)) {
112
+ // makeProvider rejects an empty override; a real run would fail here.
113
+ if (model === `${prefix}:`) {
114
+ return {
115
+ name: 'provider',
116
+ status: 'fail',
117
+ detail: `${shown} -> ${prefix}: empty model override`,
118
+ hint: `use "${prefix}" or "${prefix}:<model-id>".`,
119
+ };
120
+ }
121
+ return {
122
+ name: 'provider',
123
+ status: 'info',
124
+ detail: `${shown} -> ${prefix}: ${how} (not verified offline)`,
125
+ };
126
+ }
127
+ }
128
+ if (model === 'claude' || model.startsWith('claude')) {
129
+ return env.ANTHROPIC_API_KEY
130
+ ? { name: 'provider', status: 'ok', detail: `${shown} -> anthropic: ANTHROPIC_API_KEY set` }
131
+ : {
132
+ name: 'provider',
133
+ status: 'fail',
134
+ detail: `${shown} -> anthropic: ANTHROPIC_API_KEY not set`,
135
+ hint: 'export ANTHROPIC_API_KEY=... (or use --model claude-code for saved login).',
136
+ };
137
+ }
138
+ return env.OPENAI_API_KEY
139
+ ? { name: 'provider', status: 'ok', detail: `${shown} -> openai: OPENAI_API_KEY set` }
140
+ : {
141
+ name: 'provider',
142
+ status: 'fail',
143
+ detail: `${shown} -> openai: OPENAI_API_KEY not set`,
144
+ hint: 'export OPENAI_API_KEY=... (or use --model codex for saved login).',
145
+ };
146
+ }
147
+
148
+ function providerCheck(
149
+ flag: string | undefined,
150
+ config: Awaited<ReturnType<typeof loadConfig>>,
151
+ env: NodeJS.ProcessEnv,
152
+ ): DoctorCheck {
153
+ try {
154
+ const { model } = resolveModel(flag, config, env);
155
+ return checkCredential(model, env);
156
+ } catch (err) {
157
+ // resolveModel throws only when nothing selects a model at all. Its message
158
+ // starts with "no model configured: " — already this check's detail line —
159
+ // so keep only the remedy part for the hint.
160
+ return {
161
+ name: 'provider',
162
+ status: 'fail',
163
+ detail: 'no model configured',
164
+ hint: (err as Error).message.replace(/^no model configured:\s*/, ''),
165
+ };
166
+ }
167
+ }
168
+
169
+ function projectCheck(config: Awaited<ReturnType<typeof loadConfig>>, repoRoot: string): DoctorCheck {
170
+ const hasConfig = existsSync(path.join(repoRoot, '.copperhead', 'config.json'));
171
+ if (!hasConfig) {
172
+ return {
173
+ name: 'project',
174
+ status: 'info',
175
+ detail: 'no .copperhead/config.json (run `copperhead init` to scaffold)',
176
+ };
177
+ }
178
+ return {
179
+ name: 'project',
180
+ status: 'info',
181
+ detail: `schematic ${config.schematic ?? 'not wired'} · board ${config.board ?? 'not wired'}`,
182
+ };
183
+ }
184
+
185
+ export interface RunDoctorOptions {
186
+ repoRoot: string;
187
+ model?: string | undefined;
188
+ deps?: Partial<DoctorDeps>;
189
+ }
190
+
191
+ // Same shape loadConfig returns when no config file exists at all: a safe
192
+ // fallback so a corrupted config.json degrades the project check, not the
193
+ // whole command (resolveModel's config.model precedence level is simply
194
+ // unavailable; --model/COPPERHEAD_MODEL/an available key still resolve).
195
+ const FALLBACK_CONFIG: CopperheadConfig = { schematic: null, board: null, ...DEFAULTS };
196
+
197
+ export async function runDoctor(opts: RunDoctorOptions): Promise<DoctorReport> {
198
+ const deps = { ...defaultDeps(), ...opts.deps };
199
+ let config: CopperheadConfig;
200
+ let configError: DoctorCheck | undefined;
201
+ try {
202
+ config = await loadConfig(opts.repoRoot);
203
+ } catch (err) {
204
+ config = FALLBACK_CONFIG;
205
+ // JSON.parse throws a bare SyntaxError for bad content; readFile throws a
206
+ // coded Error (EACCES, EISDIR, ...) for a file that couldn't be read at
207
+ // all. The two need different advice: content is fixed by regenerating
208
+ // the file, unreadable is a permissions/filesystem problem regenerating
209
+ // it will not solve.
210
+ configError =
211
+ err instanceof SyntaxError
212
+ ? {
213
+ name: 'project',
214
+ status: 'fail',
215
+ detail: `.copperhead/config.json is malformed: ${err.message}`,
216
+ hint: 'fix or delete .copperhead/config.json (rerun `copperhead init`/`copperhead create` to regenerate it).',
217
+ }
218
+ : {
219
+ name: 'project',
220
+ status: 'fail',
221
+ detail: `.copperhead/config.json could not be read: ${(err as Error).message}`,
222
+ hint: 'check that it is a regular file (not a directory) and that you have permission to read it.',
223
+ };
224
+ }
225
+ const checks: DoctorCheck[] = [
226
+ nodeCheck(deps.nodeVersion),
227
+ await kicadCheck(deps.kicadVersion),
228
+ await gitCheck(deps.gitVersion),
229
+ providerCheck(opts.model, config, deps.env),
230
+ configError ?? projectCheck(config, opts.repoRoot),
231
+ ];
232
+ return { ok: checks.every((c) => c.status !== 'fail'), checks };
233
+ }
234
+
235
+ const TAG: Record<DoctorStatus, string> = { ok: '[ok]', fail: '[FAIL]', info: '[info]' };
236
+ const TAG_COL = 2; // leading indent
237
+ const NAME_COL = TAG_COL + 7; // widest tag "[FAIL]" + one space
238
+ const DETAIL_COL = NAME_COL + 10; // widest name "kicad-cli" + one space
239
+
240
+ // Plain ANSI, no color dependency: green/red/cyan tags, dim hints. Color is
241
+ // off by default; the CLI opts in only for a real TTY, so piped output and
242
+ // tests see plain text. Colored text is padded before painting — escape codes
243
+ // have zero display width but nonzero string length, so painting first would
244
+ // break the column math.
245
+ const ANSI: Record<DoctorStatus, string> = { ok: '32', fail: '31', info: '36' };
246
+ const DIM = '2';
247
+ function paint(text: string, code: string, on: boolean): string {
248
+ return on ? `\u001b[${code}m${text}\u001b[0m` : text;
249
+ }
250
+
251
+ function wrapWords(text: string, width: number): string[] {
252
+ const lines: string[] = [];
253
+ let line = '';
254
+ for (const word of text.split(' ')) {
255
+ if (line && line.length + 1 + word.length > width) {
256
+ lines.push(line);
257
+ line = word;
258
+ } else {
259
+ line = line ? `${line} ${word}` : word;
260
+ }
261
+ }
262
+ if (line) lines.push(line);
263
+ return lines;
264
+ }
265
+
266
+ /** Continuation lines land in the same column as the first, so wrapped text reads as one block. */
267
+ function pushWrapped(lines: string[], first: string, text: string, col: number, width: number): void {
268
+ const wrapped = wrapWords(text, Math.max(20, width - col));
269
+ lines.push(first + (wrapped[0] ?? ''));
270
+ for (const rest of wrapped.slice(1)) lines.push(' '.repeat(col) + rest);
271
+ }
272
+
273
+ export function formatDoctor(report: DoctorReport, width = 80, color = false): string[] {
274
+ const lines: string[] = [];
275
+ for (const c of report.checks) {
276
+ const tag = paint(TAG[c.status], ANSI[c.status], color) + ' '.repeat(NAME_COL - TAG_COL - TAG[c.status].length);
277
+ const head = ' '.repeat(TAG_COL) + tag + c.name.padEnd(DETAIL_COL - NAME_COL);
278
+ pushWrapped(lines, head, c.detail, DETAIL_COL, width);
279
+ if (c.hint) {
280
+ const start = lines.length;
281
+ pushWrapped(lines, `${' '.repeat(NAME_COL)}hint: `, c.hint, NAME_COL + 6, width);
282
+ for (let i = start; i < lines.length; i++) lines[i] = paint(lines[i]!, DIM, color);
283
+ }
284
+ }
285
+ lines.push(
286
+ report.ok ? paint('ready', ANSI.ok, color) : paint('not ready: fix the [FAIL] items above', ANSI.fail, color),
287
+ );
288
+ return lines;
289
+ }
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Read-only inspect helpers for REPL slash commands (/sync, /config, /runs…).
3
+ * All deterministic, no LLM.
4
+ */
5
+
6
+ import path from 'node:path';
7
+ import { existsSync } from 'node:fs';
8
+ import { readdir, readFile } from 'node:fs/promises';
9
+ import { execa } from 'execa';
10
+ import { loadConfig } from '../config.js';
11
+ import { checkDrift } from '../memory/drift.js';
12
+ import { loadConstraints } from '../memory/constraints.js';
13
+ import { parseBomTable } from '../memory/bom-table.js';
14
+ import { syncVerify, formatSyncReport } from './sync.js';
15
+ import { listSymbols, listNets, pinNets } from '../kicad/sexp.js';
16
+ import { openspecValidate } from '../openspec/cli.js';
17
+ import { copper, dim, ok, warn, err } from '../agent/theme.js';
18
+ import { traceRule } from '../agent/animate.js';
19
+ import { shortPath } from '../util/paths.js';
20
+ import { branchName, headCommit, isDirty, isGitRepo, uncommittedCount } from '../util/git.js';
21
+
22
+ function meta(label: string, value: string): string {
23
+ return ` ${dim(label.padEnd(12))} ${value}`;
24
+ }
25
+
26
+ async function sortedRunIds(repoRoot: string): Promise<string[]> {
27
+ const runsDir = path.join(repoRoot, '.copperhead', 'runs');
28
+ if (!existsSync(runsDir)) return [];
29
+ return (await readdir(runsDir, { withFileTypes: true }))
30
+ .filter((d) => d.isDirectory())
31
+ .map((d) => d.name)
32
+ .sort()
33
+ .reverse();
34
+ }
35
+
36
+ async function resolveSchematic(
37
+ repoRoot: string,
38
+ ): Promise<{ schPath: string } | { message: string }> {
39
+ const config = await loadConfig(repoRoot);
40
+ if (!config.schematic) {
41
+ return { message: 'no schematic configured (run copperhead init)' };
42
+ }
43
+ const schPath = path.join(repoRoot, config.schematic);
44
+ if (!existsSync(schPath)) {
45
+ return { message: `schematic missing: ${config.schematic}` };
46
+ }
47
+ return { schPath };
48
+ }
49
+
50
+ /** `/sync` — verify-only inconsistency report (never auto-resolves). */
51
+ export async function formatSyncInspect(repoRoot: string): Promise<string> {
52
+ const report = await syncVerify(repoRoot);
53
+ const body = formatSyncReport(report);
54
+ const headline =
55
+ !report.resolvable.length && !report.violations.length
56
+ ? ok(' sync: clean')
57
+ : report.violations.length
58
+ ? err(` sync: ${report.violations.length} requirement violation(s), ${report.resolvable.length} resolvable`)
59
+ : warn(` sync: ${report.resolvable.length} resolvable inconsistency(ies)`);
60
+ return ['', copper(' Sync'), traceRule(12), '', headline, '', ...body.split('\n').map((l) => ` ${l}`), ''].join(
61
+ '\n',
62
+ );
63
+ }
64
+
65
+ /** `/config` — full resolved config view. */
66
+ export async function formatConfigInspect(repoRoot: string): Promise<string> {
67
+ const c = await loadConfig(repoRoot);
68
+ const lines = [
69
+ '',
70
+ copper(' Config'),
71
+ traceRule(14),
72
+ '',
73
+ meta('schematic', c.schematic ?? dim('null')),
74
+ meta('board', c.board ?? dim('null')),
75
+ meta('docs', c.docs),
76
+ meta('model', c.model ?? dim('null (use --model / env)')),
77
+ meta('maxTurns', String(c.maxTurns)),
78
+ meta('repair', String(c.maxRepairCycles)),
79
+ meta('origin', c.origin ?? dim('unset')),
80
+ meta('llmCache', c.llmCache ? 'on' : 'off'),
81
+ meta('budgets', Object.keys(c.budgets).length ? JSON.stringify(c.budgets) : dim('{}')),
82
+ ];
83
+ if (c.stageMaxTurns && Object.keys(c.stageMaxTurns).length) {
84
+ lines.push(meta('stageTurns', JSON.stringify(c.stageMaxTurns)));
85
+ }
86
+ lines.push('');
87
+ lines.push(dim(` file ${shortPath(path.join(repoRoot, '.copperhead/config.json'))}`));
88
+ lines.push('');
89
+ return lines.join('\n');
90
+ }
91
+
92
+ /** `/git` — porcelain status + branch tip. */
93
+ export async function formatGitInspect(repoRoot: string): Promise<string> {
94
+ if (!(await isGitRepo(repoRoot))) {
95
+ return ['', warn(' git: not a repository'), ''].join('\n');
96
+ }
97
+ const [branch, commit, dirty, n, porcelain] = await Promise.all([
98
+ branchName(repoRoot).catch(() => 'unknown'),
99
+ headCommit(repoRoot).catch(() => 'unknown'),
100
+ isDirty(repoRoot).catch(() => null),
101
+ uncommittedCount(repoRoot).catch(() => null),
102
+ execa('git', ['status', '--porcelain'], { cwd: repoRoot }).then((r) =>
103
+ r.stdout ? r.stdout.split('\n').filter(Boolean) : [],
104
+ ),
105
+ ]);
106
+ const state =
107
+ dirty === null ? 'unknown' : dirty ? warn(`dirty (${n ?? '?'} file(s))`) : ok('clean');
108
+ const lines = [
109
+ '',
110
+ copper(' Git'),
111
+ traceRule(10),
112
+ '',
113
+ meta('branch', `${branch}@${commit.slice(0, 7)}`),
114
+ meta('state', state),
115
+ '',
116
+ ];
117
+ if (porcelain.length) {
118
+ lines.push(dim(' changes:'));
119
+ for (const row of porcelain.slice(0, 40)) lines.push(` ${row}`);
120
+ if (porcelain.length > 40) lines.push(dim(` … and ${porcelain.length - 40} more`));
121
+ lines.push('');
122
+ }
123
+ return lines.join('\n');
124
+ }
125
+
126
+ /** `/drift` — doc↔schematic only (no ERC/DRC). */
127
+ export async function formatDriftInspect(repoRoot: string): Promise<string> {
128
+ const config = await loadConfig(repoRoot);
129
+ if (!config.schematic) {
130
+ return ['', warn(' drift: no schematic configured (run copperhead init)'), ''].join('\n');
131
+ }
132
+ const mismatches = await checkDrift(repoRoot, config.docs, config.schematic);
133
+ if (!mismatches.length) {
134
+ return ['', ok(' drift: docs match schematic'), ''].join('\n');
135
+ }
136
+ const lines = [
137
+ '',
138
+ copper(' Drift'),
139
+ traceRule(12),
140
+ '',
141
+ warn(` ${mismatches.length} mismatch(es):`),
142
+ '',
143
+ ];
144
+ for (const m of mismatches.slice(0, 30)) {
145
+ lines.push(` ${copper('▸')} ${m.doc}`);
146
+ lines.push(dim(` claim ${m.claim}`));
147
+ lines.push(dim(` actual ${m.actual}`));
148
+ }
149
+ if (mismatches.length > 30) lines.push(dim(` … and ${mismatches.length - 30} more`));
150
+ lines.push('');
151
+ return lines.join('\n');
152
+ }
153
+
154
+ /** `/constraints` — registry summary. */
155
+ export async function formatConstraintsInspect(repoRoot: string): Promise<string> {
156
+ const registry = await loadConstraints(repoRoot);
157
+ const keys = Object.keys(registry).sort();
158
+ if (!keys.length) {
159
+ return ['', dim(' constraints: none recorded yet'), ''].join('\n');
160
+ }
161
+ const lines = [
162
+ '',
163
+ copper(' Constraints'),
164
+ traceRule(20),
165
+ '',
166
+ dim(` ${keys.length} open constraint(s)`),
167
+ '',
168
+ ];
169
+ for (const key of keys.slice(0, 40)) {
170
+ const c = registry[key]!;
171
+ const bound =
172
+ c.value !== undefined
173
+ ? String(c.value)
174
+ : [c.min !== undefined ? `min ${c.min}` : '', c.max !== undefined ? `max ${c.max}` : '']
175
+ .filter(Boolean)
176
+ .join(', ') || (c.forbidden?.length ? `forbidden ${c.forbidden.join(',')}` : '—');
177
+ lines.push(` ${copper(key)} ${bound}`);
178
+ lines.push(dim(` source ${c.source} · affects ${c.affects.join(', ') || '—'}`));
179
+ }
180
+ if (keys.length > 40) lines.push(dim(` … and ${keys.length - 40} more`));
181
+ lines.push('');
182
+ return lines.join('\n');
183
+ }
184
+
185
+ /** `/runs` — recent `.copperhead/runs/` summaries. */
186
+ export async function formatRunsInspect(repoRoot: string): Promise<string> {
187
+ const runsDir = path.join(repoRoot, '.copperhead', 'runs');
188
+ const entries = await sortedRunIds(repoRoot);
189
+ if (!entries.length) {
190
+ return ['', dim(' runs: none yet'), ''].join('\n');
191
+ }
192
+
193
+ const lines = ['', copper(' Recent runs'), traceRule(18), ''];
194
+ for (const id of entries.slice(0, 12)) {
195
+ const summaryPath = path.join(runsDir, id, 'summary.md');
196
+ const reportPath = path.join(runsDir, id, 'REPORT.md');
197
+ let outcome = dim('unknown');
198
+ if (existsSync(summaryPath)) {
199
+ const text = await readFile(summaryPath, 'utf8');
200
+ const m =
201
+ text.match(/exitPath[:\s*`]+([a-z-]+)/i) ||
202
+ text.match(/\*\*Exit(?:\s*path)?:\*\*\s*`?([a-z-]+)/i) ||
203
+ text.match(/## Run stats[\s\S]*?`([a-z-]+)`/);
204
+ if (m?.[1]) {
205
+ const pathName = m[1];
206
+ outcome =
207
+ pathName === 'done' ? ok(pathName) : /fail|error|refus|exhaust|stall/i.test(pathName) ? err(pathName) : warn(pathName);
208
+ } else if (/## Run stats/i.test(text)) {
209
+ outcome = dim('recorded');
210
+ }
211
+ }
212
+ const extras = existsSync(reportPath) ? dim(' · report') : '';
213
+ lines.push(` ${copper(id)} ${outcome}${extras}`);
214
+ }
215
+ if (entries.length > 12) lines.push(dim(` … and ${entries.length - 12} older`));
216
+ lines.push('');
217
+ lines.push(dim(` dir ${shortPath(runsDir)}`));
218
+ lines.push('');
219
+ return lines.join('\n');
220
+ }
221
+
222
+ /** `/parts` — schematic symbol table (refdes / value / footprint). */
223
+ export async function formatPartsInspect(repoRoot: string): Promise<string> {
224
+ const resolved = await resolveSchematic(repoRoot);
225
+ if ('message' in resolved) {
226
+ return ['', warn(` parts: ${resolved.message}`), ''].join('\n');
227
+ }
228
+ const syms = await listSymbols(resolved.schPath);
229
+ if (!syms.length) {
230
+ return ['', dim(' parts: schematic has no components'), ''].join('\n');
231
+ }
232
+ const refW = Math.max(4, ...syms.map((s) => s.ref.length));
233
+ const valW = Math.min(24, Math.max(5, ...syms.map((s) => s.value.length)));
234
+ const lines = [
235
+ '',
236
+ copper(' Parts'),
237
+ traceRule(12),
238
+ '',
239
+ dim(` ${'ref'.padEnd(refW)} ${'value'.padEnd(valW)} footprint`),
240
+ '',
241
+ ];
242
+ for (const s of syms.slice(0, 60)) {
243
+ const val = s.value.length > valW ? s.value.slice(0, valW - 1) + '…' : s.value;
244
+ lines.push(` ${copper(s.ref.padEnd(refW))} ${val.padEnd(valW)} ${dim(s.footprint || '—')}`);
245
+ }
246
+ if (syms.length > 60) lines.push(dim(` … and ${syms.length - 60} more`));
247
+ lines.push('');
248
+ return lines.join('\n');
249
+ }
250
+
251
+ /** `/nets` — net names and pin attachments from the schematic. */
252
+ export async function formatNetsInspect(repoRoot: string): Promise<string> {
253
+ const resolved = await resolveSchematic(repoRoot);
254
+ if ('message' in resolved) {
255
+ return ['', warn(` nets: ${resolved.message}`), ''].join('\n');
256
+ }
257
+ const [names, pins] = await Promise.all([listNets(resolved.schPath), pinNets(resolved.schPath)]);
258
+ const byNet = new Map<string, string[]>();
259
+ for (const p of pins) {
260
+ const net = p.net ?? '(unconnected)';
261
+ const tag = p.pinName ? `${p.ref}.${p.pinName}` : `${p.ref}:${p.pinNumber}`;
262
+ const list = byNet.get(net) ?? [];
263
+ list.push(tag);
264
+ byNet.set(net, list);
265
+ }
266
+ const ordered = [...new Set([...names, ...byNet.keys()])].sort((a, b) => {
267
+ if (a === '(unconnected)') return 1;
268
+ if (b === '(unconnected)') return -1;
269
+ return a.localeCompare(b);
270
+ });
271
+ const lines = ['', copper(' Nets'), traceRule(10), '', dim(` ${ordered.length} net(s)`), ''];
272
+ for (const net of ordered.slice(0, 40)) {
273
+ const attached = (byNet.get(net) ?? []).sort();
274
+ const preview = attached.slice(0, 8).join(', ');
275
+ const extra = attached.length > 8 ? dim(` +${attached.length - 8}`) : '';
276
+ lines.push(` ${copper(net)} ${dim(preview)}${extra}`);
277
+ }
278
+ if (ordered.length > 40) lines.push(dim(` … and ${ordered.length - 40} more`));
279
+ lines.push('');
280
+ return lines.join('\n');
281
+ }
282
+
283
+ /** `/bom` — parsed docs/BOM.md (inspect only; no export). */
284
+ export async function formatBomInspect(repoRoot: string): Promise<string> {
285
+ const config = await loadConfig(repoRoot);
286
+ const bomPath = path.join(repoRoot, config.docs, 'BOM.md');
287
+ if (!existsSync(bomPath)) {
288
+ return ['', warn(' bom: docs/BOM.md not found'), ''].join('\n');
289
+ }
290
+ const rows = parseBomTable(await readFile(bomPath, 'utf8'));
291
+ if (!rows.length) {
292
+ return ['', dim(' bom: no parseable rows in BOM.md'), ''].join('\n');
293
+ }
294
+ const refW = Math.max(5, ...rows.map((r) => r.refdes.length));
295
+ const lines = ['', copper(' BOM'), traceRule(8), '', dim(` ${rows.length} row(s) from ${shortPath(bomPath)}`), ''];
296
+ for (const r of rows.slice(0, 50)) {
297
+ const flags = r.flags.length ? dim(` [${r.flags.join(',')}]`) : '';
298
+ lines.push(
299
+ ` ${copper(r.refdes.padEnd(refW))} ${r.value ?? '—'} ${dim(r.footprint ?? '')}${flags}`,
300
+ );
301
+ if (r.mpn) lines.push(dim(` mpn ${r.mpn}`));
302
+ }
303
+ if (rows.length > 50) lines.push(dim(` … and ${rows.length - 50} more`));
304
+ lines.push('');
305
+ return lines.join('\n');
306
+ }
307
+
308
+ /** `/openspec` — `openspec validate` summary (same gate as check). */
309
+ export async function formatOpenSpecInspect(repoRoot: string): Promise<string> {
310
+ const cfgPath = path.join(repoRoot, 'openspec', 'config.yaml');
311
+ if (!existsSync(cfgPath)) {
312
+ return ['', dim(' openspec: no openspec/config.yaml in this repo'), ''].join('\n');
313
+ }
314
+ const res = await openspecValidate(repoRoot);
315
+ const headline = res.ok ? ok(' openspec: valid') : err(' openspec: validation failed');
316
+ const body = res.output
317
+ .trim()
318
+ .split('\n')
319
+ .slice(0, 30)
320
+ .map((l) => ` ${l}`)
321
+ .join('\n');
322
+ const lines = ['', copper(' OpenSpec'), traceRule(16), '', headline, ''];
323
+ if (body) lines.push(body, '');
324
+ return lines.join('\n');
325
+ }
326
+
327
+ /** `/last` — preview the newest run summary.md. */
328
+ export async function formatLastInspect(repoRoot: string): Promise<string> {
329
+ const entries = await sortedRunIds(repoRoot);
330
+ if (!entries.length) {
331
+ return ['', dim(' last: no runs yet'), ''].join('\n');
332
+ }
333
+ const id = entries[0]!;
334
+ const runsDir = path.join(repoRoot, '.copperhead', 'runs');
335
+ const summaryPath = path.join(runsDir, id, 'summary.md');
336
+ const transcriptPath = path.join(runsDir, id, 'transcript.jsonl');
337
+ const lines = ['', copper(' Last run'), traceRule(14), '', meta('id', id), ''];
338
+ if (!existsSync(summaryPath)) {
339
+ lines.push(dim(' summary.md missing'), '');
340
+ return lines.join('\n');
341
+ }
342
+ const text = await readFile(summaryPath, 'utf8');
343
+ const preview = text.split('\n').slice(0, 40);
344
+ lines.push(dim(` ${shortPath(summaryPath)}`));
345
+ if (existsSync(transcriptPath)) {
346
+ lines.push(dim(` ${shortPath(transcriptPath)}`));
347
+ }
348
+ lines.push('');
349
+ for (const line of preview) lines.push(` ${line}`);
350
+ if (text.split('\n').length > 40) lines.push(dim(' … truncated'));
351
+ lines.push('');
352
+ return lines.join('\n');
353
+ }