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
@@ -2,11 +2,25 @@ import { existsSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import type { Msg, Provider } from './types.js';
5
+ import { resolveLibrarySymbol, searchInstalledSymbols, symbolSearchDirs, listInstalledLibraries } from '../kicad/symlib.js';
5
6
 
6
- /** Thrown when a single provider turn blows past its watchdog deadline. */
7
+ /**
8
+ * How many times a hung provider turn is retried before the run gives up. One
9
+ * policy, two turn loops: the main agent loop and the nested skill sub-run.
10
+ */
11
+ export const MAX_TURN_TIMEOUTS = 3;
12
+
13
+ /**
14
+ * Thrown when a provider turn trips its watchdog. `idle`: no response or
15
+ * progress for `ms`, so the call looks hung. `max`: still running at the hard
16
+ * cap however much progress it reported, so the turn is too large, not hung.
17
+ */
7
18
  export class TurnTimeoutError extends Error {
8
- constructor(public readonly ms: number) {
9
- super(`turn exceeded ${ms}ms without responding`);
19
+ constructor(
20
+ public readonly ms: number,
21
+ public readonly kind: 'idle' | 'max' = 'idle',
22
+ ) {
23
+ super(kind === 'max' ? `turn still running after ${ms}ms (hard cap)` : `turn exceeded ${ms}ms without responding`);
10
24
  this.name = 'TurnTimeoutError';
11
25
  }
12
26
  }
@@ -23,18 +37,81 @@ export async function withTimeout<T>(
23
37
  ms: number,
24
38
  onTimeout?: () => void | Promise<void>,
25
39
  ): Promise<T> {
26
- if (!Number.isFinite(ms) || ms <= 0) return fn();
27
- let timer: ReturnType<typeof setTimeout> | undefined;
40
+ return withWatchdog(() => fn(), { idleMs: ms, ...(onTimeout ? { onTimeout } : {}) });
41
+ }
42
+
43
+ /**
44
+ * The turn watchdog: `withTimeout` with the deadline measured from the last sign
45
+ * of progress instead of from the start. `fn` receives an `activity` callback and
46
+ * each call restarts the `idleMs` deadline, so a long turn that keeps producing
47
+ * output is never mistaken for a hung one, while a call that reports nothing gets
48
+ * `idleMs` as a plain whole-call deadline (unchanged for providers that cannot
49
+ * stream). `maxMs` is a hard cap activity does not extend, so a turn that streams
50
+ * forever still ends. The cap bounds a call that is producing output, so it only
51
+ * trips once `fn` has reported progress (a silent call is judged by `idleMs`
52
+ * alone, exactly as before the cap existed), and it is never shorter than
53
+ * `idleMs` (a lower cap would cut off a turn the idle deadline alone allows).
54
+ * `<= 0` (or non-finite) disables either limit; with both disabled this just
55
+ * awaits `fn`.
56
+ */
57
+ export async function withWatchdog<T>(
58
+ fn: (activity: () => void) => Promise<T>,
59
+ opts: { idleMs: number; maxMs?: number; onTimeout?: () => void | Promise<void> },
60
+ ): Promise<T> {
61
+ const idleOn = Number.isFinite(opts.idleMs) && opts.idleMs > 0;
62
+ const maxMs = opts.maxMs ?? 0;
63
+ const maxOn = Number.isFinite(maxMs) && maxMs > 0;
64
+ if (!idleOn && !maxOn) return fn(() => {});
65
+ const capMs = idleOn ? Math.max(maxMs, opts.idleMs) : maxMs;
66
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
67
+ let maxTimer: ReturnType<typeof setTimeout> | undefined;
68
+ // Set once the race is decided either way, so a late activity() from an
69
+ // abandoned call cannot re-arm a timer after the watchdog is done.
70
+ let settled = false;
71
+ // Whether fn has reported progress yet, and whether the cap came due while it
72
+ // had not (see the cap timer below).
73
+ let progressed = false;
74
+ let capPassed = false;
75
+ let trip!: (err: TurnTimeoutError) => void;
28
76
  const timeout = new Promise<never>((_, reject) => {
29
- timer = setTimeout(() => {
30
- void Promise.resolve(onTimeout?.()).catch(() => {});
31
- reject(new TurnTimeoutError(ms));
32
- }, ms);
77
+ trip = (err) => {
78
+ if (settled) return;
79
+ settled = true;
80
+ clearTimeout(idleTimer);
81
+ clearTimeout(maxTimer);
82
+ // Reject before tearing down: onTimeout (provider.close()) may reject the
83
+ // in-flight call synchronously, and the race must settle on the timeout,
84
+ // not on that teardown error, or the caller never sees TurnTimeoutError.
85
+ reject(err);
86
+ void Promise.resolve(opts.onTimeout?.()).catch(() => {});
87
+ };
33
88
  });
89
+ const armIdle = (): void => {
90
+ clearTimeout(idleTimer);
91
+ idleTimer = setTimeout(() => trip(new TurnTimeoutError(opts.idleMs, 'idle')), opts.idleMs);
92
+ };
93
+ const activity = (): void => {
94
+ if (settled) return;
95
+ progressed = true;
96
+ if (capPassed) return trip(new TurnTimeoutError(capMs, 'max'));
97
+ if (idleOn) armIdle();
98
+ };
99
+ if (idleOn) armIdle();
100
+ if (maxOn) {
101
+ maxTimer = setTimeout(() => {
102
+ // The cap bounds a turn that is producing output. A call that has reported
103
+ // nothing yet stays under the idle deadline alone; if it starts reporting
104
+ // progress later, the cap applies at that moment.
105
+ if (progressed) trip(new TurnTimeoutError(capMs, 'max'));
106
+ else capPassed = true;
107
+ }, capMs);
108
+ }
34
109
  try {
35
- return await Promise.race([fn(), timeout]);
110
+ return await Promise.race([fn(activity), timeout]);
36
111
  } finally {
37
- if (timer) clearTimeout(timer);
112
+ settled = true;
113
+ clearTimeout(idleTimer);
114
+ clearTimeout(maxTimer);
38
115
  }
39
116
  }
40
117
 
@@ -116,6 +193,91 @@ export async function transcriptExcerpt(transcriptDir: string, maxChars = 4000):
116
193
  return joined.length > maxChars ? joined.slice(joined.length - maxChars) : joined;
117
194
  }
118
195
 
196
+ /**
197
+ * Deterministically re-probe every lib_id named in a failure narrative against
198
+ * the installed libraries, so the diagnostician judges symbol-availability
199
+ * claims from machine facts instead of the agent's prose. An agent that has
200
+ * been dead-ended by wrong library nicknames concludes — and records — that
201
+ * whole libraries are absent when they are installed; a refusal built on that
202
+ * premise reads exactly like a genuine environmental gap, and the one thing
203
+ * that distinguishes them is re-checking the named lib_ids, which costs no
204
+ * LLM turn. Never throws; on any probe error it reports what it could.
205
+ */
206
+ export async function symbolAvailabilityFacts(text: string, dirs?: string[], cap = 8): Promise<string> {
207
+ const ids: string[] = [];
208
+ // A library nickname is its `.kicad_sym` filename stem, so it can carry `-`
209
+ // and `.` as well as `_` (`Custom-Parts`, `MyCorp.RF`) — a nickname the regex
210
+ // truncates is probed as the wrong lib_id and reported absent, which is the
211
+ // false negative this whole fact block exists to prevent. Separators are
212
+ // interior only, so a trailing sentence period is not swallowed.
213
+ for (const m of text.matchAll(/\b([A-Za-z0-9_](?:[A-Za-z0-9_.-]*[A-Za-z0-9_])?):([A-Za-z0-9][A-Za-z0-9_.+-]*)/g)) {
214
+ const lib = m[1]!;
215
+ const name = m[2]!;
216
+ // Require letters on both sides: drops file:line refs ("create.ts:311"),
217
+ // times and bare numbers. Engine-generated power symbols are not library
218
+ // facts.
219
+ if (!/[A-Za-z]/.test(lib) || !/[A-Za-z]/.test(name) || lib === 'copperhead_power') continue;
220
+ const libId = `${lib}:${name}`;
221
+ if (!ids.includes(libId)) ids.push(libId);
222
+ }
223
+ if (!ids.length) return '';
224
+ // Collection is unbounded but probing is capped, so a probe-heavy transcript
225
+ // stays cheap. The overflow is named rather than dropped: the supervisor is
226
+ // told these facts are ground truth, and silently probing 8 of 30 lib_ids
227
+ // would let it read "unprobed" as "absent".
228
+ const probed = ids.slice(0, cap);
229
+ const unprobed = ids.slice(cap);
230
+ let searchDirs: string[];
231
+ try {
232
+ searchDirs = dirs ?? (await symbolSearchDirs());
233
+ } catch {
234
+ return '';
235
+ }
236
+ if (!searchDirs.length) return '';
237
+ // A directory with no readable library means nothing was checked: emitting
238
+ // "not installed" lines as ground truth from that state is the exact false
239
+ // absence this block exists to prevent (same guard as bomSymbolDossier).
240
+ if (!(await listInstalledLibraries(searchDirs)).size) return '';
241
+ const lines: string[] = [];
242
+ for (const libId of probed) {
243
+ const name = libId.slice(libId.indexOf(':') + 1);
244
+ try {
245
+ const r = await resolveLibrarySymbol(libId, searchDirs);
246
+ if (r.status === 'ok') {
247
+ lines.push(`- ${libId}: RESOLVES on this machine (${r.pins.length} pins)`);
248
+ } else if (r.status === 'found-elsewhere') {
249
+ // The resolver already located the part under another lib_id; saying
250
+ // "not installed" here would be the exact false absence claim this
251
+ // block exists to prevent.
252
+ lines.push(`- ${libId}: not at that lib_id, but installed as: ${r.libIds.slice(0, 4).join(', ')}`);
253
+ } else {
254
+ const elsewhere = await searchInstalledSymbols(name, searchDirs, 4);
255
+ const inThat =
256
+ r.status === 'no-symbol' && r.candidates.length
257
+ ? ` (closest in that library: ${r.candidates.slice(0, 4).join(', ')})`
258
+ : '';
259
+ const where = elsewhere.length
260
+ ? `; installed as: ${elsewhere.join(', ')}`
261
+ : `; no installed symbol matches "${name}" in any library`;
262
+ lines.push(
263
+ r.status === 'no-symbol'
264
+ ? `- ${libId}: not in that library${inThat}${where}`
265
+ : `- ${libId}: no library of that nickname is installed${where}`,
266
+ );
267
+ }
268
+ } catch {
269
+ // a single unreadable library must not sink the fact block
270
+ }
271
+ }
272
+ if (!lines.length) return '';
273
+ if (unprobed.length) {
274
+ lines.push(
275
+ `- NOT RE-PROBED (probe limit ${cap}): ${unprobed.join(', ')} — these were named in the text but not checked, so nothing above says whether they exist.`,
276
+ );
277
+ }
278
+ return lines.join('\n');
279
+ }
280
+
119
281
  /**
120
282
  * Ask the model whether a failed/incomplete stage is worth retrying, and if so
121
283
  * how. Uses a fresh, tool-less provider turn (the same saved-login backend the
@@ -132,6 +294,9 @@ export async function diagnoseStageFailure(
132
294
  excerpt: string;
133
295
  attempt: number;
134
296
  maxAttempts: number;
297
+ /** Deterministic re-probe results for lib_ids named in the failure/excerpt
298
+ * (`symbolAvailabilityFacts`); authoritative over the transcript's claims. */
299
+ symbolFacts?: string;
135
300
  },
136
301
  ): Promise<StageDiagnosis> {
137
302
  const system =
@@ -145,10 +310,15 @@ export async function diagnoseStageFailure(
145
310
  `Failure: ${input.failure}\n` +
146
311
  `This was attempt ${input.attempt} of ${input.maxAttempts}.\n\n` +
147
312
  `Recent transcript (most recent last):\n${input.excerpt}\n\n` +
313
+ (input.symbolFacts
314
+ ? `Machine-verified symbol facts — a deterministic re-probe of the lib_ids named above, run just now against this machine's installed KiCad libraries. Each line reported below is ground truth and overrides anything the transcript claims about that symbol's availability. Coverage may be partial: a lib_id listed as NOT RE-PROBED, or absent from this block entirely, is unknown, never confirmed absent.\n${input.symbolFacts}\n\n`
315
+ : '') +
148
316
  'Reply with ONLY a JSON object, no prose:\n' +
149
317
  '{"verdict":"retry"|"abort","reason":"<one sentence>","guidance":"<if retry: concrete, specific instructions to prepend to the next attempt so it avoids this failure; otherwise empty>"}\n' +
150
- '- "retry" if the failure looks transient or fixable with clearer instructions (a dropped or locked tool call, an empty/no-op edit, a skipped step, a timeout, a formatting slip).\n' +
151
- '- "abort" if repeating the same attempt will not help and a human should look (missing inputs, a genuine dead-end, or the same failure already seen on a prior attempt).';
318
+ '- "retry" if the failure looks transient or fixable with clearer instructions (a dropped or locked tool call, an empty/no-op edit, a skipped step, a hung provider call that timed out, a formatting slip).\n' +
319
+ '- a turn stopped at the hard time cap while still producing output (turnMaxMs, "too large, not hung") is NOT transient: repeating it hits the cap again. Retry only with guidance that splits that work into several smaller edits (e.g. one part or one sheet section per edit).\n' +
320
+ '- "abort" if repeating the same attempt will not help and a human should look (missing inputs, a genuine dead-end, or the same failure already seen on a prior attempt).\n' +
321
+ '- an agent\'s claim that a symbol or library is absent is NOT evidence: agents dead-ended by wrong library nicknames routinely conclude whole libraries are missing. If the machine-verified facts contradict the failure\'s premise (a cited-absent lib_id RESOLVES, or the part is installed under another library), the verdict is "retry", with guidance quoting the correct lib_ids.';
152
322
  const messages: Msg[] = [
153
323
  { role: 'system', content: system },
154
324
  { role: 'user', content: user },
@@ -0,0 +1,58 @@
1
+ import { catalog } from '../capabilities/index.js';
2
+ import type { CatalogEntry, CatalogSkill } from '../capabilities/define.js';
3
+ import type { RunContext } from './context.js';
4
+ import { PROTOCOL_VERSION } from './envelope.js';
5
+
6
+ export { PROTOCOL_VERSION };
7
+
8
+ export class ToolRegistry {
9
+ private readonly byName = new Map<string, CatalogEntry>();
10
+
11
+ constructor(entries: CatalogEntry[]) {
12
+ for (const source of entries) {
13
+ const e: CatalogEntry = { ...source };
14
+ if (this.byName.has(e.name)) throw new Error(`duplicate catalog name "${e.name}"`);
15
+ this.byName.set(e.name, e);
16
+ }
17
+ for (const e of this.byName.values()) {
18
+ if (e.kind === 'skill' && !e.gateProvided) {
19
+ const conj = (ctx: RunContext) => this.conjunction(e.tools, ctx);
20
+ e.gate = conj;
21
+ }
22
+ }
23
+ }
24
+
25
+ get protocolVersion(): number {
26
+ return PROTOCOL_VERSION;
27
+ }
28
+
29
+ get(name: string): CatalogEntry | undefined {
30
+ return this.byName.get(name);
31
+ }
32
+
33
+ all(): CatalogEntry[] {
34
+ return [...this.byName.values()];
35
+ }
36
+
37
+ skills(): CatalogSkill[] {
38
+ return this.all().filter((e): e is CatalogSkill => e.kind === 'skill');
39
+ }
40
+
41
+ /** True when every named tool exists and its gate is open. */
42
+ conjunction(toolNames: string[], ctx: RunContext): boolean {
43
+ return toolNames.every((n) => {
44
+ const t = this.byName.get(n);
45
+ return t?.kind === 'tool' && t.gate(ctx);
46
+ });
47
+ }
48
+
49
+ /** Entries whose gate is open. Skills also require every declared tool's gate (D3). */
50
+ list(ctx: RunContext): CatalogEntry[] {
51
+ return this.all().filter((e) => {
52
+ if (e.kind === 'skill' && (e.tools.includes('finish') || !this.conjunction(e.tools, ctx))) return false;
53
+ return e.gate(ctx);
54
+ });
55
+ }
56
+ }
57
+
58
+ export const registry = new ToolRegistry(catalog);
@@ -9,12 +9,13 @@
9
9
  */
10
10
 
11
11
  import { copper, dim, setColorEnabled, styleOutcome, toolLine, warn } from './theme.js';
12
+ import type { ViewHint } from './envelope.js';
12
13
 
13
14
  export interface ProgressRenderer {
14
15
  log(line: string): void;
15
16
  /** Called at the start of each turn with cumulative token totals so far. */
16
17
  turnStart(turn: number, maxTurns: number, tokensIn: number, tokensOut: number): void;
17
- toolResult(name: string, firstLine: string): void;
18
+ toolResult(name: string, firstLine: string, ok?: boolean, viewHint?: ViewHint): void;
18
19
  /** Busy text while a provider call is in flight; null when idle. */
19
20
  status(text: string | null): void;
20
21
  /**
@@ -175,8 +176,8 @@ export class InteractiveRenderer implements ProgressRenderer {
175
176
  this.redraw();
176
177
  }
177
178
 
178
- toolResult(name: string, firstLine: string): void {
179
- this.log(toolLine(name, firstLine));
179
+ toolResult(name: string, firstLine: string, ok?: boolean, viewHint?: ViewHint): void {
180
+ this.log(toolLine(name, firstLine, ok, viewHint));
180
181
  }
181
182
 
182
183
  status(text: string | null): void {
@@ -59,11 +59,21 @@ export function stageLine(name: string, detail: string, kind: 'info' | 'ok' | 'w
59
59
  return `${label} ${body}`;
60
60
  }
61
61
 
62
- /** Tool-result scrollback line: short glyph + name + first line of result. */
63
- export function toolLine(name: string, firstLine: string): string {
64
- const clean = /\b(clean|ok|pass(?:ed)?|success|done)\b/i.test(firstLine) && !/\b(fail|error|violat)/i.test(firstLine);
65
- const glyph = clean ? ok('✓') : copper('▸');
66
- return ` ${glyph} ${dim(name)} ${firstLine}`;
62
+ /**
63
+ * Tool-result scrollback line: glyph from envelope `ok`, not a regex on prose.
64
+ * The envelope `viewHint` styles the name: mutations (they changed the repo)
65
+ * read copper, exports light copper, queries/diagnostics stay dim. Plain mode
66
+ * is unaffected: every painter no-ops while color is off.
67
+ */
68
+ export function toolLine(
69
+ name: string,
70
+ firstLine: string,
71
+ succeeded = false,
72
+ viewHint?: 'diagnostic' | 'mutation' | 'query' | 'export',
73
+ ): string {
74
+ const glyph = succeeded ? ok('✓') : copper('▸');
75
+ const label = viewHint === 'mutation' ? copper(name) : viewHint === 'export' ? copperLight(name) : dim(name);
76
+ return ` ${glyph} ${label} ${firstLine}`;
67
77
  }
68
78
 
69
79
  /** Color the final outcome line from its exit-path token. */