@os-eco/overstory-cli 0.8.3 → 0.8.5

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.
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Background NDJSON event tailer for headless agent stdout logs.
3
+ *
4
+ * Headless agents (e.g. Sapling) write NDJSON events to a stdout.log file
5
+ * in .overstory/logs/{agentName}/{timestamp}/stdout.log. After ov sling exits,
6
+ * nobody reads this stream — so ov status, ov dashboard, and ov feed cannot
7
+ * show live progress for headless agents.
8
+ *
9
+ * This module provides startEventTailer(), which polls the log file on a
10
+ * configurable interval, parses new NDJSON lines, and writes them into events.db
11
+ * via EventStore. The watchdog daemon starts a tailer for each headless agent
12
+ * session and stops it when the session completes or terminates.
13
+ */
14
+
15
+ import { readdir } from "node:fs/promises";
16
+ import { join } from "node:path";
17
+ import type { EventStore, EventType } from "../types.ts";
18
+ import { createEventStore } from "./store.ts";
19
+
20
+ /**
21
+ * Handle to a running event tailer.
22
+ * Call stop() to halt polling and close the database connection.
23
+ */
24
+ export interface TailerHandle {
25
+ /** Agent name being tailed. */
26
+ readonly agentName: string;
27
+ /** Absolute path to the stdout.log file being tailed. */
28
+ readonly logPath: string;
29
+ /** Stop polling and release all resources. */
30
+ stop(): void;
31
+ }
32
+
33
+ /** Map NDJSON event type strings to EventStore EventType. */
34
+ function mapEventType(type: string): EventType {
35
+ switch (type) {
36
+ case "tool_start":
37
+ return "tool_start";
38
+ case "tool_end":
39
+ return "tool_end";
40
+ case "session_start":
41
+ return "session_start";
42
+ case "session_end":
43
+ return "session_end";
44
+ case "turn_start":
45
+ return "turn_start";
46
+ case "turn_end":
47
+ return "turn_end";
48
+ case "progress":
49
+ return "progress";
50
+ case "result":
51
+ return "result";
52
+ case "error":
53
+ return "error";
54
+ default:
55
+ return "custom";
56
+ }
57
+ }
58
+
59
+ /** Options for startEventTailer. */
60
+ export interface TailerOptions {
61
+ /** Absolute path to the stdout.log file to tail. */
62
+ stdoutLogPath: string;
63
+ /** Agent name for event attribution in events.db. */
64
+ agentName: string;
65
+ /** Run ID to associate events with, or null. */
66
+ runId: string | null;
67
+ /** Absolute path to events.db. The tailer opens its own connection. */
68
+ eventsDbPath: string;
69
+ /** Poll interval in milliseconds (default: 500). */
70
+ pollIntervalMs?: number;
71
+ /** DI: injected EventStore for testing (overrides eventsDbPath). */
72
+ _eventStore?: EventStore;
73
+ }
74
+
75
+ /**
76
+ * Start a background event tailer for a headless agent's stdout.log.
77
+ *
78
+ * Polls the log file on a configurable interval, reads new bytes since the
79
+ * last poll using file.size as a byte cursor, parses NDJSON lines, and writes
80
+ * normalized events to events.db. Maintains its own SQLite connection so it
81
+ * can outlive the daemon tick that created it.
82
+ *
83
+ * All errors (file not found, parse failures, DB write failures) are swallowed
84
+ * silently — the tailer must never crash the watchdog daemon.
85
+ *
86
+ * @param opts - Tailer configuration (log path, agent, run, db path)
87
+ * @returns TailerHandle with stop() to halt polling and close resources
88
+ */
89
+ export function startEventTailer(opts: TailerOptions): TailerHandle {
90
+ const { stdoutLogPath, agentName, runId, eventsDbPath, pollIntervalMs = 500 } = opts;
91
+
92
+ // Open a dedicated EventStore for this tailer's lifetime (not tick-scoped).
93
+ // Injected _eventStore is used for testing without an actual DB file.
94
+ let eventStore: EventStore | null = opts._eventStore ?? null;
95
+ let ownedEventStore = false;
96
+ if (!eventStore) {
97
+ try {
98
+ eventStore = createEventStore(eventsDbPath);
99
+ ownedEventStore = true;
100
+ } catch {
101
+ // If we can't open the event store, the tailer becomes a no-op.
102
+ }
103
+ }
104
+
105
+ let stopped = false;
106
+ let byteOffset = 0;
107
+ let timer: ReturnType<typeof setTimeout> | null = null;
108
+
109
+ const poll = async (): Promise<void> => {
110
+ if (stopped) return;
111
+
112
+ try {
113
+ const file = Bun.file(stdoutLogPath);
114
+ const size = file.size;
115
+
116
+ if (size > byteOffset) {
117
+ // Read only new bytes since last poll — avoids re-processing old lines.
118
+ const newContent = await file.slice(byteOffset, size).text();
119
+ byteOffset = size;
120
+
121
+ const lines = newContent.split("\n");
122
+ for (const line of lines) {
123
+ const trimmed = line.trim();
124
+ if (!trimmed) continue;
125
+
126
+ let event: Record<string, unknown>;
127
+ try {
128
+ event = JSON.parse(trimmed) as Record<string, unknown>;
129
+ } catch {
130
+ // Skip malformed lines — partial writes or debug output.
131
+ continue;
132
+ }
133
+
134
+ const type = typeof event.type === "string" ? event.type : "custom";
135
+ const eventType = mapEventType(type);
136
+ const level = type === "error" ? "error" : "info";
137
+
138
+ // Extract tool name from various field names runtimes may use.
139
+ let toolName: string | null = null;
140
+ if (typeof event.tool === "string") {
141
+ toolName = event.tool;
142
+ } else if (typeof event.tool_name === "string") {
143
+ toolName = event.tool_name;
144
+ } else if (typeof event.toolName === "string") {
145
+ toolName = event.toolName;
146
+ }
147
+
148
+ const toolDurationMs = typeof event.duration_ms === "number" ? event.duration_ms : null;
149
+
150
+ try {
151
+ eventStore?.insert({
152
+ runId,
153
+ agentName,
154
+ sessionId: null,
155
+ eventType,
156
+ toolName,
157
+ toolArgs: null,
158
+ toolDurationMs,
159
+ level,
160
+ data: JSON.stringify(event),
161
+ });
162
+ } catch {
163
+ // DB write failure is non-fatal.
164
+ }
165
+ }
166
+ }
167
+ } catch {
168
+ // File read failure is non-fatal — agent may not have started writing yet.
169
+ }
170
+
171
+ if (!stopped) {
172
+ timer = setTimeout(poll, pollIntervalMs);
173
+ }
174
+ };
175
+
176
+ // Schedule first poll.
177
+ timer = setTimeout(poll, pollIntervalMs);
178
+
179
+ return {
180
+ agentName,
181
+ logPath: stdoutLogPath,
182
+ stop() {
183
+ stopped = true;
184
+ if (timer !== null) {
185
+ clearTimeout(timer);
186
+ timer = null;
187
+ }
188
+ // Close only the EventStore this tailer owns (not the injected one).
189
+ if (ownedEventStore && eventStore) {
190
+ try {
191
+ eventStore.close();
192
+ } catch {
193
+ // Non-fatal.
194
+ }
195
+ eventStore = null;
196
+ }
197
+ },
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Discover the most recent stdout.log path for a headless agent.
203
+ *
204
+ * Scans .overstory/logs/{agentName}/ for timestamped session directories and
205
+ * returns the stdout.log path from the lexicographically last directory.
206
+ * Directories use ISO timestamps with `-` replacing `.` and `:`, which sort
207
+ * correctly in lexicographic order (e.g. 2026-03-05T14-52-26-089Z).
208
+ *
209
+ * Returns null if no log directory exists or no stdout.log is found.
210
+ *
211
+ * @param overstoryDir - Absolute path to .overstory/
212
+ * @param agentName - Agent name to look up (matches .overstory/logs/{agentName}/)
213
+ */
214
+ export async function findLatestStdoutLog(
215
+ overstoryDir: string,
216
+ agentName: string,
217
+ ): Promise<string | null> {
218
+ const agentLogsDir = join(overstoryDir, "logs", agentName);
219
+ try {
220
+ const entries = await readdir(agentLogsDir);
221
+ if (entries.length === 0) return null;
222
+
223
+ // Lexicographic sort: ISO timestamps sort correctly without parsing.
224
+ const sorted = entries.sort();
225
+ const latest = sorted[sorted.length - 1];
226
+ if (!latest) return null;
227
+
228
+ const logPath = join(agentLogsDir, latest, "stdout.log");
229
+ const file = Bun.file(logPath);
230
+ if (await file.exists()) return logPath;
231
+ return null;
232
+ } catch {
233
+ return null;
234
+ }
235
+ }
package/src/index.ts CHANGED
@@ -49,7 +49,7 @@ import { ConfigError, OverstoryError, WorktreeError } from "./errors.ts";
49
49
  import { jsonError } from "./json.ts";
50
50
  import { brand, chalk, muted, setQuiet } from "./logging/color.ts";
51
51
 
52
- export const VERSION = "0.8.3";
52
+ export const VERSION = "0.8.5";
53
53
 
54
54
  const rawArgs = process.argv.slice(2);
55
55
 
@@ -290,6 +290,105 @@ describe("createMergeResolver", () => {
290
290
  });
291
291
  });
292
292
 
293
+ describe("Dirty working tree pre-check", () => {
294
+ test("throws MergeError when unstaged changes exist on tracked files", async () => {
295
+ const repoDir = await createTempGitRepo();
296
+ try {
297
+ const defaultBranch = await getDefaultBranch(repoDir);
298
+ // Create a tracked file and then leave it modified (unstaged)
299
+ await commitFile(repoDir, "src/main.ts", "original content\n");
300
+ await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
301
+ await commitFile(repoDir, "src/feature.ts", "feature content\n");
302
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
303
+ // Modify a tracked file without staging
304
+ await Bun.write(`${repoDir}/src/main.ts`, "modified content\n");
305
+
306
+ const entry = makeTestEntry({
307
+ branchName: "feature-branch",
308
+ filesModified: ["src/feature.ts"],
309
+ });
310
+
311
+ const resolver = createMergeResolver({
312
+ aiResolveEnabled: false,
313
+ reimagineEnabled: false,
314
+ });
315
+
316
+ await expect(resolver.resolve(entry, defaultBranch, repoDir)).rejects.toThrow(MergeError);
317
+ } finally {
318
+ await cleanupTempDir(repoDir);
319
+ }
320
+ });
321
+
322
+ test("throws MergeError with message listing dirty files", async () => {
323
+ const repoDir = await createTempGitRepo();
324
+ try {
325
+ const defaultBranch = await getDefaultBranch(repoDir);
326
+ await commitFile(repoDir, "src/main.ts", "original content\n");
327
+ await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
328
+ await commitFile(repoDir, "src/feature.ts", "feature content\n");
329
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
330
+ await Bun.write(`${repoDir}/src/main.ts`, "modified content\n");
331
+
332
+ const entry = makeTestEntry({ branchName: "feature-branch" });
333
+ const resolver = createMergeResolver({ aiResolveEnabled: false, reimagineEnabled: false });
334
+
335
+ try {
336
+ await resolver.resolve(entry, defaultBranch, repoDir);
337
+ expect(true).toBe(false); // should not reach
338
+ } catch (err: unknown) {
339
+ expect(err).toBeInstanceOf(MergeError);
340
+ const mergeErr = err as MergeError;
341
+ expect(mergeErr.message).toContain("src/main.ts");
342
+ expect(mergeErr.message).toContain("Commit or stash");
343
+ }
344
+ } finally {
345
+ await cleanupTempDir(repoDir);
346
+ }
347
+ });
348
+
349
+ test("throws MergeError when staged but uncommitted changes exist", async () => {
350
+ const repoDir = await createTempGitRepo();
351
+ try {
352
+ const defaultBranch = await getDefaultBranch(repoDir);
353
+ await commitFile(repoDir, "src/main.ts", "original content\n");
354
+ await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
355
+ await commitFile(repoDir, "src/feature.ts", "feature content\n");
356
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
357
+ // Modify and stage (but don't commit)
358
+ await Bun.write(`${repoDir}/src/main.ts`, "staged but not committed\n");
359
+ await runGitInDir(repoDir, ["add", "src/main.ts"]);
360
+
361
+ const entry = makeTestEntry({ branchName: "feature-branch" });
362
+ const resolver = createMergeResolver({ aiResolveEnabled: false, reimagineEnabled: false });
363
+
364
+ await expect(resolver.resolve(entry, defaultBranch, repoDir)).rejects.toThrow(MergeError);
365
+ } finally {
366
+ await cleanupTempDir(repoDir);
367
+ }
368
+ });
369
+
370
+ test("clean working tree proceeds normally to Tier 1", async () => {
371
+ const repoDir = await createTempGitRepo();
372
+ try {
373
+ const defaultBranch = await getDefaultBranch(repoDir);
374
+ await setupCleanMerge(repoDir, defaultBranch);
375
+
376
+ const entry = makeTestEntry({
377
+ branchName: "feature-branch",
378
+ filesModified: ["src/feature-file.ts"],
379
+ });
380
+
381
+ const resolver = createMergeResolver({ aiResolveEnabled: false, reimagineEnabled: false });
382
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
383
+
384
+ expect(result.success).toBe(true);
385
+ expect(result.tier).toBe("clean-merge");
386
+ } finally {
387
+ await cleanupTempDir(repoDir);
388
+ }
389
+ });
390
+ });
391
+
293
392
  describe("Tier 1 fail -> Tier 2: Auto-resolve", () => {
294
393
  test("auto-resolves conflicts keeping incoming changes with correct content", async () => {
295
394
  const repoDir = await createTempGitRepo();
@@ -50,6 +50,26 @@ async function runGit(
50
50
  return { stdout, stderr, exitCode };
51
51
  }
52
52
 
53
+ /**
54
+ * Get the list of tracked files with uncommitted changes (unstaged or staged).
55
+ * Returns deduplicated list of file paths. An empty list means the working tree is clean.
56
+ */
57
+ async function checkDirtyWorkingTree(repoRoot: string): Promise<string[]> {
58
+ const { stdout: unstaged } = await runGit(repoRoot, ["diff", "--name-only"]);
59
+ const { stdout: staged } = await runGit(repoRoot, ["diff", "--name-only", "--cached"]);
60
+ const files = [
61
+ ...unstaged
62
+ .trim()
63
+ .split("\n")
64
+ .filter((l) => l.length > 0),
65
+ ...staged
66
+ .trim()
67
+ .split("\n")
68
+ .filter((l) => l.length > 0),
69
+ ];
70
+ return [...new Set(files)];
71
+ }
72
+
53
73
  /**
54
74
  * Get the list of conflicted files from `git diff --name-only --diff-filter=U`.
55
75
  */
@@ -593,6 +613,17 @@ export function createMergeResolver(options: {
593
613
  }
594
614
  }
595
615
 
616
+ // Pre-check: abort early if working tree has uncommitted changes.
617
+ // When dirty tracked files exist, git merge refuses to start (exit 1, no conflict markers),
618
+ // causing all tiers to cascade with empty conflict lists and a misleading final error.
619
+ const dirtyFiles = await checkDirtyWorkingTree(repoRoot);
620
+ if (dirtyFiles.length > 0) {
621
+ throw new MergeError(
622
+ `Working tree has uncommitted changes to tracked files: ${dirtyFiles.join(", ")}. Commit or stash changes before running ov merge.`,
623
+ { branchName: entry.branchName },
624
+ );
625
+ }
626
+
596
627
  let lastTier: ResolutionTier = "clean-merge";
597
628
  let conflictFiles: string[] = [];
598
629
 
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * Coverage:
8
8
  * - parseTranscriptUsage (transcript.ts)
9
- * - estimateCost re-export (transcript.ts -> pricing.ts)
9
+ * - estimateCost (pricing.ts, imported directly)
10
10
  * - getPricingForModel (pricing.ts)
11
11
  */
12
12
 
@@ -15,8 +15,8 @@ import { mkdtemp } from "node:fs/promises";
15
15
  import { tmpdir } from "node:os";
16
16
  import { join } from "node:path";
17
17
  import { cleanupTempDir } from "../test-helpers.ts";
18
- import { getPricingForModel, estimateCost as pricingEstimateCost } from "./pricing.ts";
19
- import { estimateCost, parseTranscriptUsage } from "./transcript.ts";
18
+ import { estimateCost, getPricingForModel } from "./pricing.ts";
19
+ import { parseTranscriptUsage } from "./transcript.ts";
20
20
 
21
21
  let tempDir: string;
22
22
 
@@ -479,17 +479,5 @@ describe("getPricingForModel", () => {
479
479
  });
480
480
  });
481
481
 
482
- // === re-export parity ===
483
-
484
- describe("estimateCost re-export parity", () => {
485
- test("transcript.estimateCost and pricing.estimateCost produce same result", () => {
486
- const usage = {
487
- inputTokens: 1_000_000,
488
- outputTokens: 1_000_000,
489
- cacheReadTokens: 1_000_000,
490
- cacheCreationTokens: 1_000_000,
491
- modelUsed: "claude-opus-4-6",
492
- };
493
- expect(estimateCost(usage)).toBe(pricingEstimateCost(usage));
494
- });
495
- });
482
+ // estimateCost re-export removed from transcript.ts (overstory-aa00).
483
+ // estimateCost is now imported directly from pricing.ts everywhere.
@@ -27,8 +27,6 @@ import type { TokenUsage } from "./pricing.ts";
27
27
 
28
28
  export type TranscriptUsage = TokenUsage;
29
29
 
30
- export { estimateCost } from "./pricing.ts";
31
-
32
30
  /**
33
31
  * Narrow an unknown value to determine if it looks like a transcript assistant entry.
34
32
  * Returns the usage fields if valid, or null otherwise.
@@ -651,7 +651,7 @@ describe("ClaudeRuntime integration: registry resolves 'claude' as default", ()
651
651
 
652
652
  test("getRuntime rejects unknown runtimes", async () => {
653
653
  const { getRuntime } = await import("./registry.ts");
654
- expect(() => getRuntime("opencode")).toThrow('Unknown runtime: "opencode"');
655
654
  expect(() => getRuntime("aider")).toThrow('Unknown runtime: "aider"');
655
+ expect(() => getRuntime("cursor")).toThrow('Unknown runtime: "cursor"');
656
656
  });
657
657
  });
@@ -5,7 +5,8 @@
5
5
  import { mkdir } from "node:fs/promises";
6
6
  import { join } from "node:path";
7
7
  import { deployHooks } from "../agents/hooks-deployer.ts";
8
- import { estimateCost, parseTranscriptUsage } from "../metrics/transcript.ts";
8
+ import { estimateCost } from "../metrics/pricing.ts";
9
+ import { parseTranscriptUsage } from "../metrics/transcript.ts";
9
10
  import type { ResolvedModel } from "../types.ts";
10
11
  import type {
11
12
  AgentRuntime,
@@ -219,6 +220,22 @@ export class ClaudeRuntime implements AgentRuntime {
219
220
  buildEnv(model: ResolvedModel): Record<string, string> {
220
221
  return model.env ?? {};
221
222
  }
223
+
224
+ /**
225
+ * Return the Claude Code transcript directory for a given project root.
226
+ *
227
+ * Claude Code stores session transcripts at ~/.claude/projects/<projectKey>/
228
+ * where <projectKey> is the project root path with "/" replaced by "-".
229
+ *
230
+ * @param projectRoot - Absolute path to the project root
231
+ * @returns Absolute path to the transcript directory, or null if HOME is unavailable
232
+ */
233
+ getTranscriptDir(projectRoot: string): string | null {
234
+ const home = process.env.HOME ?? "";
235
+ if (home.length === 0) return null;
236
+ const projectKey = projectRoot.replace(/\//g, "-");
237
+ return join(home, ".claude", "projects", projectKey);
238
+ }
222
239
  }
223
240
 
224
241
  /** Singleton instance for use in callers that do not need DI. */
@@ -230,4 +230,9 @@ export class CodexRuntime implements AgentRuntime {
230
230
  buildEnv(model: ResolvedModel): Record<string, string> {
231
231
  return model.env ?? {};
232
232
  }
233
+
234
+ /** Codex does not produce transcript files. */
235
+ getTranscriptDir(_projectRoot: string): string | null {
236
+ return null;
237
+ }
233
238
  }
@@ -223,4 +223,9 @@ export class CopilotRuntime implements AgentRuntime {
223
223
  buildEnv(model: ResolvedModel): Record<string, string> {
224
224
  return model.env ?? {};
225
225
  }
226
+
227
+ /** Copilot does not produce transcript files. */
228
+ getTranscriptDir(_projectRoot: string): string | null {
229
+ return null;
230
+ }
226
231
  }
@@ -232,4 +232,9 @@ export class GeminiRuntime implements AgentRuntime {
232
232
  buildEnv(model: ResolvedModel): Record<string, string> {
233
233
  return model.env ?? {};
234
234
  }
235
+
236
+ /** Gemini does not produce transcript files. */
237
+ getTranscriptDir(_projectRoot: string): string | null {
238
+ return null;
239
+ }
235
240
  }