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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,32 @@
All notable changes to **basou** are recorded here. The project follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html) starting with v0.1.0.

## Unreleased

### Added

- Multi-root capture — one `.basou/` workspace can now aggregate the native
logs of several sibling repositories. This matters when a single logical
project spans multiple checkouts (e.g. an implementation repo, a planning
repo, and a shared agent working directory): the AI's sessions are recorded
under whichever directory it ran in, but the provenance belongs to one
workspace. Two ways to drive it, applied symmetrically to both the
`claude-code` and `codex` importers (and to `basou refresh`):
- `--project <path>` is now **repeatable** on `basou import claude-code`,
`basou import codex`, and `basou refresh`. Each path is a source root to
scan; passing several unions their sessions into the current workspace
(deduplicated, so a root listed twice is scanned once). Explicit
`--project` flags take precedence over the manifest setting below.
- `manifest.import.source_roots` — an optional, ordered list of source roots
**relative to the repository root** (e.g. `[".", "../basou-workspace"]`),
so `basou refresh` with no arguments aggregates every listed repo. The list
is complete: include `"."` to keep the host repository itself. Absent means
"the host repository only" (the prior behaviour, unchanged). Absolute
paths, `~`-expansion, and empty entries are rejected by the schema so the
committed manifest stays path-clean and machine-portable.
- `basou init --source-root <path>` (repeatable) seeds `import.source_roots`
for a new workspace, normalizing each value to a repo-root-relative path.

## 0.6.0 — 2026-06-05

### Added
Expand Down
27 changes: 27 additions & 0 deletions docs/spec/terminal-and-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,30 @@ basou session import --source codex
basou session import --source gemini
basou session import --source copilot
```

## §14.3 Multi-root source roots

A single logical project can span several sibling repositories — for
example an implementation repo, a planning repo, and a shared agent
working directory the AI is launched from. The AI's native logs are
recorded under whichever directory it ran in (Codex keys each rollout by
its session `cwd`; Claude Code stores transcripts under a per-project
directory), but the provenance belongs to one `.basou/` workspace.

Discovery therefore accepts a set of **source roots** instead of one:

- `--project <path>` is repeatable on `basou import claude-code`,
`basou import codex`, and `basou refresh`. Each path is a source root;
several paths union their sessions into the workspace. Resolved against
the cwd, then de-duplicated.
- `manifest.import.source_roots` is an optional, ordered list of roots
**relative to the repository root** (e.g. `[".", "../basou-workspace"]`).
`basou refresh` with no `--project` reads it. The list is complete —
include `"."` to keep the host repository. Absolute paths, `~`-expansion,
and empty entries are rejected so the committed manifest stays path-clean.
- Precedence: explicit `--project` flags, else `import.source_roots`, else
the repository root alone (the prior single-root behaviour).

Each session is sanitized against its own `working_directory`, so
aggregating sibling repos never relativizes a path across repositories or
leaks an absolute host path beyond the existing pathless contract.
183 changes: 182 additions & 1 deletion packages/cli/src/commands/import.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { devNull, tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import {
basouPaths,
Expand Down Expand Up @@ -515,3 +515,184 @@ describe("basou import codex", () => {
expect(kinds.filter((k) => k === "codex-import")).toHaveLength(1);
});
});

// --- Multi-root source roots -------------------------------------------------

/** Like setupInitedRepo, but persists `import.source_roots` in the manifest. */
async function setupInitedRepoWithSourceRoots(roots: string[]): Promise<string> {
const repo = await realpath(tmpRepo as string);
const paths = await ensureBasouDirectory(repo);
const manifest = createManifest({
workspaceName: "fixture-ws",
now: FIXED_DATE,
workspaceId: FIXED_WS_ID,
sourceRoots: roots,
});
await writeManifest(paths, manifest);
return repo;
}

/** Write a Claude transcript into the encoded per-project dir of `sourcePath`. */
async function writeTranscriptFor(
sourcePath: string,
sessionId: string,
records: Array<Record<string, unknown>>,
): Promise<void> {
const dir = join(getProjectsRoot(), sourcePath.replaceAll("/", "-"));
await mkdir(dir, { recursive: true });
await writeFile(
join(dir, `${sessionId}.jsonl`),
records.map((r) => JSON.stringify(r)).join("\n"),
);
}

/** Sorted source.external_id of every imported session in the workspace. */
async function importedExternalIds(repo: string): Promise<string[]> {
const dirs = await listSessionDirs(repo);
const ids = await Promise.all(
dirs.map(
async (d) =>
SessionSchema.parse(await readYamlFile(join(basouPaths(repo).sessions, d, "session.yaml")))
.session.source.external_id,
),
);
return ids.filter((x): x is string => typeof x === "string").sort();
}

describe("multi-root source roots", () => {
it("codex --project (repeatable) unions rollouts across roots", async () => {
const repo = await setupInitedRepo();
const sibling = join(dirname(repo), "sibling-codex");
await writeRollout("c-host", codexActionRollout(repo, "c-host"));
await writeRollout("c-sib", codexActionRollout(sibling, "c-sib"), join("2026", "05", "11"));

await doRunImportCodex(
{ all: true, project: [repo, sibling] },
{ cwd: repo, codexSessionsDir: getCodexRoot() },
);

expect(await importedExternalIds(repo)).toEqual(["c-host", "c-sib"]);
});

it("codex aggregates manifest import.source_roots with no --project", async () => {
const repo = await setupInitedRepoWithSourceRoots([".", "../sibling-codex2"]);
const sibling = join(dirname(repo), "sibling-codex2");
await writeRollout("c-host", codexActionRollout(repo, "c-host"));
await writeRollout("c-sib", codexActionRollout(sibling, "c-sib"), join("2026", "05", "11"));

await doRunImportCodex({ all: true }, { cwd: repo, codexSessionsDir: getCodexRoot() });

expect(await importedExternalIds(repo)).toEqual(["c-host", "c-sib"]);
});

it("codex --project overrides manifest source_roots", async () => {
const repo = await setupInitedRepoWithSourceRoots(["."]);
const sibling = join(dirname(repo), "sibling-codex3");
await writeRollout("c-host", codexActionRollout(repo, "c-host"));
await writeRollout("c-sib", codexActionRollout(sibling, "c-sib"), join("2026", "05", "11"));

await doRunImportCodex(
{ all: true, project: [sibling] },
{ cwd: repo, codexSessionsDir: getCodexRoot() },
);

// Only the flag's root is imported; the manifest's "." (the repo) is ignored.
expect(await importedExternalIds(repo)).toEqual(["c-sib"]);
});

it("codex de-duplicates a root listed twice", async () => {
const repo = await setupInitedRepo();
await writeRollout("c-host", codexActionRollout(repo, "c-host"));

await doRunImportCodex(
{ all: true, project: [repo, repo] },
{ cwd: repo, codexSessionsDir: getCodexRoot() },
);

expect(await importedExternalIds(repo)).toEqual(["c-host"]);
});

it("imports a sibling-root session without a '..'-escape in working_directory", async () => {
const repo = await setupInitedRepo();
const sibling = join(dirname(repo), "sibling-wd");
await writeRollout("c-sib", codexActionRollout(sibling, "c-sib"));

await doRunImportCodex(
{ all: true, project: [sibling] },
{ cwd: repo, codexSessionsDir: getCodexRoot() },
);

const dirs = await listSessionDirs(repo);
const session = SessionSchema.parse(
await readYamlFile(join(basouPaths(repo).sessions, dirs[0] as string, "session.yaml")),
);
// Each session is sanitized against its OWN cwd, never relativized against
// the host repo, so no '..'-escape leaks into the committed session.yaml.
expect(session.session.working_directory.includes("..")).toBe(false);
});

it("claude --project (repeatable) unions transcripts across roots", async () => {
const repo = await setupInitedRepo();
const sibling = join(dirname(repo), "sibling-claude");
await writeTranscriptFor(repo, "cl-host", actionTranscript(repo));
await writeTranscriptFor(sibling, "cl-sib", actionTranscript(sibling));

await doRunImportClaudeCode(
{ all: true, project: [repo, sibling] },
{ cwd: repo, claudeProjectsDir: getProjectsRoot() },
);

expect(await importedExternalIds(repo)).toEqual(["cl-host", "cl-sib"]);
});

it("claude tolerates an absent root dir when another has transcripts", async () => {
const repo = await setupInitedRepo();
const absent = join(dirname(repo), "nope-claude");
await writeTranscriptFor(repo, "cl-host", actionTranscript(repo));

await doRunImportClaudeCode(
{ all: true, project: [repo, absent] },
{ cwd: repo, claudeProjectsDir: getProjectsRoot() },
);

expect(await importedExternalIds(repo)).toEqual(["cl-host"]);
});

it("claude errors only when no root has a transcript directory", async () => {
const repo = await setupInitedRepo();
const absent1 = join(dirname(repo), "nope-1");
const absent2 = join(dirname(repo), "nope-2");

await expect(
doRunImportClaudeCode(
{ all: true, project: [absent1, absent2] },
{ cwd: repo, claudeProjectsDir: getProjectsRoot() },
),
).rejects.toThrow(/transcript directory not found/);
});

it("claude --session finds the transcript in a sibling root", async () => {
const repo = await setupInitedRepo();
const sibling = join(dirname(repo), "sibling-session");
await writeTranscriptFor(sibling, "only-sib", actionTranscript(sibling));

await doRunImportClaudeCode(
{ session: "only-sib", project: [repo, sibling] },
{ cwd: repo, claudeProjectsDir: getProjectsRoot() },
);

expect(await importedExternalIds(repo)).toEqual(["only-sib"]);
});

it("claude --session errors when no root has that transcript", async () => {
const repo = await setupInitedRepo();
await writeTranscriptFor(repo, "exists", actionTranscript(repo));

await expect(
doRunImportClaudeCode(
{ session: "missing", project: [repo] },
{ cwd: repo, claudeProjectsDir: getProjectsRoot() },
),
).rejects.toThrow(/not found for session id/);
});
});
Loading