@towles/tool 0.0.127 → 0.0.128

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 (32) hide show
  1. package/node_modules/@towles/shared/src/errors.test.ts +33 -0
  2. package/node_modules/@towles/shared/src/errors.ts +14 -0
  3. package/node_modules/@towles/shared/src/index.ts +1 -0
  4. package/package.json +15 -15
  5. package/packages/agentboard/apps/tui/package.json +2 -2
  6. package/packages/agentboard/apps/tui/src/constants.ts +3 -1
  7. package/packages/agentboard/apps/tui/src/index.tsx +3 -1
  8. package/packages/agentboard/apps/tui/src/mux-context.ts +3 -1
  9. package/packages/agentboard/packages/runtime/src/agents/watchers/amp.ts +6 -2
  10. package/packages/agentboard/packages/runtime/src/agents/watchers/claude-code.ts +21 -7
  11. package/packages/agentboard/packages/runtime/src/agents/watchers/codex.ts +9 -3
  12. package/packages/agentboard/packages/runtime/src/agents/watchers/opencode.ts +18 -6
  13. package/packages/agentboard/packages/runtime/src/debug.ts +3 -1
  14. package/packages/agentboard/packages/runtime/src/server/index.ts +44 -14
  15. package/packages/agentboard/packages/runtime/src/server/launcher.ts +3 -1
  16. package/packages/agentboard/packages/runtime/src/server/pane-scanner.ts +7 -2
  17. package/packages/shared/src/errors.test.ts +33 -0
  18. package/packages/shared/src/errors.ts +14 -0
  19. package/packages/shared/src/index.ts +1 -0
  20. package/src/commands/agentboard.ts +3 -1
  21. package/src/commands/auto-claude/claude-cli.ts +15 -6
  22. package/src/commands/auto-claude/index.ts +21 -21
  23. package/src/commands/auto-claude/logger.ts +3 -0
  24. package/src/commands/auto-claude/pipeline.ts +12 -11
  25. package/src/commands/auto-claude/steps/create-pr.ts +8 -10
  26. package/src/commands/auto-claude/steps/fetch-issues.ts +8 -8
  27. package/src/commands/auto-claude/steps/implement.ts +7 -8
  28. package/src/commands/auto-claude/utils.ts +5 -8
  29. package/src/commands/graph/analyzer.test.ts +3 -1
  30. package/src/commands/graph.test.ts +2 -0
  31. package/src/commands/journal/list.ts +2 -1
  32. package/src/commands/journal/search.ts +3 -3
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { AppError } from "./errors";
3
+
4
+ describe("AppError", () => {
5
+ it("carries code and message", () => {
6
+ const err = new AppError("my_code", "boom");
7
+ expect(err.code).toBe("my_code");
8
+ expect(err.message).toBe("boom");
9
+ expect(err).toBeInstanceOf(Error);
10
+ expect(err).toBeInstanceOf(AppError);
11
+ });
12
+
13
+ it("uses the constructor name as the error name", () => {
14
+ class ChildError extends AppError {
15
+ constructor(message: string) {
16
+ super("child_code", message);
17
+ }
18
+ }
19
+ expect(new AppError("c", "m").name).toBe("AppError");
20
+ expect(new ChildError("m").name).toBe("ChildError");
21
+ });
22
+
23
+ it("forwards cause to the native Error.cause", () => {
24
+ const root = new Error("root");
25
+ const err = new AppError("wrapped", "outer", { cause: root });
26
+ expect(err.cause).toBe(root);
27
+ });
28
+
29
+ it("leaves cause undefined when not provided", () => {
30
+ const err = new AppError("nope", "no cause");
31
+ expect(err.cause).toBeUndefined();
32
+ });
33
+ });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Shared error base class for towles-tool.
3
+ *
4
+ * Uses the native ES2022 `Error.cause` option so stack traces chain properly.
5
+ */
6
+ export class AppError extends Error {
7
+ readonly code: string;
8
+
9
+ constructor(code: string, message: string, options?: { cause?: unknown }) {
10
+ super(message, options);
11
+ this.name = new.target.name;
12
+ this.code = code;
13
+ }
14
+ }
@@ -1,3 +1,4 @@
1
+ export { AppError } from "./errors.js";
1
2
  export { ensureDir, fileExists, readFile, writeFile } from "./fs.js";
2
3
  export { getTerminalColumns, limitText, printWithHexColor } from "./render.js";
3
4
  export { formatDate, generateJournalFilename, getMondayOfWeek, getWeekInfo } from "./date-utils.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@towles/tool",
3
- "version": "0.0.127",
3
+ "version": "0.0.128",
4
4
  "description": "One off quality of life scripts that I use on a daily basis.",
5
5
  "homepage": "https://github.com/ChrisTowles/towles-tool#readme",
6
6
  "bugs": {
@@ -50,32 +50,32 @@
50
50
  "prepare": "simple-git-hooks"
51
51
  },
52
52
  "dependencies": {
53
- "@anthropic-ai/claude-code": "^2.1.4",
54
- "@anthropic-ai/sdk": "^0.56.0",
53
+ "@anthropic-ai/claude-code": "^2.1.107",
54
+ "@anthropic-ai/sdk": "^0.88.0",
55
55
  "@towles/shared": "workspace:*",
56
- "citty": "^0.1.6",
56
+ "citty": "^0.2.2",
57
57
  "consola": "^3.4.2",
58
58
  "d3-hierarchy": "^3.1.2",
59
59
  "fzf": "^0.5.2",
60
- "luxon": "^3.7.1",
60
+ "luxon": "^3.7.2",
61
61
  "neverthrow": "^8.2.0",
62
62
  "picocolors": "^1.1.1",
63
63
  "prompts": "^2.4.2",
64
- "zod": "^4.0.5"
64
+ "zod": "^4.3.6"
65
65
  },
66
66
  "devDependencies": {
67
67
  "@types/bun": "latest",
68
68
  "@types/d3-hierarchy": "^3.1.7",
69
- "@types/luxon": "^3.6.2",
70
- "@types/node": "^22.16.3",
69
+ "@types/luxon": "^3.7.1",
70
+ "@types/node": "^25.6.0",
71
71
  "@types/prompts": "^2.4.9",
72
- "@typescript/native-preview": "^7.0.0-dev.20260111.1",
73
- "bumpp": "^10.4.0",
74
- "oxfmt": "^0.24.0",
75
- "oxlint": "^1.7.0",
76
- "simple-git-hooks": "^2.13.0",
77
- "typescript": "^5.8.3",
78
- "vitest": "^4.0.17"
72
+ "@typescript/native-preview": "^7.0.0-dev.20260414.1",
73
+ "bumpp": "^11.0.1",
74
+ "oxfmt": "^0.45.0",
75
+ "oxlint": "^1.60.0",
76
+ "simple-git-hooks": "^2.13.1",
77
+ "typescript": "^6.0.2",
78
+ "vitest": "^4.1.4"
79
79
  },
80
80
  "bundledDependencies": [
81
81
  "@towles/shared"
@@ -12,8 +12,8 @@
12
12
  "dependencies": {
13
13
  "@babel/core": "^7.28.0",
14
14
  "@babel/preset-typescript": "^7.27.1",
15
- "@opentui/core": "^0.1.88",
16
- "@opentui/solid": "^0.1.88",
15
+ "@opentui/core": "^0.1.99",
16
+ "@opentui/solid": "^0.1.99",
17
17
  "@tt-agentboard/mux-tmux": "workspace:*",
18
18
  "@tt-agentboard/runtime": "workspace:*",
19
19
  "babel-plugin-module-resolver": "^5.0.2",
@@ -32,5 +32,7 @@ export function logResizeDebug(message: string, data?: Record<string, unknown>):
32
32
  const extra = data ? ` ${JSON.stringify(data)}` : "";
33
33
  try {
34
34
  appendFileSync(TUI_RESIZE_LOG, `[${ts}] [pid:${process.pid}] ${message}${extra}\n`);
35
- } catch {}
35
+ } catch {
36
+ // intentionally ignored: debug log file write is best-effort
37
+ }
36
38
  }
@@ -360,7 +360,9 @@ function App() {
360
360
  if (startupFocusToPublish) {
361
361
  send({ type: "focus-session", name: startupFocusToPublish });
362
362
  }
363
- } catch {}
363
+ } catch {
364
+ // intentionally ignored: socket setup errors are non-fatal at startup
365
+ }
364
366
  };
365
367
 
366
368
  socket.onclose = () => {
@@ -34,7 +34,9 @@ export function refocusMainPane(muxCtx: MuxContext): void {
34
34
  const paneId = main.split(" ")[0];
35
35
  Bun.spawnSync(["tmux", "select-pane", "-t", paneId], { stdout: "pipe", stderr: "pipe" });
36
36
  }
37
- } catch {}
37
+ } catch {
38
+ // intentionally ignored: pane discovery is best-effort
39
+ }
38
40
  }
39
41
  }
40
42
 
@@ -129,13 +129,17 @@ export class AmpAgentWatcher implements AgentWatcher {
129
129
  if (this.fsWatcher) {
130
130
  try {
131
131
  this.fsWatcher.close();
132
- } catch {}
132
+ } catch {
133
+ // intentionally ignored: watcher may already be closed during shutdown
134
+ }
133
135
  this.fsWatcher = null;
134
136
  }
135
137
  if (this.sessionWatcher) {
136
138
  try {
137
139
  this.sessionWatcher.close();
138
- } catch {}
140
+ } catch {
141
+ // intentionally ignored: watcher may already be closed during shutdown
142
+ }
139
143
  this.sessionWatcher = null;
140
144
  }
141
145
  if (this.pollTimer) {
@@ -175,7 +175,9 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
175
175
  for (const w of this.fsWatchers) {
176
176
  try {
177
177
  w.close();
178
- } catch {}
178
+ } catch {
179
+ // intentionally ignored: watcher may already be closed during shutdown
180
+ }
179
181
  }
180
182
  this.fsWatchers = [];
181
183
  if (this.pollTimer) {
@@ -211,7 +213,9 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
211
213
  try {
212
214
  const mtime = (await stat(filePath)).mtimeMs;
213
215
  if (Date.now() - mtime > JOURNAL_IDLE_TIMEOUT_MS) becomeIdle = true;
214
- } catch {}
216
+ } catch {
217
+ // intentionally ignored: stat failure leaves becomeIdle unchanged
218
+ }
215
219
  }
216
220
 
217
221
  if (becomeIdle) {
@@ -271,7 +275,9 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
271
275
  try {
272
276
  const mtime = (await stat(filePath)).mtimeMs;
273
277
  if (Date.now() - mtime > JOURNAL_IDLE_TIMEOUT_MS) latestStatus = "idle";
274
- } catch {}
278
+ } catch {
279
+ // intentionally ignored: stat failure leaves status unchanged
280
+ }
275
281
  }
276
282
 
277
283
  this.sessions.set(threadId, {
@@ -448,7 +454,9 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
448
454
  });
449
455
  this.fsWatchers.push(w);
450
456
  this.watchedDirs.add(dirPath);
451
- } catch {}
457
+ } catch {
458
+ // intentionally ignored: watching dir is best-effort, will retry on next setup
459
+ }
452
460
  }
453
461
 
454
462
  private hasRecentFiles(dirPath: string): boolean {
@@ -461,9 +469,13 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
461
469
  try {
462
470
  const s = fs.statSync(join(dirPath, file));
463
471
  if (now - s.mtimeMs < STALE_MS) return true;
464
- } catch {}
472
+ } catch {
473
+ // intentionally ignored: skip unreadable file
474
+ }
465
475
  }
466
- } catch {}
476
+ } catch {
477
+ // intentionally ignored: dir unreadable, treat as no recent files
478
+ }
467
479
  return false;
468
480
  }
469
481
 
@@ -503,6 +515,8 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
503
515
  this.watchDir(dirPath);
504
516
  });
505
517
  this.fsWatchers.push(w);
506
- } catch {}
518
+ } catch {
519
+ // intentionally ignored: top-level watch is best-effort
520
+ }
507
521
  }
508
522
  }
@@ -212,7 +212,9 @@ export class CodexAgentWatcher implements AgentWatcher {
212
212
  if (this.fsWatcher) {
213
213
  try {
214
214
  this.fsWatcher.close();
215
- } catch {}
215
+ } catch {
216
+ // intentionally ignored: watcher may already be closed during shutdown
217
+ }
216
218
  this.fsWatcher = null;
217
219
  }
218
220
  if (this.pollTimer) {
@@ -238,7 +240,9 @@ export class CodexAgentWatcher implements AgentWatcher {
238
240
  if (entry.id && entry.thread_name) {
239
241
  names.set(entry.id, entry.thread_name);
240
242
  }
241
- } catch {}
243
+ } catch {
244
+ // intentionally ignored: skip malformed JSON line
245
+ }
242
246
  }
243
247
 
244
248
  this.threadNames = names;
@@ -359,6 +363,8 @@ export class CodexAgentWatcher implements AgentWatcher {
359
363
  if (!filename?.endsWith(".jsonl")) return;
360
364
  this.processFile(join(this.sessionsDir, filename));
361
365
  });
362
- } catch {}
366
+ } catch {
367
+ // intentionally ignored: sessions dir watch is best-effort
368
+ }
363
369
  }
364
370
  }
@@ -86,7 +86,9 @@ export class OpenCodeAgentWatcher implements AgentWatcher {
86
86
  }
87
87
  try {
88
88
  this.db?.close();
89
- } catch {}
89
+ } catch {
90
+ // intentionally ignored: best-effort sqlite handle cleanup
91
+ }
90
92
  this.db = null;
91
93
  this.ctx = null;
92
94
  }
@@ -124,7 +126,9 @@ export class OpenCodeAgentWatcher implements AgentWatcher {
124
126
  } catch {
125
127
  try {
126
128
  this.db.close();
127
- } catch {}
129
+ } catch {
130
+ // intentionally ignored: best-effort sqlite handle cleanup
131
+ }
128
132
  this.db = null;
129
133
  return;
130
134
  }
@@ -153,7 +157,9 @@ export class OpenCodeAgentWatcher implements AgentWatcher {
153
157
  for (const pr of partRows) {
154
158
  try {
155
159
  lastParts.push(JSON.parse(pr.data));
156
- } catch {}
160
+ } catch {
161
+ // intentionally ignored: skip malformed part JSON
162
+ }
157
163
  }
158
164
  }
159
165
  } catch {
@@ -164,7 +170,9 @@ export class OpenCodeAgentWatcher implements AgentWatcher {
164
170
  if (lastMsg) {
165
171
  try {
166
172
  lastMsgData = JSON.parse(lastMsg.data);
167
- } catch {}
173
+ } catch {
174
+ // intentionally ignored: leave lastMsgData null on parse failure
175
+ }
168
176
  }
169
177
 
170
178
  const status = determineStatus(lastMsgData, lastParts);
@@ -211,7 +219,9 @@ export class OpenCodeAgentWatcher implements AgentWatcher {
211
219
  for (const pr of partRows) {
212
220
  try {
213
221
  lastParts.push(JSON.parse(pr.data));
214
- } catch {}
222
+ } catch {
223
+ // intentionally ignored: skip malformed part JSON
224
+ }
215
225
  }
216
226
  }
217
227
  } catch {
@@ -222,7 +232,9 @@ export class OpenCodeAgentWatcher implements AgentWatcher {
222
232
  if (lastMsg) {
223
233
  try {
224
234
  lastMsgData = JSON.parse(lastMsg.data);
225
- } catch {}
235
+ } catch {
236
+ // intentionally ignored: leave lastMsgData null on parse failure
237
+ }
226
238
  }
227
239
 
228
240
  const status = determineStatus(lastMsgData, lastParts);
@@ -15,5 +15,7 @@ export function debugLog(category: string, msg: string, data?: Record<string, un
15
15
  const line = `[${ts}] [${category}] ${msg}${extra}\n`;
16
16
  try {
17
17
  appendFileSync(DEBUG_LOG, line);
18
- } catch {}
18
+ } catch {
19
+ // intentionally ignored: debug log file write is best-effort
20
+ }
19
21
  }
@@ -1,6 +1,9 @@
1
1
  import { readFileSync, unlinkSync, writeFileSync, appendFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
+
5
+ import consola from "consola";
6
+
4
7
  import type { MuxProvider } from "../contracts/mux";
5
8
  import { isFullSidebarCapable, isBatchCapable } from "../contracts/mux";
6
9
  import type { AgentEvent } from "../contracts/agent";
@@ -40,7 +43,9 @@ function log(category: string, msg: string, data?: Record<string, unknown>) {
40
43
  const line = `[${ts}] [${category}] ${msg}${extra}\n`;
41
44
  try {
42
45
  appendFileSync(DEBUG_LOG, line);
43
- } catch {}
46
+ } catch {
47
+ // intentionally ignored: debug log file write is best-effort
48
+ }
44
49
  }
45
50
 
46
51
  // shell, getGitInfo, invalidateGitCache imported from ./git-info
@@ -67,7 +72,9 @@ export function startServer(
67
72
  // Clear previous log on server start
68
73
  try {
69
74
  writeFileSync(DEBUG_LOG, "");
70
- } catch {}
75
+ } catch {
76
+ // intentionally ignored: clearing debug log is best-effort
77
+ }
71
78
  log("server", "starting", { providers: allProviders.map((p) => p.name) });
72
79
 
73
80
  // Load initial theme from config
@@ -840,7 +847,9 @@ export function startServer(
840
847
  try {
841
848
  const data = JSON.parse(readFileSync(join(sessionsDir, `${agentPid}.json`), "utf-8"));
842
849
  if (data.sessionId === threadId) return pane.id;
843
- } catch {}
850
+ } catch {
851
+ // intentionally ignored: Claude session file missing or malformed — try next pane
852
+ }
844
853
  }
845
854
  return undefined;
846
855
  }
@@ -870,7 +879,9 @@ export function startServer(
870
879
  } finally {
871
880
  try {
872
881
  db.close();
873
- } catch {}
882
+ } catch {
883
+ // intentionally ignored: best-effort sqlite handle cleanup
884
+ }
874
885
  }
875
886
  return undefined;
876
887
  }
@@ -894,7 +905,9 @@ export function startServer(
894
905
  const logText = readFileSync(pathMatch[1], "utf-8");
895
906
  const match = logText.match(/ses_[A-Za-z0-9]+/);
896
907
  if (match?.[0] === threadId) return pane.id;
897
- } catch {}
908
+ } catch {
909
+ // intentionally ignored: OpenCode log file unreadable — try next pane
910
+ }
898
911
  }
899
912
  return undefined;
900
913
  }
@@ -1189,7 +1202,9 @@ export function startServer(
1189
1202
  continue;
1190
1203
  }
1191
1204
  }
1192
- } catch {}
1205
+ } catch {
1206
+ // intentionally ignored: Claude projects dir missing or unreadable
1207
+ }
1193
1208
  return {};
1194
1209
  }
1195
1210
 
@@ -1216,10 +1231,13 @@ export function startServer(
1216
1231
  .get(`pid:${agentPid}:%`);
1217
1232
  if (row?.thread_id) return { threadId: row.thread_id };
1218
1233
  } catch {
1234
+ // intentionally ignored: Codex sqlite query failed — no thread info available
1219
1235
  } finally {
1220
1236
  try {
1221
1237
  db.close();
1222
- } catch {}
1238
+ } catch {
1239
+ // intentionally ignored: best-effort sqlite handle cleanup
1240
+ }
1223
1241
  }
1224
1242
  return {};
1225
1243
  }
@@ -1498,7 +1516,9 @@ export function startServer(
1498
1516
  if (idleTimer) clearTimeout(idleTimer);
1499
1517
  try {
1500
1518
  unlinkSync(PID_FILE);
1501
- } catch {}
1519
+ } catch {
1520
+ // intentionally ignored: PID file may already be gone during shutdown
1521
+ }
1502
1522
  for (const p of allProviders) p.cleanupHooks();
1503
1523
  }
1504
1524
 
@@ -1542,7 +1562,9 @@ export function startServer(
1542
1562
  const name = body.trim().replace(/^"+|"+$/g, "");
1543
1563
  if (name) handleFocus(name);
1544
1564
  }
1545
- } catch {}
1565
+ } catch {
1566
+ // intentionally ignored: malformed focus body is non-fatal, respond ok
1567
+ }
1546
1568
  return new Response("ok", { status: 200 });
1547
1569
  }
1548
1570
 
@@ -1553,7 +1575,9 @@ export function startServer(
1553
1575
  log("http", "POST /toggle", { ctx });
1554
1576
  toggleSidebar(ctx);
1555
1577
  broadcastState();
1556
- } catch {}
1578
+ } catch {
1579
+ // intentionally ignored: malformed toggle body is non-fatal, respond ok
1580
+ }
1557
1581
  return new Response("ok", { status: 200 });
1558
1582
  }
1559
1583
 
@@ -1573,7 +1597,9 @@ export function startServer(
1573
1597
  const ctx = parseContext(body) ?? undefined;
1574
1598
  log("http", "POST /switch-index", { index, ctx });
1575
1599
  switchToVisibleIndex(index, ctx?.clientTty);
1576
- } catch {}
1600
+ } catch {
1601
+ // intentionally ignored: malformed switch-index body is non-fatal, respond ok
1602
+ }
1577
1603
  return new Response("ok", { status: 200 });
1578
1604
  }
1579
1605
 
@@ -1585,7 +1611,9 @@ export function startServer(
1585
1611
  // Debounce ensure-sidebar during rapid switching — intermediate sessions
1586
1612
  // don't need full sidebar validation immediately.
1587
1613
  debouncedEnsureSidebar(ctx ?? undefined);
1588
- } catch {}
1614
+ } catch {
1615
+ // intentionally ignored: malformed ensure-sidebar body is non-fatal, respond ok
1616
+ }
1589
1617
  return new Response("ok", { status: 200 });
1590
1618
  }
1591
1619
 
@@ -1737,7 +1765,9 @@ export function startServer(
1737
1765
  const cmd = JSON.parse(msg as string) as ClientCommand;
1738
1766
  log("ws", "command", { type: cmd.type });
1739
1767
  handleCommand(cmd, ws);
1740
- } catch {}
1768
+ } catch {
1769
+ // intentionally ignored: malformed websocket command is non-fatal
1770
+ }
1741
1771
  },
1742
1772
  },
1743
1773
  });
@@ -1775,5 +1805,5 @@ export function startServer(
1775
1805
  });
1776
1806
 
1777
1807
  const names = allProviders.map((p) => p.name).join(", ");
1778
- console.log(`agentboard server listening on ${SERVER_HOST}:${SERVER_PORT} (mux: ${names})`);
1808
+ consola.info(`agentboard server listening on ${SERVER_HOST}:${SERVER_PORT} (mux: ${names})`);
1779
1809
  }
@@ -40,7 +40,9 @@ export async function ensureServer(): Promise<void> {
40
40
  // Stale PID file — remove before spawning a new server
41
41
  try {
42
42
  unlinkSync(PID_FILE);
43
- } catch {}
43
+ } catch {
44
+ // intentionally ignored: PID file may already be gone
45
+ }
44
46
  }
45
47
 
46
48
  const proc = Bun.spawn(["tt", "agentboard", "server"], {
@@ -170,7 +170,9 @@ function resolveClaudeCodeJournalInfo(threadId: string): {
170
170
  continue;
171
171
  }
172
172
  }
173
- } catch {}
173
+ } catch {
174
+ // intentionally ignored: Claude projects dir missing or unreadable
175
+ }
174
176
  return {};
175
177
  }
176
178
 
@@ -197,10 +199,13 @@ function resolveCodexPaneInfo(
197
199
  .get(`pid:${agentPid}:%`);
198
200
  if (row?.thread_id) return { threadId: row.thread_id };
199
201
  } catch {
202
+ // intentionally ignored: Codex sqlite query failed — no thread info available
200
203
  } finally {
201
204
  try {
202
205
  db.close();
203
- } catch {}
206
+ } catch {
207
+ // intentionally ignored: best-effort sqlite handle cleanup
208
+ }
204
209
  }
205
210
  return {};
206
211
  }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { AppError } from "./errors";
3
+
4
+ describe("AppError", () => {
5
+ it("carries code and message", () => {
6
+ const err = new AppError("my_code", "boom");
7
+ expect(err.code).toBe("my_code");
8
+ expect(err.message).toBe("boom");
9
+ expect(err).toBeInstanceOf(Error);
10
+ expect(err).toBeInstanceOf(AppError);
11
+ });
12
+
13
+ it("uses the constructor name as the error name", () => {
14
+ class ChildError extends AppError {
15
+ constructor(message: string) {
16
+ super("child_code", message);
17
+ }
18
+ }
19
+ expect(new AppError("c", "m").name).toBe("AppError");
20
+ expect(new ChildError("m").name).toBe("ChildError");
21
+ });
22
+
23
+ it("forwards cause to the native Error.cause", () => {
24
+ const root = new Error("root");
25
+ const err = new AppError("wrapped", "outer", { cause: root });
26
+ expect(err.cause).toBe(root);
27
+ });
28
+
29
+ it("leaves cause undefined when not provided", () => {
30
+ const err = new AppError("nope", "no cause");
31
+ expect(err.cause).toBeUndefined();
32
+ });
33
+ });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Shared error base class for towles-tool.
3
+ *
4
+ * Uses the native ES2022 `Error.cause` option so stack traces chain properly.
5
+ */
6
+ export class AppError extends Error {
7
+ readonly code: string;
8
+
9
+ constructor(code: string, message: string, options?: { cause?: unknown }) {
10
+ super(message, options);
11
+ this.name = new.target.name;
12
+ this.code = code;
13
+ }
14
+ }
@@ -1,3 +1,4 @@
1
+ export { AppError } from "./errors.js";
1
2
  export { ensureDir, fileExists, readFile, writeFile } from "./fs.js";
2
3
  export { getTerminalColumns, limitText, printWithHexColor } from "./render.js";
3
4
  export { formatDate, generateJournalFilename, getMondayOfWeek, getWeekInfo } from "./date-utils.js";
@@ -199,7 +199,9 @@ function stopServer(): boolean {
199
199
  }
200
200
  try {
201
201
  unlinkSync(PID_FILE);
202
- } catch {}
202
+ } catch {
203
+ // intentionally ignored: PID file may already be gone
204
+ }
203
205
  return true;
204
206
  }
205
207
 
@@ -1,11 +1,11 @@
1
1
  import { createInterface } from "node:readline";
2
2
  import { join } from "node:path";
3
3
 
4
- import consola from "consola";
5
4
  import pc from "picocolors";
6
5
 
7
- import { readFile } from "@towles/shared";
6
+ import { AppError, readFile } from "@towles/shared";
8
7
  import { getConfig } from "./config.js";
8
+ import { logger } from "./logger.js";
9
9
  import { sleep } from "./shell.js";
10
10
  import { spawnClaude as defaultSpawnClaude } from "./spawn-claude.js";
11
11
  import type { SpawnClaudeFn } from "./spawn-claude.js";
@@ -28,6 +28,13 @@ export interface ClaudeLogger {
28
28
  log: (...args: unknown[]) => void;
29
29
  }
30
30
 
31
+ /** Raised when the Claude CLI process fails after all retries. */
32
+ export class ClaudeProcessError extends AppError {
33
+ constructor(message: string, options?: { cause?: unknown }) {
34
+ super("claude_process_failed", message, options);
35
+ }
36
+ }
37
+
31
38
  const PROCESS_RETRIES = 3;
32
39
  const PROCESS_RETRY_DELAY_MS = 5_000;
33
40
 
@@ -39,7 +46,7 @@ export async function runClaude(opts: {
39
46
  }): Promise<ClaudeResult> {
40
47
  const cfg = getConfig();
41
48
  const spawnFn = opts.spawnFn ?? defaultSpawnClaude;
42
- const log = opts.logger ?? consola;
49
+ const log = opts.logger ?? logger;
43
50
  const args = [
44
51
  "-p",
45
52
  "--output-format",
@@ -61,7 +68,7 @@ export async function runClaude(opts: {
61
68
  `\n${pc.bold(pc.cyan("── System Prompt (CLAUDE.md) ──"))}\n${pc.dim(systemPrompt.trimEnd())}\n`,
62
69
  );
63
70
  } catch {
64
- /* CLAUDE.md not present */
71
+ // intentionally ignored: CLAUDE.md is optional
65
72
  }
66
73
 
67
74
  try {
@@ -70,7 +77,7 @@ export async function runClaude(opts: {
70
77
  `\n${pc.bold(pc.cyan(`── Prompt (${opts.promptFile}) ──`))}\n${pc.dim(promptContent.trimEnd())}\n`,
71
78
  );
72
79
  } catch {
73
- /* prompt file not present */
80
+ // intentionally ignored: prompt file preview is optional
74
81
  }
75
82
 
76
83
  let lastError: Error | undefined;
@@ -92,7 +99,9 @@ export async function runClaude(opts: {
92
99
  }
93
100
  }
94
101
  }
95
- throw lastError ?? new Error("runClaude failed after all retries");
102
+ throw new ClaudeProcessError(`runClaude failed after ${PROCESS_RETRIES} attempts`, {
103
+ cause: lastError,
104
+ });
96
105
  }
97
106
 
98
107
  function logActivityEvent(event: ReturnType<typeof parseStreamLine>, log: ClaudeLogger): void {
@@ -3,7 +3,6 @@ import { join } from "node:path";
3
3
  import { tmpdir } from "node:os";
4
4
 
5
5
  import { defineCommand } from "citty";
6
- import consola from "consola";
7
6
 
8
7
  import { debugArg } from "../shared.js";
9
8
  import { printExplain, printStepTemplate } from "./explain.js";
@@ -12,8 +11,9 @@ import { fetchIssue, fetchIssues } from "./steps/fetch-issues.js";
12
11
  import { getConfig, initConfig } from "./config.js";
13
12
  import { git } from "@towles/shared";
14
13
  import { runClaude } from "./claude-cli.js";
14
+ import { logger } from "./logger.js";
15
15
  import { sleep } from "./shell.js";
16
- import { log, logBanner } from "./utils.js";
16
+ import { logBanner } from "./utils.js";
17
17
  import type { IssueContext } from "./utils.js";
18
18
  import type { StepName } from "./prompt-templates/index.js";
19
19
 
@@ -104,7 +104,7 @@ export default defineCommand({
104
104
  try {
105
105
  printStepTemplate(args["step-template"] as string);
106
106
  } catch (e) {
107
- consola.error(e instanceof Error ? e.message : String(e));
107
+ logger.error(e instanceof Error ? e.message : String(e));
108
108
  process.exit(1);
109
109
  }
110
110
  return;
@@ -125,7 +125,7 @@ export default defineCommand({
125
125
  });
126
126
 
127
127
  if (result.is_error) {
128
- consola.error("Claude reported an error");
128
+ logger.error("Claude reported an error");
129
129
  process.exit(1);
130
130
  }
131
131
  return;
@@ -142,9 +142,9 @@ export default defineCommand({
142
142
  const resetIssue = args.reset ? Number(args.reset) : undefined;
143
143
  if (resetIssue) {
144
144
  const issueDir = join(process.cwd(), `.auto-claude/issue-${resetIssue}`);
145
- log(`Resetting state for issue-${resetIssue}...`);
145
+ logger.info(`Resetting state for issue-${resetIssue}...`);
146
146
  rmSync(issueDir, { recursive: true, force: true });
147
- log(`Cleaned ${issueDir}`);
147
+ logger.info(`Cleaned ${issueDir}`);
148
148
  return;
149
149
  }
150
150
 
@@ -155,7 +155,7 @@ export default defineCommand({
155
155
 
156
156
  if (loopMode) {
157
157
  registerShutdownHandlers();
158
- log(`Loop mode — interval: ${intervalMs / 60_000}min, limit: ${limit}`);
158
+ logger.info(`Loop mode — interval: ${intervalMs / 60_000}min, limit: ${limit}`);
159
159
  }
160
160
 
161
161
  const issueNumber = args.issue ? Number(args.issue) : undefined;
@@ -172,16 +172,16 @@ export default defineCommand({
172
172
  try {
173
173
  await syncWithRemote();
174
174
  } catch (e) {
175
- log(`Sync failed: ${e instanceof Error ? e.message : String(e)}`);
175
+ logger.warn(`Sync failed: ${e instanceof Error ? e.message : String(e)}`);
176
176
  if (loopMode) {
177
- log(`Will retry in ${Math.round(intervalMs / 1000)}s...`);
177
+ logger.info(`Will retry in ${Math.round(intervalMs / 1000)}s...`);
178
178
  await sleep(intervalMs);
179
179
  continue;
180
180
  }
181
181
  throw e;
182
182
  }
183
183
 
184
- log("Fetching labeled issues…");
184
+ logger.info("Fetching labeled issues…");
185
185
  let contexts: IssueContext[];
186
186
  if (issueNumber) {
187
187
  const ctx = await fetchIssue(issueNumber);
@@ -191,19 +191,19 @@ export default defineCommand({
191
191
  }
192
192
 
193
193
  if (contexts.length === 0) {
194
- log("No issues to process.");
194
+ logger.info("No issues to process.");
195
195
  } else {
196
- log(`Processing ${contexts.length} issue(s)...\n`);
196
+ logger.info(`Processing ${contexts.length} issue(s)...\n`);
197
197
 
198
198
  for (const ctx of contexts) {
199
199
  const issueStart = Date.now();
200
200
  try {
201
201
  await runPipeline(ctx, untilStep);
202
202
  } catch (e) {
203
- consola.error(`Pipeline error for ${ctx.repo}#${ctx.number}:`, e);
203
+ logger.error(`Pipeline error for ${ctx.repo}#${ctx.number}:`, e);
204
204
  } finally {
205
205
  const elapsed = ((Date.now() - issueStart) / 1000).toFixed(1);
206
- log(`Completed ${ctx.repo}#${ctx.number} in ${elapsed}s`);
206
+ logger.info(`Completed ${ctx.repo}#${ctx.number} in ${elapsed}s`);
207
207
  }
208
208
  }
209
209
  }
@@ -211,23 +211,23 @@ export default defineCommand({
211
211
  if (loopMode) {
212
212
  const waitMs = Math.max(0, intervalMs - (Date.now() - iterationStart));
213
213
  if (waitMs > 0) {
214
- log(`Waiting ${Math.round(waitMs / 1000)}s until next iteration...`);
214
+ logger.info(`Waiting ${Math.round(waitMs / 1000)}s until next iteration...`);
215
215
  await sleep(waitMs);
216
216
  }
217
217
  }
218
218
  } while (loopMode);
219
219
 
220
- log("Done.");
220
+ logger.info("Done.");
221
221
  },
222
222
  });
223
223
 
224
224
  async function syncWithRemote(): Promise<void> {
225
225
  const cfg = getConfig();
226
- log("Syncing with remote...");
226
+ logger.info("Syncing with remote...");
227
227
  await git(["fetch", "--all", "--prune"]);
228
228
  const branch = await git(["rev-parse", "--abbrev-ref", "HEAD"]);
229
229
  if (branch !== cfg.mainBranch) {
230
- log(`Warning: on branch "${branch}", switching to ${cfg.mainBranch}...`);
230
+ logger.warn(`On branch "${branch}", switching to ${cfg.mainBranch}...`);
231
231
  await git(["checkout", cfg.mainBranch]).catch(() => {
232
232
  // Best-effort checkout — may fail if working tree is dirty
233
233
  });
@@ -235,9 +235,9 @@ async function syncWithRemote(): Promise<void> {
235
235
  const status = await git(["status", "--porcelain"]);
236
236
  if (status.length > 0) {
237
237
  const files = status.trim().split("\n");
238
- consola.warn(`Working tree has ${files.length} uncommitted change(s):`);
238
+ logger.warn(`Working tree has ${files.length} uncommitted change(s):`);
239
239
  for (const file of files) {
240
- consola.warn(` ${file.trim()}`);
240
+ logger.warn(` ${file.trim()}`);
241
241
  }
242
242
  }
243
243
  await git(["pull", cfg.remote, cfg.mainBranch]);
@@ -246,7 +246,7 @@ async function syncWithRemote(): Promise<void> {
246
246
  function registerShutdownHandlers(): void {
247
247
  for (const signal of ["SIGINT", "SIGTERM"] as const) {
248
248
  process.on(signal, () => {
249
- log(`Received ${signal}, shutting down...`);
249
+ logger.info(`Received ${signal}, shutting down...`);
250
250
  setTimeout(() => process.exit(1), 5_000).unref();
251
251
  git(["checkout", getConfig().mainBranch])
252
252
  .catch(() => {
@@ -0,0 +1,3 @@
1
+ import consola from "consola";
2
+
3
+ export const logger = consola.withTag("auto-claude");
@@ -9,8 +9,9 @@ import { stepImplement } from "./steps/implement.js";
9
9
  import { stepPlan, stepReview, stepSimplify } from "./steps/simple-steps.js";
10
10
  import { LABELS, ensureLabelsExist, removeLabel, setLabel } from "./labels.js";
11
11
  import type { ExecSafeFn } from "./labels.js";
12
+ import { logger } from "./logger.js";
13
+
12
14
  import { ensureDir, execSafe, fileExists, ghRaw, git, readFile, writeFile } from "@towles/shared";
13
- import { log } from "./utils.js";
14
15
  import type { IssueContext } from "./utils.js";
15
16
  import type { SpawnClaudeFn } from "./spawn-claude.js";
16
17
 
@@ -29,14 +30,14 @@ export async function runPipeline(
29
30
  const cfg = getConfig();
30
31
  const exec = deps?.exec;
31
32
  const spawnFn = deps?.spawnFn;
32
- log(`Pipeline starting for ${ctx.repo}#${ctx.number}: ${ctx.title}`);
33
+ logger.info(`Pipeline starting for ${ctx.repo}#${ctx.number}: ${ctx.title}`);
33
34
 
34
35
  ensureDir(ctx.issueDir);
35
36
  const ramblingsPath = join(ctx.issueDir, ARTIFACTS.initialRamblings);
36
37
  if (!fileExists(ramblingsPath)) {
37
38
  const content = `# ${ctx.title}\n\n> ${ctx.repo}#${ctx.number}\n\n${ctx.body ?? ""}`;
38
39
  writeFile(ramblingsPath, content);
39
- log("Saved initial-ramblings.md");
40
+ logger.info("Saved initial-ramblings.md");
40
41
  }
41
42
 
42
43
  // Label management
@@ -51,7 +52,7 @@ export async function runPipeline(
51
52
  return;
52
53
  }
53
54
  if (untilStep === "plan") {
54
- log(`Pipeline paused after "plan" (--until plan)`);
55
+ logger.info(`Pipeline paused after "plan" (--until plan)`);
55
56
  return;
56
57
  }
57
58
 
@@ -71,7 +72,7 @@ export async function runPipeline(
71
72
  return;
72
73
  }
73
74
  if (untilStep === "implement") {
74
- log(`Pipeline paused after "implement" (--until implement)`);
75
+ logger.info(`Pipeline paused after "implement" (--until implement)`);
75
76
  return;
76
77
  }
77
78
 
@@ -81,7 +82,7 @@ export async function runPipeline(
81
82
  return;
82
83
  }
83
84
  if (untilStep === "simplify") {
84
- log(`Pipeline paused after "simplify" (--until simplify)`);
85
+ logger.info(`Pipeline paused after "simplify" (--until simplify)`);
85
86
  return;
86
87
  }
87
88
 
@@ -91,7 +92,7 @@ export async function runPipeline(
91
92
  return;
92
93
  }
93
94
  if (untilStep === "review") {
94
- log(`Pipeline paused after "review" (--until review)`);
95
+ logger.info(`Pipeline paused after "review" (--until review)`);
95
96
  return;
96
97
  }
97
98
 
@@ -101,13 +102,13 @@ export async function runPipeline(
101
102
  await removeLabel(ctx.repo, ctx.number, LABELS.inProgress, exec);
102
103
  await setLabel(ctx.repo, ctx.number, LABELS.success, exec);
103
104
  await setLabel(ctx.repo, ctx.number, LABELS.review, exec);
104
- log(`Pipeline complete for ${ctx.repo}#${ctx.number} — ${prUrl}`);
105
+ logger.info(`Pipeline complete for ${ctx.repo}#${ctx.number} — ${prUrl}`);
105
106
  return;
106
107
  }
107
108
 
108
109
  // Review failed
109
110
  if (attempt < maxRetries) {
110
- log(
111
+ logger.warn(
111
112
  `Review did not pass (attempt ${attempt + 1}/${maxRetries + 1}), retrying implement→simplify→review…`,
112
113
  );
113
114
  }
@@ -151,7 +152,7 @@ async function handleFailure(
151
152
  exec,
152
153
  );
153
154
  }
154
- log(`Pipeline stopped at "${stepName}" for ${ctx.repo}#${ctx.number}`);
155
+ logger.info(`Pipeline stopped at "${stepName}" for ${ctx.repo}#${ctx.number}`);
155
156
  }
156
157
 
157
158
  async function checkoutMain(): Promise<void> {
@@ -165,7 +166,7 @@ async function checkoutMain(): Promise<void> {
165
166
  const idx = lines.findIndex((l) => l.includes("auto-claude: before switching to"));
166
167
  if (idx >= 0) {
167
168
  await execSafe("git", ["stash", "pop", `stash@{${idx}}`]);
168
- log("Restored stashed changes");
169
+ logger.info("Restored stashed changes");
169
170
  }
170
171
  }
171
172
  }
@@ -1,11 +1,9 @@
1
1
  import { join } from "node:path";
2
2
 
3
- import consola from "consola";
4
-
5
3
  import { execSafe, fileExists, ghRaw, git, readFile, writeFile } from "@towles/shared";
6
4
  import { getConfig } from "../config.js";
5
+ import { logger } from "../logger.js";
7
6
  import { ARTIFACTS } from "../prompt-templates/index.js";
8
- import { log } from "../utils.js";
9
7
  import type { IssueContext } from "../utils.js";
10
8
  import type { ExecSafeFn } from "../labels.js";
11
9
 
@@ -14,7 +12,7 @@ export async function createPr(ctx: IssueContext, exec?: ExecSafeFn): Promise<st
14
12
 
15
13
  const existingUrl = await getExistingPrUrl(ctx.branch, exec);
16
14
  if (existingUrl) {
17
- log(`PR already exists: ${existingUrl}`);
15
+ logger.info(`PR already exists: ${existingUrl}`);
18
16
  return existingUrl;
19
17
  }
20
18
 
@@ -68,12 +66,12 @@ export async function createPr(ctx: IssueContext, exec?: ExecSafeFn): Promise<st
68
66
  }
69
67
 
70
68
  writeFile(join(ctx.issueDir, ARTIFACTS.prUrl), prUrl);
71
- log(`PR created: ${prUrl}`);
69
+ logger.info(`PR created: ${prUrl}`);
72
70
 
73
71
  try {
74
72
  await attachArtifacts(ctx, prUrl, exec);
75
73
  } catch (e) {
76
- consola.warn(`Artifact upload failed (non-blocking): ${e}`);
74
+ logger.warn(`Artifact upload failed (non-blocking): ${e}`);
77
75
  }
78
76
 
79
77
  return prUrl;
@@ -84,7 +82,7 @@ async function attachArtifacts(ctx: IssueContext, prUrl: string, exec?: ExecSafe
84
82
  const tag = `ac-issue-${ctx.number}`;
85
83
  const cfg = getConfig();
86
84
 
87
- log("Archiving pipeline artifacts…");
85
+ logger.info("Archiving pipeline artifacts…");
88
86
  await execSafe("tar", [
89
87
  "czf",
90
88
  archivePath,
@@ -95,7 +93,7 @@ async function attachArtifacts(ctx: IssueContext, prUrl: string, exec?: ExecSafe
95
93
  ".",
96
94
  ]);
97
95
 
98
- log("Uploading artifacts to GitHub release…");
96
+ logger.info("Uploading artifacts to GitHub release…");
99
97
  await ghRaw(["release", "delete", tag, "--yes", "--repo", cfg.repo], exec);
100
98
 
101
99
  await ghRaw(
@@ -119,7 +117,7 @@ async function attachArtifacts(ctx: IssueContext, prUrl: string, exec?: ExecSafe
119
117
  );
120
118
 
121
119
  if (!assetUrl) {
122
- consola.warn("Could not get artifact download URL — skipping PR comment");
120
+ logger.warn("Could not get artifact download URL — skipping PR comment");
123
121
  return;
124
122
  }
125
123
 
@@ -154,7 +152,7 @@ async function getExistingPrUrl(branch: string, exec?: ExecSafeFn): Promise<stri
154
152
  const prs = JSON.parse(out) as Array<{ url: string }>;
155
153
  return prs.length > 0 ? prs[0].url : null;
156
154
  } catch {
157
- consola.debug(`Failed to parse PR list JSON for branch "${branch}"`);
155
+ logger.debug(`Failed to parse PR list JSON for branch "${branch}"`);
158
156
  return null;
159
157
  }
160
158
  }
@@ -1,7 +1,7 @@
1
- import consola from "consola";
2
- import { getConfig } from "../config.js";
3
1
  import { gh } from "@towles/shared";
4
- import { buildIssueContext, log } from "../utils.js";
2
+ import { getConfig } from "../config.js";
3
+ import { logger } from "../logger.js";
4
+ import { buildIssueContext } from "../utils.js";
5
5
  import type { IssueContext } from "../utils.js";
6
6
 
7
7
  interface GhIssue {
@@ -14,7 +14,7 @@ interface GhIssue {
14
14
  export async function fetchIssues(limit?: number): Promise<IssueContext[]> {
15
15
  const cfg = getConfig();
16
16
 
17
- log(`Scanning ${cfg.repo} for issues labeled "${cfg.triggerLabel}"...`);
17
+ logger.info(`Scanning ${cfg.repo} for issues labeled "${cfg.triggerLabel}"...`);
18
18
 
19
19
  let issues: GhIssue[];
20
20
  try {
@@ -31,16 +31,16 @@ export async function fetchIssues(limit?: number): Promise<IssueContext[]> {
31
31
  "number,title,body,labels",
32
32
  ]);
33
33
  } catch (e) {
34
- log(`Warning: could not fetch issues from ${cfg.repo}: ${e}`);
34
+ logger.warn(`Could not fetch issues from ${cfg.repo}: ${e}`);
35
35
  return [];
36
36
  }
37
37
 
38
38
  if (issues.length === 0) {
39
- log("No issues found.");
39
+ logger.info("No issues found.");
40
40
  return [];
41
41
  }
42
42
 
43
- log(`Found ${issues.length} issue(s).`);
43
+ logger.info(`Found ${issues.length} issue(s).`);
44
44
 
45
45
  const selected = limit != null ? issues.slice(0, limit) : issues;
46
46
  return selected.map((issue) => buildIssueContext(issue, cfg.repo, cfg.scopePath));
@@ -61,7 +61,7 @@ export async function fetchIssue(issueNumber: number): Promise<IssueContext | un
61
61
  ]);
62
62
  return buildIssueContext(issue, cfg.repo, cfg.scopePath);
63
63
  } catch {
64
- consola.debug(`Could not fetch issue #${issueNumber} from ${cfg.repo}`);
64
+ logger.debug(`Could not fetch issue #${issueNumber} from ${cfg.repo}`);
65
65
  return undefined;
66
66
  }
67
67
  }
@@ -1,13 +1,12 @@
1
1
  import { join } from "node:path";
2
2
 
3
- import consola from "consola";
4
-
5
3
  import { getConfig } from "../config.js";
6
4
  import { ARTIFACTS, STEP_LABELS, TEMPLATES } from "../prompt-templates/index.js";
7
5
  import { fileExists, git, readFile } from "@towles/shared";
8
6
  import { runClaude } from "../claude-cli.js";
7
+ import { logger } from "../logger.js";
9
8
  import { resolveTemplate } from "../templates.js";
10
- import { buildTokens, log, logStep } from "../utils.js";
9
+ import { buildTokens, logStep } from "../utils.js";
11
10
  import type { IssueContext } from "../utils.js";
12
11
  import type { SpawnClaudeFn } from "../spawn-claude.js";
13
12
 
@@ -28,7 +27,7 @@ export async function stepImplement(ctx: IssueContext, spawnFn?: SpawnClaudeFn):
28
27
  const reviewFeedback = fileExists(reviewPath) ? readFile(reviewPath) : "";
29
28
 
30
29
  for (let i = 1; i <= maxIterations; i++) {
31
- log(`Implementation iteration ${i}/${maxIterations}`);
30
+ logger.info(`Implementation iteration ${i}/${maxIterations}`);
32
31
 
33
32
  const tokens = buildTokens(ctx, { REVIEW_FEEDBACK: reviewFeedback });
34
33
  const promptFile = resolveTemplate(TEMPLATES.implement, tokens, ctx.issueDir);
@@ -40,18 +39,18 @@ export async function stepImplement(ctx: IssueContext, spawnFn?: SpawnClaudeFn):
40
39
  });
41
40
 
42
41
  if (result.is_error) {
43
- consola.error(`Implement iteration ${i} failed: ${result.result}`);
42
+ logger.error(`Implement iteration ${i} failed: ${result.result}`);
44
43
  return false;
45
44
  }
46
45
 
47
46
  if (fileExists(completedPath)) {
48
- log(`Implementation complete after ${i} iteration(s)`);
47
+ logger.info(`Implementation complete after ${i} iteration(s)`);
49
48
  return true;
50
49
  }
51
50
 
52
- log(`Iteration ${i} finished but completed-summary.md not yet created — tasks remain`);
51
+ logger.warn(`Iteration ${i} finished but completed-summary.md not yet created — tasks remain`);
53
52
  }
54
53
 
55
- consola.error(`Implementation did not complete after ${maxIterations} iterations`);
54
+ logger.error(`Implementation did not complete after ${maxIterations} iterations`);
56
55
  return false;
57
56
  }
@@ -14,6 +14,7 @@ import {
14
14
  } from "@towles/shared";
15
15
  import { runClaude } from "./claude-cli.js";
16
16
  import { getConfig } from "./config.js";
17
+ import { logger } from "./logger.js";
17
18
  import { ARTIFACTS } from "./prompt-templates/index.js";
18
19
  import { resolveTemplate } from "./templates.js";
19
20
 
@@ -100,10 +101,6 @@ function findNthBlankLine(lines: string[], n: number): number {
100
101
 
101
102
  // ── Logging ──
102
103
 
103
- export function log(msg: string): void {
104
- consola.info(`[auto-claude] ${msg}`);
105
- }
106
-
107
104
  export function logBanner(label: string, width = 60): void {
108
105
  const inner = ` ${label} `;
109
106
  const totalDashes = Math.max(0, width - inner.length - 2);
@@ -131,7 +128,7 @@ export async function ensureBranch(branch: string): Promise<void> {
131
128
  const hadDirtyTree = status.ok && status.stdout.length > 0;
132
129
  if (hadDirtyTree) {
133
130
  await git(["stash", "push", "-m", `auto-claude: before switching to ${branch}`]);
134
- log("Stashed uncommitted changes");
131
+ logger.info("Stashed uncommitted changes");
135
132
  }
136
133
 
137
134
  // Check if branch exists locally (rev-parse is reliable, no output parsing)
@@ -147,7 +144,7 @@ export async function ensureBranch(branch: string): Promise<void> {
147
144
  await git(["checkout", branch]);
148
145
  return;
149
146
  } catch {
150
- /* doesn't exist remotely */
147
+ // intentionally ignored: branch doesn't exist remotely, fall through to create it
151
148
  }
152
149
 
153
150
  // Create new branch from main
@@ -195,12 +192,12 @@ export async function runStepWithArtifact(opts: StepRunnerOptions): Promise<bool
195
192
  });
196
193
 
197
194
  if (result.is_error) {
198
- consola.error(`${stepName} step failed: ${result.result}`);
195
+ logger.error(`${stepName} step failed: ${result.result}`);
199
196
  return false;
200
197
  }
201
198
 
202
199
  if (!isValid(artifactPath)) {
203
- consola.error(`${stepName} step did not produce expected artifact`);
200
+ logger.error(`${stepName} step did not produce expected artifact`);
204
201
  return false;
205
202
  }
206
203
 
@@ -18,7 +18,7 @@ function textBlock(text: string): ContentBlock {
18
18
  }
19
19
 
20
20
  function toolUseBlock(name: string, input: Record<string, unknown>): ContentBlock {
21
- return { type: "tool_use" as const, id: "tool-stub", name, input };
21
+ return { type: "tool_use" as const, id: "tool-stub", name, input, caller: { type: "direct" } };
22
22
  }
23
23
 
24
24
  function makeUsage(overrides: Partial<Usage> = {}): Usage {
@@ -27,6 +27,8 @@ function makeUsage(overrides: Partial<Usage> = {}): Usage {
27
27
  output_tokens: 0,
28
28
  cache_read_input_tokens: null,
29
29
  cache_creation_input_tokens: null,
30
+ cache_creation: null,
31
+ inference_geo: null,
30
32
  server_tool_use: null,
31
33
  service_tier: null,
32
34
  ...overrides,
@@ -11,6 +11,8 @@ function makeUsage(overrides: Partial<Usage> = {}): Usage {
11
11
  output_tokens: 0,
12
12
  cache_read_input_tokens: null,
13
13
  cache_creation_input_tokens: null,
14
+ cache_creation: null,
15
+ inference_geo: null,
14
16
  server_tool_use: null,
15
17
  service_tier: null,
16
18
  ...overrides,
@@ -33,6 +33,7 @@ export function collectJournalEntries(files: string[], baseDir: string): Journal
33
33
  try {
34
34
  size = statSync(filePath).size;
35
35
  } catch {
36
+ // intentionally ignored: skip files that can't be stat'd (deleted, permission)
36
37
  continue;
37
38
  }
38
39
  entries.push({
@@ -204,7 +205,7 @@ export default defineCommand({
204
205
  }
205
206
 
206
207
  consola.info(`Showing ${colors.green(String(result.length))} journal entries:\n`);
207
- console.log(renderTable(result));
208
+ consola.log(renderTable(result));
208
209
  } catch (error) {
209
210
  consola.error("Failed to list journal entries:", error);
210
211
  process.exit(1);
@@ -240,12 +240,12 @@ export default defineCommand({
240
240
 
241
241
  for (const [filePath, fileMatches] of byFile) {
242
242
  const relative = path.relative(baseFolder, filePath);
243
- console.log(colors.bold(colors.cyan(relative)));
243
+ consola.log(colors.bold(colors.cyan(relative)));
244
244
  for (const m of fileMatches) {
245
245
  for (const line of m.context) {
246
- console.log(line);
246
+ consola.log(line);
247
247
  }
248
- console.log("");
248
+ consola.log("");
249
249
  }
250
250
  }
251
251
  } catch (error) {