● Zion Boggan zionboggan.com ↗

Release 0.10.5: --each skips empty sessions instead of aborting the batch

f844a26   Zion Boggan committed on Jul 4, 2026 (1 month ago)
CHANGELOG.md +6 -0
@@ -2,6 +2,12 @@
Notable changes to TreeTrace. The format follows Keep a Changelog, and the project uses semantic versioning.
+## 0.10.5 - 2026-07-04
+
+### Fixed
+
+- `--each` no longer aborts the whole batch when one session has no human prompts (for example a title-only session abandoned before the first message). The empty session is skipped with a log line, every non-empty session still gets its bundle, the `INDEX.md` / `index.json` manifests are written, and the run exits 0. Previously the batch died mid-run after some bundles were already written, leaving no manifest and a non-zero exit, which also broke unattended and CI runs over real session directories where abandoned sessions are common.
+
## 0.10.2 - 2026-06-20
### Documentation
package.json +1 -1
@@ -1,6 +1,6 @@
{
"name": "treetrace",
- "version": "0.10.4",
+ "version": "0.10.5",
"description": "Catch every time your AI coding agent touches auth, secrets, or skips a test, then turn the correction you made into a local regression eval. Local-first, deterministic, no LLM judge.",
"keywords": [
"security",
src/cli.js +1 -1
@@ -496,7 +496,7 @@ async function runEach(opts, projectDir, projectName, log) {
try {
built = await treeFromSessions([session], opts, projectDir, log, { forceAuto: true });
} catch (err) {
- if (err instanceof TreetraceError && err.code === ExitCode.NO_DATA) {
+ if (err instanceof TreetraceError && err.exitCode === ExitCode.NO_DATA) {
log(c.dim(` skip ${session.sessionId || `session-${idx}`}: nothing to trace`));
continue;
}
test/treetrace.test.js +25 -0
@@ -2593,6 +2593,31 @@ test('--each writes one report bundle per session plus index manifests', async (
}
});
+test('--each skips a session with no prompts instead of aborting the batch', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'tt-each-empty-'));
+ const here = dirname(fileURLToPath(import.meta.url));
+ const claudeFix = join(here, 'fixtures', 'synthetic-session.jsonl');
+ // a title-only Claude session (abandoned before the first prompt): one summary line, no turns
+ const empty = join(dir, 'title-only.jsonl');
+ writeFileSync(empty, '{"type":"summary","summary":"abandoned session","leafUuid":"11111111-2222-3333-4444-555555555555"}\n');
+ const outDir = join(dir, 'reports');
+ try {
+ // good session first, then empty: pre-fix this aborted after writing the first
+ // bundle (err.code vs err.exitCode), leaving no INDEX and a non-zero exit
+ await main(['--each', '--from', 'claude', '--file', claudeFix, empty, '--out-dir', outDir, '--dir', dir, '--quiet']);
+ assert.ok(existsSync(join(outDir, 'INDEX.md')), 'INDEX.md written despite the empty session');
+ const index = JSON.parse(readFileSync(join(outDir, 'index.json'), 'utf8'));
+ assert.equal(index.sessionCount, 1, 'only the real session lands in the manifest');
+ // same batch with the empty session FIRST must behave identically
+ const outDir2 = join(dir, 'reports2');
+ await main(['--each', '--from', 'claude', '--file', empty, claudeFix, '--out-dir', outDir2, '--dir', dir, '--quiet']);
+ const index2 = JSON.parse(readFileSync(join(outDir2, 'index.json'), 'utf8'));
+ assert.equal(index2.sessionCount, 1, 'order-independent: empty-first batch still completes');
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
test('--each collides labels safely when session ids repeat', async () => {
const dir = mkdtempSync(join(tmpdir(), 'tt-each-dup-'));
// two plain transcripts with the SAME basename in different subdirs -> same sessionId label