@arcreflex/agent-transcripts 0.1.4 → 0.1.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.
package/CLAUDE.md ADDED
@@ -0,0 +1,10 @@
1
+ # agent-transcripts
2
+
3
+ @README.md
4
+
5
+ ## Verification
6
+
7
+ Before committing:
8
+ 1. `bun run check` (typecheck + prettier)
9
+ 2. `bun run test` (snapshot tests)
10
+ 3. Check for documentation drift in README.md
package/README.md CHANGED
@@ -15,10 +15,14 @@ src/
15
15
  cli.ts # CLI entry point, subcommand routing
16
16
  parse.ts # Source → intermediate JSON
17
17
  render.ts # Intermediate JSON → markdown
18
+ convert.ts # Full pipeline with provenance tracking
18
19
  sync.ts # Batch sync sessions → markdown
19
20
  types.ts # Core types (Transcript, Message, Adapter)
20
21
  adapters/ # Source format adapters (currently: claude-code)
21
- utils/ # Helpers (summary extraction)
22
+ utils/
23
+ naming.ts # Descriptive output file naming
24
+ provenance.ts # Source tracking via YAML front matter
25
+ summary.ts # Summary extraction
22
26
  test/
23
27
  fixtures/ # Snapshot test inputs/outputs
24
28
  snapshots.test.ts
@@ -32,6 +36,22 @@ bun run test # snapshot tests
32
36
  bun run format # auto-format
33
37
  ```
34
38
 
39
+ ## CLI Usage
40
+
41
+ ```bash
42
+ # Subcommands (convert is default if omitted)
43
+ agent-transcripts convert <file> # Full pipeline: parse → render
44
+ agent-transcripts parse <file> # Source → intermediate JSON
45
+ agent-transcripts render <file> # JSON → markdown
46
+ agent-transcripts sync <dir> -o <out> # Batch sync sessions
47
+
48
+ # Use "-" for stdin
49
+ cat session.jsonl | agent-transcripts -
50
+
51
+ # Environment variables
52
+ OPENROUTER_API_KEY=... # Enables LLM-based descriptive output naming
53
+ ```
54
+
35
55
  ## Architecture
36
56
 
37
57
  Two-stage pipeline: Parse (source → JSON) → Render (JSON → markdown).
@@ -39,6 +59,9 @@ Two-stage pipeline: Parse (source → JSON) → Render (JSON → markdown).
39
59
  - Adapters handle source formats (see `src/adapters/index.ts` for registry)
40
60
  - Auto-detection: paths containing `.claude/` → claude-code adapter
41
61
  - Branching conversations preserved via `parentMessageRef` on messages
62
+ - Provenance tracking: rendered markdown includes YAML front matter with source path
63
+ - Descriptive naming: output files named by date + summary (LLM-enhanced if API key set)
64
+ - Sync uses mtime to skip unchanged sources
42
65
 
43
66
  ## Key Types
44
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcreflex/agent-transcripts",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Transform AI coding agent session files into readable transcripts",
5
5
  "type": "module",
6
6
  "repository": {
package/src/cli.ts CHANGED
@@ -15,15 +15,16 @@ import {
15
15
  import { parse, parseToTranscripts } from "./parse.ts";
16
16
  import { render, renderTranscript } from "./render.ts";
17
17
  import { sync } from "./sync.ts";
18
+ import { convertToDirectory } from "./convert.ts";
18
19
 
19
20
  // Read OpenRouter API key from environment for LLM-based slug generation
20
21
  const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
21
22
 
22
23
  // Shared options
23
24
  const inputArg = positional({
24
- type: optional(string),
25
+ type: string,
25
26
  displayName: "file",
26
- description: "Input file (reads from stdin if not provided)",
27
+ description: "Input file (use - for stdin)",
27
28
  });
28
29
 
29
30
  const outputOpt = option({
@@ -100,7 +101,7 @@ const syncCmd = command({
100
101
  type: string,
101
102
  long: "output",
102
103
  short: "o",
103
- description: "Output directory (mirrors source structure)",
104
+ description: "Output directory for transcripts",
104
105
  }),
105
106
  force: flag({
106
107
  long: "force",
@@ -114,14 +115,24 @@ const syncCmd = command({
114
115
  }),
115
116
  },
116
117
  async handler({ source, output, force, quiet }) {
117
- await sync({ source, output, force, quiet });
118
+ const naming = OPENROUTER_API_KEY
119
+ ? { apiKey: OPENROUTER_API_KEY }
120
+ : undefined;
121
+ await sync({ source, output, force, quiet, naming });
118
122
  },
119
123
  });
120
124
 
121
- // Default command: full pipeline (parse → render)
122
- const defaultCmd = command({
123
- name: "agent-transcripts",
124
- description: "Transform agent session files to readable transcripts",
125
+ /**
126
+ * Check if output looks like a directory (no extension) vs a specific file.
127
+ */
128
+ function isDirectoryOutput(output: string): boolean {
129
+ return !output.match(/\.\w+$/);
130
+ }
131
+
132
+ // Convert subcommand: full pipeline (parse → render) - the default
133
+ const convertCmd = command({
134
+ name: "convert",
135
+ description: "Full pipeline: parse source and render to markdown (default)",
125
136
  args: {
126
137
  input: inputArg,
127
138
  output: outputOpt,
@@ -133,15 +144,24 @@ const defaultCmd = command({
133
144
  ? { apiKey: OPENROUTER_API_KEY }
134
145
  : undefined;
135
146
 
136
- if (output) {
137
- // Write intermediate JSON and markdown files
147
+ if (output && isDirectoryOutput(output)) {
148
+ // Directory output: use sync-like behavior with provenance tracking
149
+ await convertToDirectory({
150
+ input,
151
+ outputDir: output,
152
+ adapter,
153
+ head,
154
+ naming,
155
+ });
156
+ } else if (output) {
157
+ // Explicit file output: write intermediate JSON and markdown
138
158
  const { outputPaths } = await parse({ input, output, adapter, naming });
139
159
  for (const jsonPath of outputPaths) {
140
160
  const mdPath = jsonPath.replace(/\.json$/, ".md");
141
161
  await render({ input: jsonPath, output: mdPath, head });
142
162
  }
143
163
  } else {
144
- // Stream to stdout - no intermediate files
164
+ // No output: stream to stdout
145
165
  const { transcripts } = await parseToTranscripts({ input, adapter });
146
166
  for (let i = 0; i < transcripts.length; i++) {
147
167
  if (i > 0) console.log(); // blank line between transcripts
@@ -151,25 +171,29 @@ const defaultCmd = command({
151
171
  },
152
172
  });
153
173
 
174
+ const SUBCOMMANDS = ["convert", "parse", "render", "sync"] as const;
175
+
154
176
  // Main CLI with subcommands
155
177
  const cli = subcommands({
156
178
  name: "agent-transcripts",
157
179
  description: "Transform agent session files to readable transcripts",
158
180
  cmds: {
181
+ convert: convertCmd,
159
182
  parse: parseCmd,
160
183
  render: renderCmd,
161
184
  sync: syncCmd,
162
185
  },
163
- // Default command when no subcommand is specified
164
186
  });
165
187
 
166
188
  // Run CLI
167
189
  const args = process.argv.slice(2);
168
190
 
169
- // Check if first arg is a subcommand
170
- if (args[0] === "parse" || args[0] === "render" || args[0] === "sync") {
171
- run(cli, args);
172
- } else {
173
- // Run default command for full pipeline
174
- run(defaultCmd, args);
175
- }
191
+ // If first arg isn't a subcommand (and isn't a help flag), prepend "convert" as the default
192
+ const isSubcommand =
193
+ args.length > 0 &&
194
+ SUBCOMMANDS.includes(args[0] as (typeof SUBCOMMANDS)[number]);
195
+ const isHelpFlag =
196
+ args.length === 0 || args[0] === "--help" || args[0] === "-h";
197
+ const effectiveArgs = isSubcommand || isHelpFlag ? args : ["convert", ...args];
198
+
199
+ run(cli, effectiveArgs);
package/src/convert.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Convert command: full pipeline with provenance tracking.
3
+ *
4
+ * When output is a directory, uses the same replace-existing behavior
5
+ * as sync: scans for existing outputs by provenance and replaces them.
6
+ */
7
+
8
+ import { dirname, join, resolve } from "path";
9
+ import { mkdir, stat } from "fs/promises";
10
+ import { parseToTranscripts } from "./parse.ts";
11
+ import { renderTranscript } from "./render.ts";
12
+ import { generateOutputName, type NamingOptions } from "./utils/naming.ts";
13
+ import {
14
+ findExistingOutputs,
15
+ deleteExistingOutputs,
16
+ } from "./utils/provenance.ts";
17
+
18
+ export interface ConvertToDirectoryOptions {
19
+ input: string;
20
+ outputDir: string;
21
+ adapter?: string;
22
+ head?: string;
23
+ naming?: NamingOptions;
24
+ }
25
+
26
+ /**
27
+ * Convert source file to markdown in output directory.
28
+ * Uses provenance tracking to replace existing outputs.
29
+ */
30
+ export async function convertToDirectory(
31
+ options: ConvertToDirectoryOptions,
32
+ ): Promise<void> {
33
+ const { input, outputDir, adapter, head, naming } = options;
34
+
35
+ // Parse input to transcripts
36
+ const { transcripts, inputPath } = await parseToTranscripts({
37
+ input,
38
+ adapter,
39
+ });
40
+
41
+ // Resolve absolute source path for provenance tracking
42
+ const sourcePath = inputPath === "<stdin>" ? "<stdin>" : resolve(inputPath);
43
+
44
+ // Find and delete existing outputs for this source
45
+ if (sourcePath !== "<stdin>") {
46
+ const existingOutputs = await findExistingOutputs(outputDir, sourcePath);
47
+ if (existingOutputs.length > 0) {
48
+ await deleteExistingOutputs(existingOutputs);
49
+ }
50
+ }
51
+
52
+ // Generate fresh outputs
53
+ for (let i = 0; i < transcripts.length; i++) {
54
+ const transcript = transcripts[i];
55
+ const suffix = transcripts.length > 1 ? `_${i + 1}` : undefined;
56
+
57
+ // Generate descriptive name
58
+ const baseName = await generateOutputName(
59
+ transcript,
60
+ inputPath,
61
+ naming || {},
62
+ );
63
+ const finalName = suffix ? `${baseName}${suffix}` : baseName;
64
+ const outputPath = join(outputDir, `${finalName}.md`);
65
+
66
+ // Ensure output directory exists
67
+ await mkdir(dirname(outputPath), { recursive: true });
68
+
69
+ // Render with provenance front matter
70
+ const markdown = renderTranscript(transcript, {
71
+ head,
72
+ sourcePath: sourcePath !== "<stdin>" ? sourcePath : undefined,
73
+ });
74
+ await Bun.write(outputPath, markdown);
75
+
76
+ console.error(`Wrote: ${outputPath}`);
77
+ }
78
+ }
package/src/parse.ts CHANGED
@@ -9,7 +9,7 @@ import { detectAdapter, getAdapter, listAdapters } from "./adapters/index.ts";
9
9
  import { generateOutputName, type NamingOptions } from "./utils/naming.ts";
10
10
 
11
11
  export interface ParseOptions {
12
- input?: string; // file path, undefined for stdin
12
+ input: string; // file path, or "-" for stdin
13
13
  output?: string; // output path/dir
14
14
  adapter?: string; // explicit adapter name
15
15
  naming?: NamingOptions; // options for output file naming
@@ -19,14 +19,14 @@ export interface ParseOptions {
19
19
  * Read input content from file or stdin.
20
20
  */
21
21
  async function readInput(
22
- input?: string,
22
+ input: string,
23
23
  ): Promise<{ content: string; path: string }> {
24
- if (input) {
24
+ if (input !== "-") {
25
25
  const content = await Bun.file(input).text();
26
26
  return { content, path: input };
27
27
  }
28
28
 
29
- // Read from stdin
29
+ // Read from stdin (when input is "-")
30
30
  const chunks: string[] = [];
31
31
  const reader = Bun.stdin.stream().getReader();
32
32
 
@@ -115,7 +115,7 @@ export async function parseToTranscripts(
115
115
 
116
116
  // Determine adapter
117
117
  let adapterName = options.adapter;
118
- if (!adapterName && options.input) {
118
+ if (!adapterName && options.input !== "-") {
119
119
  adapterName = detectAdapter(options.input);
120
120
  }
121
121
 
package/src/render.ts CHANGED
@@ -7,7 +7,7 @@ import { mkdir } from "fs/promises";
7
7
  import type { Transcript, Message, ToolCall } from "./types.ts";
8
8
 
9
9
  export interface RenderOptions {
10
- input?: string; // file path, undefined for stdin
10
+ input: string; // file path, or "-" for stdin
11
11
  output?: string; // output path
12
12
  head?: string; // render branch ending at this message ID
13
13
  }
@@ -16,12 +16,12 @@ export interface RenderOptions {
16
16
  * Read transcript from file or stdin.
17
17
  */
18
18
  async function readTranscript(
19
- input?: string,
19
+ input: string,
20
20
  ): Promise<{ transcript: Transcript; path: string }> {
21
21
  let content: string;
22
22
  let path: string;
23
23
 
24
- if (input) {
24
+ if (input !== "-") {
25
25
  content = await Bun.file(input).text();
26
26
  path = input;
27
27
  } else {
@@ -213,15 +213,33 @@ function tracePath(target: string, parents: Map<string, string>): string[] {
213
213
  return path;
214
214
  }
215
215
 
216
+ export interface RenderTranscriptOptions {
217
+ head?: string; // render branch ending at this message ID
218
+ sourcePath?: string; // absolute source path for front matter provenance
219
+ }
220
+
216
221
  /**
217
222
  * Render transcript to markdown with branch awareness.
218
223
  */
219
224
  export function renderTranscript(
220
225
  transcript: Transcript,
221
- head?: string,
226
+ options: RenderTranscriptOptions | string = {},
222
227
  ): string {
228
+ // Support legacy signature: renderTranscript(transcript, head?: string)
229
+ const opts: RenderTranscriptOptions =
230
+ typeof options === "string" ? { head: options } : options;
231
+ const { head, sourcePath } = opts;
232
+
223
233
  const lines: string[] = [];
224
234
 
235
+ // YAML front matter (for provenance tracking)
236
+ if (sourcePath) {
237
+ lines.push("---");
238
+ lines.push(`source: ${sourcePath}`);
239
+ lines.push("---");
240
+ lines.push("");
241
+ }
242
+
225
243
  // Header
226
244
  lines.push("# Transcript");
227
245
  lines.push("");
package/src/sync.ts CHANGED
@@ -3,21 +3,29 @@
3
3
  *
4
4
  * Discovers session files in source directory, parses them,
5
5
  * and writes rendered markdown to output directory.
6
- * Output structure mirrors source structure with extension changed.
6
+ * Uses LLM-generated descriptive names when API key is available.
7
+ * Tracks provenance via YAML front matter to correlate updates.
7
8
  */
8
9
 
9
10
  import { Glob } from "bun";
10
- import { dirname, join, relative } from "path";
11
+ import { dirname, join } from "path";
11
12
  import { mkdir, stat } from "fs/promises";
12
13
  import { getAdapters } from "./adapters/index.ts";
13
14
  import type { Adapter } from "./types.ts";
14
15
  import { renderTranscript } from "./render.ts";
16
+ import { generateOutputName, type NamingOptions } from "./utils/naming.ts";
17
+ import {
18
+ scanOutputDirectory,
19
+ deleteExistingOutputs,
20
+ hasStaleOutputs,
21
+ } from "./utils/provenance.ts";
15
22
 
16
23
  export interface SyncOptions {
17
24
  source: string;
18
25
  output: string;
19
26
  force?: boolean;
20
27
  quiet?: boolean;
28
+ naming?: NamingOptions;
21
29
  }
22
30
 
23
31
  export interface SyncResult {
@@ -65,49 +73,26 @@ async function discoverForAdapter(
65
73
  return sessions;
66
74
  }
67
75
 
68
- /**
69
- * Compute output path for a session file.
70
- * Mirrors input structure, changing extension to .md.
71
- */
72
- function computeOutputPath(
73
- relativePath: string,
74
- outputDir: string,
75
- suffix?: string,
76
- ): string {
77
- // Replace extension with .md
78
- const mdPath = relativePath.replace(/\.[^.]+$/, ".md");
79
- // Add suffix if provided (for multiple transcripts from same file)
80
- const finalPath = suffix ? mdPath.replace(/\.md$/, `${suffix}.md`) : mdPath;
81
- return join(outputDir, finalPath);
82
- }
83
-
84
- /**
85
- * Check if output file needs to be re-rendered based on mtime.
86
- */
87
- async function needsSync(
88
- outputPath: string,
89
- sourceMtime: number,
90
- force: boolean,
91
- ): Promise<boolean> {
92
- if (force) return true;
93
-
94
- try {
95
- const outputStat = await stat(outputPath);
96
- return outputStat.mtime.getTime() < sourceMtime;
97
- } catch {
98
- // Output doesn't exist, needs sync
99
- return true;
100
- }
101
- }
102
-
103
76
  /**
104
77
  * Sync session files from source to output directory.
105
78
  */
106
79
  export async function sync(options: SyncOptions): Promise<SyncResult> {
107
- const { source, output, force = false, quiet = false } = options;
80
+ const { source, output, force = false, quiet = false, naming } = options;
108
81
 
109
82
  const result: SyncResult = { synced: 0, skipped: 0, errors: 0 };
110
83
 
84
+ // Scan output directory for existing transcripts (source → output paths)
85
+ const existingOutputs = await scanOutputDirectory(output);
86
+ if (!quiet && existingOutputs.size > 0) {
87
+ const totalFiles = [...existingOutputs.values()].reduce(
88
+ (sum, paths) => sum + paths.length,
89
+ 0,
90
+ );
91
+ console.error(
92
+ `Found ${totalFiles} existing transcript(s) from ${existingOutputs.size} source(s)`,
93
+ );
94
+ }
95
+
111
96
  // Discover sessions for each adapter
112
97
  const sessions: SessionFile[] = [];
113
98
  for (const adapter of getAdapters()) {
@@ -126,37 +111,58 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
126
111
  const content = await Bun.file(session.path).text();
127
112
  const transcripts = session.adapter.parse(content, session.path);
128
113
 
129
- // Process each transcript (usually just one per file)
114
+ // Get all existing outputs for this source
115
+ const existingPaths = existingOutputs.get(session.path) || [];
116
+
117
+ // Check if sync needed (force, count mismatch, or any stale)
118
+ const needsUpdate =
119
+ force ||
120
+ (await hasStaleOutputs(
121
+ existingPaths,
122
+ transcripts.length,
123
+ session.mtime,
124
+ ));
125
+ if (!needsUpdate) {
126
+ if (!quiet) {
127
+ console.error(`Skip (up to date): ${session.relativePath}`);
128
+ }
129
+ result.skipped++;
130
+ continue;
131
+ }
132
+
133
+ // Delete existing outputs before regenerating
134
+ await deleteExistingOutputs(existingPaths, quiet);
135
+
136
+ // Generate fresh outputs for all transcripts
130
137
  for (let i = 0; i < transcripts.length; i++) {
131
138
  const transcript = transcripts[i];
132
139
  const suffix = transcripts.length > 1 ? `_${i + 1}` : undefined;
133
- const outputPath = computeOutputPath(
134
- session.relativePath,
135
- output,
136
- suffix,
137
- );
138
140
 
139
- // Check if sync needed
140
- if (!(await needsSync(outputPath, session.mtime, force))) {
141
- if (!quiet) {
142
- console.error(`Skip (up to date): ${outputPath}`);
143
- }
144
- result.skipped++;
145
- continue;
146
- }
141
+ // Generate descriptive name, preserving directory structure
142
+ const baseName = await generateOutputName(
143
+ transcript,
144
+ session.path,
145
+ naming || {},
146
+ );
147
+ const finalName = suffix ? `${baseName}${suffix}` : baseName;
148
+ const relativeDir = dirname(session.relativePath);
149
+ const outputPath = join(output, relativeDir, `${finalName}.md`);
147
150
 
148
151
  // Ensure output directory exists
149
152
  await mkdir(dirname(outputPath), { recursive: true });
150
153
 
151
- // Render and write
152
- const markdown = renderTranscript(transcript);
154
+ // Render with provenance front matter and write
155
+ const markdown = renderTranscript(transcript, {
156
+ sourcePath: session.path,
157
+ });
153
158
  await Bun.write(outputPath, markdown);
154
159
 
155
160
  if (!quiet) {
156
161
  console.error(`Synced: ${outputPath}`);
157
162
  }
158
- result.synced++;
159
163
  }
164
+
165
+ result.synced++;
160
166
  } catch (error) {
161
167
  const message = error instanceof Error ? error.message : String(error);
162
168
  console.error(`Error: ${session.relativePath}: ${message}`);
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Provenance tracking utilities.
3
+ *
4
+ * Tracks the relationship between source files and output transcripts
5
+ * via YAML front matter, enabling update-in-place behavior.
6
+ */
7
+
8
+ import { Glob } from "bun";
9
+ import { join } from "path";
10
+ import { stat, unlink } from "fs/promises";
11
+
12
+ /**
13
+ * Extract source path from YAML front matter.
14
+ * Returns null if no front matter or no source field.
15
+ */
16
+ export function extractSourceFromFrontMatter(content: string): string | null {
17
+ // Match YAML front matter at start of file
18
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
19
+ if (!match) return null;
20
+
21
+ // Extract source field (simple line-based parsing)
22
+ const frontMatter = match[1];
23
+ const sourceLine = frontMatter
24
+ .split("\n")
25
+ .find((line) => line.startsWith("source:"));
26
+ if (!sourceLine) return null;
27
+
28
+ return sourceLine.replace(/^source:\s*/, "").trim();
29
+ }
30
+
31
+ /**
32
+ * Scan output directory for existing transcripts.
33
+ * Returns map from absolute source path → all output file paths for that source.
34
+ */
35
+ export async function scanOutputDirectory(
36
+ outputDir: string,
37
+ ): Promise<Map<string, string[]>> {
38
+ const sourceToOutputs = new Map<string, string[]>();
39
+ const glob = new Glob("**/*.md");
40
+
41
+ for await (const file of glob.scan({ cwd: outputDir, absolute: false })) {
42
+ const fullPath = join(outputDir, file);
43
+ try {
44
+ const content = await Bun.file(fullPath).text();
45
+ const sourcePath = extractSourceFromFrontMatter(content);
46
+ if (sourcePath) {
47
+ const existing = sourceToOutputs.get(sourcePath) || [];
48
+ existing.push(fullPath);
49
+ sourceToOutputs.set(sourcePath, existing);
50
+ }
51
+ } catch {
52
+ // Skip files we can't read
53
+ }
54
+ }
55
+
56
+ return sourceToOutputs;
57
+ }
58
+
59
+ /**
60
+ * Find existing outputs for a specific source path.
61
+ */
62
+ export async function findExistingOutputs(
63
+ outputDir: string,
64
+ sourcePath: string,
65
+ ): Promise<string[]> {
66
+ const allOutputs = await scanOutputDirectory(outputDir);
67
+ return allOutputs.get(sourcePath) || [];
68
+ }
69
+
70
+ /**
71
+ * Delete existing output files, with warnings on failure.
72
+ */
73
+ export async function deleteExistingOutputs(
74
+ paths: string[],
75
+ quiet = false,
76
+ ): Promise<void> {
77
+ for (const oldPath of paths) {
78
+ try {
79
+ await unlink(oldPath);
80
+ if (!quiet) {
81
+ console.error(`Deleted: ${oldPath}`);
82
+ }
83
+ } catch (err) {
84
+ // Warn but continue - file may already be gone or have permission issues
85
+ const msg = err instanceof Error ? err.message : String(err);
86
+ console.error(`Warning: could not delete ${oldPath}: ${msg}`);
87
+ }
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Check if any outputs are stale relative to source mtime.
93
+ */
94
+ export async function hasStaleOutputs(
95
+ existingOutputs: string[],
96
+ expectedCount: number,
97
+ sourceMtime: number,
98
+ ): Promise<boolean> {
99
+ if (existingOutputs.length !== expectedCount) return true;
100
+
101
+ for (const outputPath of existingOutputs) {
102
+ try {
103
+ const outputStat = await stat(outputPath);
104
+ if (outputStat.mtime.getTime() < sourceMtime) {
105
+ return true;
106
+ }
107
+ } catch {
108
+ // Output doesn't exist
109
+ return true;
110
+ }
111
+ }
112
+
113
+ return false;
114
+ }