Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,17 @@ All notable changes to **basou** are recorded here. The project follows
is deleted. The active time `basou stats` bills and the board's active
time are unions of time, which the copies overlapped, so they hardly
change; the per-session sums of active time and the output tokens do. The
order is read from the files' birth times on every import, so if they
change after an import (restored from a backup, or copied to another disk)
and the order flips, the shared records end up counted in neither session.
birth times are read from the files on every import and can change after
one (restored from a backup, or copied to another disk), so the
transcripts already imported keep the order in which their sessions begin,
and a transcript not imported yet that holds a copy of what an imported
fork's session counts comes after that fork. Three cases are left, which
`docs/spec/terminal-and-import.md` describes: two sessions an earlier
basou imported with the shared records are still ordered by birth time
(counted once either way); a fork not imported yet whose birth time
becomes earlier than its imported original's takes the shared records from
it; and an original never imported whose birth time becomes later than its
fork's is imported without them, so they count in neither session.
- **A key no version of its kind takes, in a measure or an observation of
a board, is refused with the keys the kind takes**, in the version the
board is read by:
Expand Down
33 changes: 27 additions & 6 deletions docs/spec/terminal-and-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,12 +155,33 @@ transcript, so a fork's copies stay out after the older transcript is deleted.
A fork imported with its copies whose older transcript was deleted before it
could be re-derived cannot be told apart from an original, and keeps them.

The order within a conversation is read from the files on every import. If the
birth times change after an import (the transcripts are restored from a backup,
or copied to another disk) so that the order flips, the transcript now taken as
the later one is re-derived without the shared records, while the one now taken
as the earlier keeps the session it has, so the shared records are then counted
in neither session.
The birth times are read from the files on every import, and they can change
after one (the transcripts are restored from a backup, or copied to another
disk). The transcripts already imported therefore keep the order in which
their sessions begin: the session that took the shared records begins at or
before the conversation's first record, and a fork imported without them
begins after the last of them, so a later change of their birth times moves no
record from one session to another, between an original and its fork or
between two forks. A transcript not imported yet takes its place among them by
its birth time, except that one holding a copy of a record an imported fork's
session counts comes after that fork: it forked from that fork, or from a fork
of it.

Three cases are left that the order cannot recover from the files:

- Two sessions that begin together, as an earlier basou left a fork imported
with the shared records beside its original, are ordered by birth time. If
those flip, the original is re-derived without the shared records and the
fork keeps them; they are counted once either way.
- A fork not imported yet whose birth time becomes earlier than its imported
original's takes the shared records: the original's session is re-derived
without them, and the fork's session holds them. A `--session` import that
names only the fork counts them in both sessions until an import covers the
original again.
- An original that was never imported (for example, a `--session` import took
only its fork) and whose birth time becomes later than the fork's is
imported without the shared records, which the fork's session does not hold
either, so they are counted in neither session.

## §14.3 Multi-root source roots

Expand Down
280 changes: 280 additions & 0 deletions packages/cli/src/commands/import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1260,6 +1260,9 @@ describe("basou import claude-code — scoped re-import of a grown source", () =
const ORIGINAL_ID = "zzzzzzzz-original";
const FORK_ID = "aaaaaaaa-fork";
const CREATED_AT_MS: Record<string, number> = { [ORIGINAL_ID]: 1_000, [FORK_ID]: 2_000 };
/** The same two files after a restore that gave the fork the earlier birth time. */
const FLIPPED_CREATED_AT_MS: Record<string, number> = { [ORIGINAL_ID]: 2_000, [FORK_ID]: 1_000 };
const FORK_OF_FORK_ID = "bbbbbbbb-fork-of-fork";

function uuid(n: number): string {
return `4a1f0c2e-0000-4000-8000-${String(n).padStart(12, "0")}`;
Expand Down Expand Up @@ -1329,6 +1332,28 @@ function conversationStart(repo: string, sessionId: string): Array<Record<string
];
}

/** An assistant record that runs one Bash command. */
function commandRecord(
repo: string,
n: number,
timestamp: string,
sessionId: string,
command: string,
): Record<string, unknown> {
return {
type: "assistant",
uuid: uuid(n),
timestamp,
cwd: repo,
sessionId,
message: {
id: `msg_${n}`,
usage: { output_tokens: 10 },
content: [{ type: "tool_use", name: "Bash", input: { command } }],
},
};
}

function originalTranscript(repo: string): Array<Record<string, unknown>> {
return [
...conversationStart(repo, ORIGINAL_ID),
Expand Down Expand Up @@ -1376,6 +1401,16 @@ function forkTranscript(repo: string, extra: Array<Record<string, unknown>> = []
];
}

/** A fork of the fork, taken after the fork's 01:00 command: it holds a copy of that too. */
function forkOfForkTranscript(repo: string): Array<Record<string, unknown>> {
return [
...forkTranscript(repo)
.slice(1)
.map((record) => ({ ...record, sessionId: FORK_OF_FORK_ID })),
commandRecord(repo, 21, "2026-05-10T03:00:00.000Z", FORK_OF_FORK_ID, "pnpm lint"),
];
}

function forkCtx(repo: string, createdAtMs: Record<string, number> = CREATED_AT_MS) {
return {
cwd: repo,
Expand Down Expand Up @@ -1784,6 +1819,251 @@ describe("basou import claude-code: forked transcripts", () => {
expect(result?.skipped_unverifiable).toBe(1);
expect(await commandsOf(repo, fork)).toEqual(["pnpm build", "pnpm test"]);
});

it("keeps the shared records where they are when the birth times flip after an import", async () => {
const repo = await setupInitedRepo();
await writeTranscript(repo, ORIGINAL_ID, originalTranscript(repo));
await writeTranscript(repo, FORK_ID, forkTranscript(repo));
await doRunImportClaudeCode({ all: true }, forkCtx(repo));
const original = await sessionIdOf(repo, ORIGINAL_ID);
const fork = await sessionIdOf(repo, FORK_ID);
const eventsOf = (sid: string) =>
readFile(join(basouPaths(repo).sessions, sid, "events.jsonl"), "utf8");
const originalBytes = await eventsOf(original);
const forkBytes = await eventsOf(fork);

// Restored from a backup: the fork's file is now the older one.
const result = await importJson(repo, { all: true }, forkCtx(repo, FLIPPED_CREATED_AT_MS));
expect(result?.reimported_count).toBe(0);
expect(result?.dropped_copies_count).toBe(0);
expect(result?.skipped_already_imported).toBe(2);
expect(await eventsOf(original)).toBe(originalBytes);
expect(await eventsOf(fork)).toBe(forkBytes);
});

it("re-imports a grown original without dropping the shared records after the birth times flip", async () => {
const repo = await setupInitedRepo();
await writeTranscript(repo, ORIGINAL_ID, originalTranscript(repo));
await writeTranscript(repo, FORK_ID, forkTranscript(repo));
await doRunImportClaudeCode({ all: true }, forkCtx(repo));
const original = await sessionIdOf(repo, ORIGINAL_ID);

await writeTranscript(repo, ORIGINAL_ID, [
...originalTranscript(repo),
commandRecord(repo, 6, "2026-05-10T03:00:00.000Z", ORIGINAL_ID, "git diff"),
]);
const result = await importJson(repo, { all: true }, forkCtx(repo, FLIPPED_CREATED_AT_MS));
expect(result?.reimported_count).toBe(1);
expect(result?.dropped_copies_count).toBe(0);
expect(await commandsOf(repo, original)).toEqual(["git status", "git log", "git diff"]);
expect(
(await readEvents(repo, original)).filter((e) => e.type === "decision_recorded"),
).toHaveLength(1);
expect((await readSession(repo, original)).session.started_at).toBe("2026-05-10T00:00:00.000Z");
expect(await commandsOf(repo, await sessionIdOf(repo, FORK_ID))).toEqual(["pnpm build"]);
});

it("keeps what a later fork copied from a fork in that fork when their birth times flip", async () => {
const repo = await setupInitedRepo();
await writeTranscript(repo, ORIGINAL_ID, originalTranscript(repo));
await writeTranscript(repo, FORK_ID, forkTranscript(repo));
// Forked from the fork after its 01:00 command, so it holds a copy of that too.
await writeTranscript(repo, FORK_OF_FORK_ID, forkOfForkTranscript(repo));
await doRunImportClaudeCode(
{ all: true },
forkCtx(repo, { [ORIGINAL_ID]: 1_000, [FORK_ID]: 2_000, [FORK_OF_FORK_ID]: 3_000 }),
);
const fork = await sessionIdOf(repo, FORK_ID);
const forkOfFork = await sessionIdOf(repo, FORK_OF_FORK_ID);
expect(await commandsOf(repo, forkOfFork)).toEqual(["pnpm lint"]);

// The two forks' files flip, and the first fork goes on.
await writeTranscript(
repo,
FORK_ID,
forkTranscript(repo, [
commandRecord(repo, 13, "2026-05-10T04:00:00.000Z", FORK_ID, "pnpm test"),
]),
);
const result = await importJson(
repo,
{ all: true },
forkCtx(repo, { [ORIGINAL_ID]: 1_000, [FORK_ID]: 3_000, [FORK_OF_FORK_ID]: 2_000 }),
);
expect(result?.reimported_count).toBe(1);
expect(await commandsOf(repo, fork)).toEqual(["pnpm build", "pnpm test"]);
expect((await readSession(repo, fork)).session.started_at).toBe("2026-05-10T01:00:00.000Z");
expect(await commandsOf(repo, forkOfFork)).toEqual(["pnpm lint"]);
expect(await commandsOf(repo, await sessionIdOf(repo, ORIGINAL_ID))).toEqual([
"git status",
"git log",
]);
});

it("--force re-creates the sessions in the order they began after the birth times flip", async () => {
const repo = await setupInitedRepo();
await writeTranscript(repo, ORIGINAL_ID, originalTranscript(repo));
await writeTranscript(repo, FORK_ID, forkTranscript(repo));
await doRunImportClaudeCode({ all: true }, forkCtx(repo));

await doRunImportClaudeCode({ all: true, force: true }, forkCtx(repo, FLIPPED_CREATED_AT_MS));
expect(await listSessionDirs(repo)).toHaveLength(2);
expect(await commandsOf(repo, await sessionIdOf(repo, ORIGINAL_ID))).toEqual([
"git status",
"git log",
]);
expect(await commandsOf(repo, await sessionIdOf(repo, FORK_ID))).toEqual(["pnpm build"]);
});

it("re-derives a fork imported with its copies beside an original imported with them too", async () => {
const repo = await setupInitedRepo();
const fork = await importForkWithCopies(repo);
// The original is imported on its own, so both sessions begin at the
// conversation's first record, as an earlier basou left them.
await writeTranscript(repo, ORIGINAL_ID, originalTranscript(repo));
await doRunImportClaudeCode({ session: ORIGINAL_ID }, forkCtx(repo));
const original = await sessionIdOf(repo, ORIGINAL_ID);
expect(await commandsOf(repo, original)).toEqual(["git status", "git log"]);
expect(await commandsOf(repo, fork)).toEqual(["git status", "pnpm build"]);

// They begin together, so their files' birth times decide.
const result = await importJson(repo, { all: true });
expect(result?.reimported_count).toBe(1);
expect(result?.dropped_copies_count).toBe(1);
expect(await commandsOf(repo, original)).toEqual(["git status", "git log"]);
expect(await commandsOf(repo, fork)).toEqual(["pnpm build"]);
});

it("imports a new fork of an imported original with only what the fork added", async () => {
const repo = await setupInitedRepo();
await writeTranscript(repo, ORIGINAL_ID, originalTranscript(repo));
await doRunImportClaudeCode({ all: true }, forkCtx(repo));
const original = await sessionIdOf(repo, ORIGINAL_ID);
const originalBytes = await readFile(
join(basouPaths(repo).sessions, original, "events.jsonl"),
"utf8",
);

await writeTranscript(repo, FORK_ID, forkTranscript(repo));
const result = await importJson(repo, { all: true });
expect(result?.imported_count).toBe(1);
expect(result?.reimported_count).toBe(0);
expect(await commandsOf(repo, await sessionIdOf(repo, FORK_ID))).toEqual(["pnpm build"]);
expect(await readFile(join(basouPaths(repo).sessions, original, "events.jsonl"), "utf8")).toBe(
originalBytes,
);
});

it("places a transcript not imported yet by its birth time", async () => {
const repo = await setupInitedRepo();
await writeTranscript(repo, ORIGINAL_ID, originalTranscript(repo));
await writeTranscript(repo, FORK_ID, forkTranscript(repo));
await doRunImportClaudeCode({ all: true }, forkCtx(repo));

// A new fork of the fork, after its 01:00 command.
await writeTranscript(repo, FORK_OF_FORK_ID, forkOfForkTranscript(repo));
const result = await importJson(
repo,
{ all: true },
forkCtx(repo, { [ORIGINAL_ID]: 1_000, [FORK_ID]: 2_000, [FORK_OF_FORK_ID]: 3_000 }),
);
expect(result?.imported_count).toBe(1);
expect(result?.skipped_already_imported).toBe(2);
expect(await commandsOf(repo, await sessionIdOf(repo, FORK_OF_FORK_ID))).toEqual(["pnpm lint"]);
});

it("places a transcript not imported yet after an imported fork it copied from, whatever its birth time", async () => {
const repo = await setupInitedRepo();
await writeTranscript(repo, ORIGINAL_ID, originalTranscript(repo));
await writeTranscript(repo, FORK_ID, forkTranscript(repo));
await doRunImportClaudeCode({ all: true }, forkCtx(repo));
const fork = await sessionIdOf(repo, FORK_ID);
await writeTranscript(repo, FORK_OF_FORK_ID, forkOfForkTranscript(repo));

// Restored in name order: the new transcript's birth time falls between
// the two imported ones, which keep the order their sessions began in.
const restored = { [FORK_ID]: 1_000, [FORK_OF_FORK_ID]: 2_000, [ORIGINAL_ID]: 3_000 };
const result = await importJson(repo, { all: true }, forkCtx(repo, restored));
expect(result?.imported_count).toBe(1);
expect(result?.reimported_count).toBe(0);
const forkOfFork = await sessionIdOf(repo, FORK_OF_FORK_ID);
expect(await commandsOf(repo, forkOfFork)).toEqual(["pnpm lint"]);
expect((await readSession(repo, forkOfFork)).session.started_at).toBe(
"2026-05-10T03:00:00.000Z",
);
expect(await commandsOf(repo, fork)).toEqual(["pnpm build"]);
expect(await commandsOf(repo, await sessionIdOf(repo, ORIGINAL_ID))).toEqual([
"git status",
"git log",
]);
});

it("keeps the shared records with the original when a new fork of a fork is restored first", async () => {
const repo = await setupInitedRepo();
await writeTranscript(repo, ORIGINAL_ID, originalTranscript(repo));
await writeTranscript(repo, FORK_ID, forkTranscript(repo));
await doRunImportClaudeCode({ all: true }, forkCtx(repo));
await writeTranscript(repo, FORK_OF_FORK_ID, forkOfForkTranscript(repo));

const restored = { [FORK_OF_FORK_ID]: 1_000, [FORK_ID]: 2_000, [ORIGINAL_ID]: 3_000 };
const result = await importJson(repo, { all: true }, forkCtx(repo, restored));
expect(result?.imported_count).toBe(1);
expect(result?.reimported_count).toBe(0);
expect(await commandsOf(repo, await sessionIdOf(repo, ORIGINAL_ID))).toEqual([
"git status",
"git log",
]);
expect(await commandsOf(repo, await sessionIdOf(repo, FORK_ID))).toEqual(["pnpm build"]);
expect(await commandsOf(repo, await sessionIdOf(repo, FORK_OF_FORK_ID))).toEqual(["pnpm lint"]);
});

it("keeps an original not imported yet before an imported fork that it holds nothing of", async () => {
const repo = await setupInitedRepo();
// The conversation is talk only until it forks, so the original is not
// imported, and the fork is imported with what it added.
const talk = (sessionId: string): Array<Record<string, unknown>> => [
{
type: "user",
uuid: uuid(1),
timestamp: "2026-05-10T00:00:00.000Z",
cwd: repo,
sessionId,
message: { role: "user", content: [{ type: "text", text: "go" }] },
},
{
type: "assistant",
uuid: uuid(2),
timestamp: "2026-05-10T00:00:01.000Z",
cwd: repo,
sessionId,
message: {
id: "msg_1",
usage: { output_tokens: 999 },
content: [{ type: "text", text: "ok" }],
},
},
];
await writeTranscript(repo, ORIGINAL_ID, talk(ORIGINAL_ID));
await writeTranscript(repo, FORK_ID, [...talk(FORK_ID), ...forkTranscript(repo).slice(-2)]);
await doRunImportClaudeCode({ all: true }, forkCtx(repo));
expect(await listSessionDirs(repo)).toHaveLength(1);
const fork = await sessionIdOf(repo, FORK_ID);
expect((await readSession(repo, fork)).session.started_at).toBe("2026-05-10T01:00:00.000Z");

// The original goes on and runs a command of its own.
await writeTranscript(repo, ORIGINAL_ID, [
...talk(ORIGINAL_ID),
commandRecord(repo, 6, "2026-05-10T02:00:00.000Z", ORIGINAL_ID, "git log"),
]);
const result = await importJson(repo, { all: true });
expect(result?.imported_count).toBe(1);
const original = await sessionIdOf(repo, ORIGINAL_ID);
expect(await commandsOf(repo, original)).toEqual(["git log"]);
const originalSession = await readSession(repo, original);
expect(originalSession.session.started_at).toBe("2026-05-10T00:00:00.000Z");
expect(originalSession.session.metrics?.output_tokens).toBe(1009);
expect(await commandsOf(repo, fork)).toEqual(["pnpm build"]);
});
});

describe("basou import codex — scoped re-import of a grown source", () => {
Expand Down
Loading
Loading