From 721672a6345ba04a3dd02f9fd7e573711b5ab876 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=BE=E5=B1=B1=E8=B2=B4=E8=87=B3?= Date: Sun, 7 Jun 2026 12:02:59 +0900 Subject: [PATCH] basou: aggregate one .basou/ across multiple source repos A single logical project often spans several checkouts -- an implementation repo, a planning repo, and a shared working directory the AI is launched from. Each AI session is recorded under whichever directory it ran in (Codex keys each rollout by its session cwd; Claude Code stores transcripts per project directory), but the provenance belongs to one workspace. Until now import discovery keyed on a single exact source path, so a multi-repo project captured only the slice whose cwd matched the workspace's own repo root and silently dropped the rest. Let one .basou/ aggregate the native logs of multiple source roots, applied symmetrically to the claude-code and codex importers and to basou refresh: - --project is now repeatable on import claude-code, import codex, and refresh. Each path is a source root; several paths union their sessions into the workspace, de-duplicated (a root listed twice, or equal to the repo root, is scanned once). - manifest.import.source_roots: an optional, ordered list of roots relative to the repository root (e.g. ["." , "../basou-workspace"]), so refresh with no arguments aggregates every listed repo. The list is complete -- include "." to keep the host repo. Absent means the host repo only (prior behaviour, unchanged). Entries are validated by a regex that rejects absolute paths, '~' expansion, backslashes, and drive letters, so the committed manifest stays path-clean and portable; the constraint is emitted into the JSON Schema 'pattern' too. - init --source-root (repeatable) seeds source_roots for a new workspace, normalizing each value to a repo-root-relative path. Resolution precedence: explicit --project flags, else the manifest's source_roots, else the repository root alone. The path sanitizer is unchanged: each session is sanitized against its own working_directory, so aggregating siblings never relativizes a path across repositories or leaks an absolute host path. The basou view project field no longer pre-fills the repo root, so a GUI refresh honors source_roots instead of overriding them. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 26 +++ docs/spec/terminal-and-import.md | 27 +++ packages/cli/src/commands/import.test.ts | 183 +++++++++++++++++- packages/cli/src/commands/import.ts | 150 +++++++++++--- packages/cli/src/commands/init.test.ts | 42 +++- packages/cli/src/commands/init.ts | 28 ++- packages/cli/src/commands/refresh.test.ts | 61 +++++- packages/cli/src/commands/refresh.ts | 15 +- packages/cli/src/lib/provenance-actions.ts | 2 +- packages/cli/src/lib/view-server.ts | 11 +- packages/cli/src/lib/view-ui.ts | 7 +- packages/core/schemas/manifest.schema.json | 14 ++ .../core/src/schemas/manifest.schema.test.ts | 51 +++++ packages/core/src/schemas/manifest.schema.ts | 33 ++++ packages/core/src/storage/manifest.test.ts | 19 ++ packages/core/src/storage/manifest.ts | 11 ++ 16 files changed, 637 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 277bdb62..f74a7b49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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 ` (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 diff --git a/docs/spec/terminal-and-import.md b/docs/spec/terminal-and-import.md index 49f11d74..adb96483 100644 --- a/docs/spec/terminal-and-import.md +++ b/docs/spec/terminal-and-import.md @@ -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 ` 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. diff --git a/packages/cli/src/commands/import.test.ts b/packages/cli/src/commands/import.test.ts index 04ab612c..e05a719c 100644 --- a/packages/cli/src/commands/import.test.ts +++ b/packages/cli/src/commands/import.test.ts @@ -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, @@ -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 { + 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>, +): Promise { + 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 { + 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/); + }); +}); diff --git a/packages/cli/src/commands/import.ts b/packages/cli/src/commands/import.ts index 48f3b7a8..8744ea42 100644 --- a/packages/cli/src/commands/import.ts +++ b/packages/cli/src/commands/import.ts @@ -1,7 +1,7 @@ import { createReadStream, type Dirent } from "node:fs"; -import { readdir, readFile, rm } from "node:fs/promises"; +import { readdir, readFile, rm, stat } from "node:fs/promises"; import { homedir } from "node:os"; -import { basename, join } from "node:path"; +import { basename, join, resolve } from "node:path"; import { createInterface } from "node:readline"; import { assertBasouRootSafe, @@ -34,7 +34,12 @@ const SHORT_ID_LEN = 6; /** Options shared by every `basou import ` subcommand. */ export type ImportOptions = { - project?: string; + /** + * Source project roots whose native logs to import. Repeatable on the CLI; + * empty means "fall back to the manifest's `import.source_roots`, then the + * repository root". Each entry may be absolute or relative to the cwd. + */ + project?: string[]; session?: string; all?: boolean; force?: boolean; @@ -43,6 +48,11 @@ export type ImportOptions = { verbose?: boolean; }; +/** Commander collector: accumulate a repeatable option into an array. */ +function collectPath(value: string, previous: string[]): string[] { + return [...previous, value]; +} + export type ImportClaudeCodeOptions = ImportOptions; export type ImportCodexOptions = ImportOptions; @@ -87,7 +97,9 @@ export function registerImportCommand(program: Command): void { .description("Derive Basou sessions from Claude Code native transcripts (~/.claude/projects)") .option( "--project ", - "Source project path whose transcripts to import (defaults to the current repository root)", + "Source project path whose transcripts to import (repeatable; defaults to the manifest source roots, then the repository root)", + collectPath, + [], ) .option("--session ", "Import a single transcript by its Claude session id") .option("--all", "Import every transcript found for the project") @@ -107,7 +119,9 @@ export function registerImportCommand(program: Command): void { .description("Derive Basou sessions from OpenAI Codex native rollout logs (~/.codex/sessions)") .option( "--project ", - "Source project path whose rollouts to import (defaults to the current repository root)", + "Source project path whose rollouts to import (repeatable; defaults to the manifest source roots, then the repository root)", + collectPath, + [], ) .option("--session ", "Import a single rollout by its Codex session id") .option("--all", "Import every rollout found for the project") @@ -155,6 +169,31 @@ export async function runImportCodex( } } +/** + * Resolve the absolute source roots to import from, applying precedence: + * explicit `--project` flags first (resolved against the cwd), else the + * manifest's `import.source_roots` (resolved against the repo root), else the + * repository root alone. The result is de-duplicated, so a root listed twice + * (or equal to the repo root) is scanned once. + */ +function resolveSourceRoots(args: { + projectFlags: string[]; + manifest: Manifest; + repoRoot: string; + cwd: string; +}): string[] { + const { projectFlags, manifest, repoRoot, cwd } = args; + let resolved: string[]; + if (projectFlags.length > 0) { + resolved = projectFlags.map((p) => resolve(cwd, p)); + } else { + const roots = manifest.import?.source_roots; + resolved = + roots !== undefined && roots.length > 0 ? roots.map((r) => resolve(repoRoot, r)) : [repoRoot]; + } + return [...new Set(resolved)]; +} + export async function doRunImportClaudeCode( options: ImportClaudeCodeOptions, ctx: ImportContext, @@ -162,11 +201,15 @@ export async function doRunImportClaudeCode( assertSelector(options); const { repositoryRoot, paths, manifest } = await resolveImportTarget(ctx); - const projectPath = options.project ?? repositoryRoot; + const projectPaths = resolveSourceRoots({ + projectFlags: options.project ?? [], + manifest, + repoRoot: repositoryRoot, + cwd: ctx.cwd ?? process.cwd(), + }); const projectsRoot = ctx.claudeProjectsDir ?? join(homedir(), ".claude", "projects"); - const transcriptDir = join(projectsRoot, encodeProjectDir(projectPath)); - const files = await selectTranscriptFiles(transcriptDir, options); + const files = await selectTranscriptFiles(projectsRoot, projectPaths, options); const candidates: ImportCandidate[] = files.map((file) => { // The transcript filename is the Claude session id; it is both the dedup // key and the source external_id. @@ -191,10 +234,15 @@ export async function doRunImportCodex( assertSelector(options); const { repositoryRoot, paths, manifest } = await resolveImportTarget(ctx); - const projectPath = options.project ?? repositoryRoot; + const projectPaths = resolveSourceRoots({ + projectFlags: options.project ?? [], + manifest, + repoRoot: repositoryRoot, + cwd: ctx.cwd ?? process.cwd(), + }); const sessionsRoot = ctx.codexSessionsDir ?? join(homedir(), ".codex", "sessions"); - const rollouts = await discoverCodexRollouts(sessionsRoot, projectPath, options); + const rollouts = await discoverCodexRollouts(sessionsRoot, projectPaths, options); const candidates: ImportCandidate[] = rollouts.map(({ file, externalId }) => ({ externalId, toPayload: async () => @@ -363,50 +411,88 @@ async function loadExistingByExternalId( return byExternalId; } +/** + * Select the Claude transcript files to import across one or more source roots. + * Each root maps to a per-project transcript directory under `projectsRoot`. + * With `--session`, every root is probed and only existing matches are returned + * (an error is raised only if no root holds that transcript). With `--all`, the + * `.jsonl` files of every root are unioned; a root whose directory is absent + * contributes nothing. The missing-directory error is raised only when NO root + * has a transcript directory, so refresh classifies "nothing anywhere" as a + * skip rather than a failure. + */ async function selectTranscriptFiles( - transcriptDir: string, + projectsRoot: string, + projectPaths: string[], options: ImportClaudeCodeOptions, ): Promise { if (options.session !== undefined) { - return [join(transcriptDir, `${options.session}.jsonl`)]; + const matches: string[] = []; + for (const projectPath of projectPaths) { + const file = join(projectsRoot, encodeProjectDir(projectPath), `${options.session}.jsonl`); + if (await pathExists(file)) matches.push(file); + } + if (matches.length === 0) { + throw new Error("Claude transcript not found for session id in project"); + } + return [...new Set(matches)]; + } + const files: string[] = []; + let anyDirFound = false; + for (const projectPath of projectPaths) { + const transcriptDir = join(projectsRoot, encodeProjectDir(projectPath)); + let entries: string[]; + try { + entries = await readdir(transcriptDir); + } catch (error: unknown) { + if (findErrorCode(error, "ENOENT")) continue; // this root has no transcripts; try the next + throw new Error("Failed to read Claude transcript directory", { cause: error }); + } + anyDirFound = true; + for (const name of entries) { + if (name.endsWith(".jsonl")) files.push(join(transcriptDir, name)); + } } - let entries: string[]; + if (!anyDirFound) { + throw new Error("Claude transcript directory not found for project"); + } + return [...new Set(files)].sort(); +} + +/** Whether `file` exists (ENOENT => false; any other error propagates). */ +async function pathExists(file: string): Promise { try { - entries = await readdir(transcriptDir); + await stat(file); + return true; } catch (error: unknown) { - if (findErrorCode(error, "ENOENT")) { - throw new Error("Claude transcript directory not found for project", { cause: error }); - } - throw new Error("Failed to read Claude transcript directory", { cause: error }); + if (findErrorCode(error, "ENOENT")) return false; + throw error; } - return entries - .filter((name) => name.endsWith(".jsonl")) - .sort() - .map((name) => join(transcriptDir, name)); } /** - * Discover the Codex rollouts that belong to `projectPath`. Codex stores - * rollouts under date directories (not per-project like Claude), so the whole - * tree is walked and each rollout's `session_meta.cwd` is matched against the - * project. The exact-match is also the safety boundary: only sessions started - * in the requested project are ever imported. `--session` narrows to a single - * rollout by its Codex session id within that project; a session id that - * matches no rollout in the project is an error, mirroring how the Claude - * path fails on a missing `--session` transcript rather than reporting a + * Discover the Codex rollouts that belong to any of `projectPaths`. Codex + * stores rollouts under date directories (not per-project like Claude), so the + * whole tree is walked once and each rollout's `session_meta.cwd` is matched + * against the set of requested roots. The exact-match is also the safety + * boundary: only sessions started in a requested root are ever imported. + * `--session` narrows to a single rollout by its Codex session id within those + * roots; a session id that matches no rollout is an error, mirroring how the + * Claude path fails on a missing `--session` transcript rather than reporting a * silent success. */ async function discoverCodexRollouts( sessionsRoot: string, - projectPath: string, + projectPaths: string[], options: ImportCodexOptions, ): Promise> { + const projectSet = new Set(projectPaths); const files = await findRolloutFiles(sessionsRoot); const matched: Array<{ file: string; externalId: string }> = []; for (const file of files) { const meta = await readRolloutMeta(file); if (meta === undefined) continue; - if (meta.cwd !== projectPath) continue; + if (!projectSet.has(meta.cwd)) continue; if (options.session !== undefined && meta.id !== options.session) continue; matched.push({ file, externalId: meta.id }); } diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index 2f2eda00..d9dc13d9 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; import { devNull, tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -279,3 +279,43 @@ describe("runInit (process-state wrapper)", () => { expect(process.exitCode).toBe(1); }); }); + +describe("doRunInit --source-root", () => { + it("persists a sibling --source-root as a repo-root-relative path", async () => { + const repo = await realpath(getTmpRepo()); + await doRunInit({ sourceRoot: ["../sibling-x"] }, { cwd: repo }); + const manifest = await readManifest(basouPaths(repo)); + expect(manifest.import?.source_roots).toEqual(["../sibling-x"]); + }); + + it("normalizes an absolute --source-root equal to the repo to '.'", async () => { + const repo = await realpath(getTmpRepo()); + await doRunInit({ sourceRoot: [repo] }, { cwd: repo }); + const manifest = await readManifest(basouPaths(repo)); + expect(manifest.import?.source_roots).toEqual(["."]); + }); + + it("collects multiple --source-root values in order", async () => { + const repo = await realpath(getTmpRepo()); + await doRunInit({ sourceRoot: [".", "../basou", "../basou-workspace"] }, { cwd: repo }); + const manifest = await readManifest(basouPaths(repo)); + expect(manifest.import?.source_roots).toEqual([".", "../basou", "../basou-workspace"]); + }); + + it("resolves a --source-root against the invocation cwd when run from a subdirectory", async () => { + const repo = await realpath(getTmpRepo()); + const sub = join(repo, "src"); + await mkdir(sub, { recursive: true }); + // "." from the subdir resolves to the subdir, stored relative to repo root. + await doRunInit({ sourceRoot: ["."] }, { cwd: sub }); + const manifest = await readManifest(basouPaths(repo)); + expect(manifest.import?.source_roots).toEqual(["src"]); + }); + + it("omits the import block when no --source-root is given", async () => { + const repo = await realpath(getTmpRepo()); + await doRunInit({}, { cwd: repo }); + const manifest = await readManifest(basouPaths(repo)); + expect("import" in manifest).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 903cd789..5aaf245e 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,4 +1,4 @@ -import { basename } from "node:path"; +import { basename, relative, resolve } from "node:path"; import { appendBasouGitignore, createManifest, @@ -15,10 +15,21 @@ export type InitOptions = { projectName?: string; projectDescription?: string; repoUrl?: string; + /** + * Import source roots (repeatable). Each may be absolute or relative to the + * invocation cwd; persisted as a path relative to the repository root under + * `import.source_roots`, so one `.basou/` can aggregate sibling repos. + */ + sourceRoot?: string[]; force?: boolean; verbose?: boolean; }; +/** Commander collector: accumulate a repeatable option into an array. */ +function collectValue(value: string, previous: string[]): string[] { + return [...previous, value]; +} + export type InitContext = { /** Defaults to `process.cwd()`. Injectable for tests. */ cwd?: string; @@ -41,6 +52,12 @@ export function registerInitCommand(program: Command): void { "--repo-url ", "Repository URL (defaults to git remote.origin.url; pass empty string for null)", ) + .option( + "--source-root ", + "Extra import source root, relative to the repo root (repeatable; aggregates sibling repos into this workspace)", + collectValue, + [], + ) .option("-f, --force", "Overwrite an existing manifest") .option("-v, --verbose", "Show error causes") .action(async (options: InitOptions) => { @@ -82,6 +99,14 @@ export async function doRunInit(options: InitOptions, ctx: InitContext): Promise repositoryUrl = await tryRemoteUrl(repositoryRoot); } + // Normalize each --source-root to a repo-root-relative path. A root that is + // the repo root itself becomes ".". Stored relative so the committed manifest + // carries no absolute machine paths. + const sourceRoots = (options.sourceRoot ?? []).map((p) => { + const rel = relative(repositoryRoot, resolve(cwd, p)); + return rel === "" ? "." : rel; + }); + const paths = await ensureBasouDirectory(repositoryRoot); const manifest = createManifest({ workspaceName, @@ -90,6 +115,7 @@ export async function doRunInit(options: InitOptions, ctx: InitContext): Promise ? { projectDescription: options.projectDescription } : {}), ...(repositoryUrl !== undefined ? { repositoryUrl } : {}), + ...(sourceRoots.length > 0 ? { sourceRoots } : {}), }); await writeManifest(paths, manifest, { force: options.force === true }); diff --git a/packages/cli/src/commands/refresh.test.ts b/packages/cli/src/commands/refresh.test.ts index b0d845b1..18fa402f 100644 --- a/packages/cli/src/commands/refresh.test.ts +++ b/packages/cli/src/commands/refresh.test.ts @@ -1,7 +1,7 @@ import { execFile } from "node:child_process"; import { access, mkdir, mkdtemp, 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, createManifest, ensureBasouDirectory, writeManifest } from "@basou/core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -126,6 +126,42 @@ async function writeCodexRollout(repo: string): Promise { ); } +/** Write a Codex rollout whose session cwd is `cwd`, under a unique filename. */ +async function writeCodexRolloutAt(cwd: string, id: string): Promise { + const dir = join(getCodexRoot(), "2026", "05", "10"); + await mkdir(dir, { recursive: true }); + const records = [ + { + type: "session_meta", + timestamp: "2026-05-10T00:00:00.000Z", + payload: { id, cwd, timestamp: "2026-05-10T00:00:00.000Z" }, + }, + { + type: "response_item", + timestamp: "2026-05-10T00:00:01.000Z", + payload: { + type: "function_call", + name: "exec_command", + arguments: JSON.stringify({ cmd: "ls", workdir: cwd }), + call_id: "c1", + }, + }, + { + type: "response_item", + timestamp: "2026-05-10T00:00:02.000Z", + payload: { + type: "function_call_output", + call_id: "c1", + output: "Wall time: 0.1000 seconds\nProcess exited with code 0\n", + }, + }, + ]; + await writeFile( + join(dir, `rollout-${id}.jsonl`), + records.map((r) => JSON.stringify(r)).join("\n"), + ); +} + function ctxFor(repo: string) { return { cwd: repo, claudeProjectsDir: getClaudeRoot(), codexSessionsDir: getCodexRoot() }; } @@ -182,6 +218,29 @@ describe("basou refresh", () => { await expect(access(paths.files.handoff)).rejects.toThrow(); }); + it("aggregates manifest import.source_roots across sibling repos in one run", async () => { + const repo = await realpath(tmpRepo as string); + const sibling = join(dirname(repo), "sibling-refresh"); + const paths = await ensureBasouDirectory(repo); + await writeManifest( + paths, + createManifest({ + workspaceName: "fixture-ws", + now: FIXED_DATE, + workspaceId: FIXED_WS_ID, + sourceRoots: [".", "../sibling-refresh"], + }), + ); + await writeCodexRolloutAt(repo, "codex-host"); + await writeCodexRolloutAt(sibling, "codex-sib"); + + // No --project: refresh reads the manifest's source roots and unions them. + const result = await doRunRefresh({}, ctxFor(repo)); + + expect(result.codex.status).toBe("ran"); + if (result.codex.status === "ran") expect(result.codex.importedCount).toBe(2); + }); + it("errors with exit code 1 on an uninitialized workspace", async () => { const repo = await realpath(tmpRepo as string); const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/packages/cli/src/commands/refresh.ts b/packages/cli/src/commands/refresh.ts index dc8b5992..173bb006 100644 --- a/packages/cli/src/commands/refresh.ts +++ b/packages/cli/src/commands/refresh.ts @@ -5,13 +5,18 @@ import { type ImportOutcome, type RefreshResult, refreshAll } from "../lib/prove import type { ImportContext } from "./import.js"; export type RefreshOptions = { - project?: string; + project?: string[]; force?: boolean; dryRun?: boolean; json?: boolean; verbose?: boolean; }; +/** Commander collector: accumulate a repeatable option into an array. */ +function collectPath(value: string, previous: string[]): string[] { + return [...previous, value]; +} + export type RefreshContext = ImportContext & { /** Defaults to `() => new Date()`. Injectable for tests. */ nowProvider?: () => Date; @@ -30,7 +35,9 @@ export function registerRefreshCommand(program: Command): void { ) .option( "--project ", - "Source project path to import (defaults to the current repository root)", + "Source project path to import (repeatable; defaults to the manifest source roots, then the repository root)", + collectPath, + [], ) .option("--force", "Re-import sessions already imported instead of skipping") .option("--dry-run", "Preview imports and skip writing handoff / decisions") @@ -71,7 +78,9 @@ export async function doRunRefresh( const nowIso = (ctx.nowProvider?.() ?? new Date()).toISOString(); const result = await refreshAll({ options: { - ...(options.project !== undefined ? { project: options.project } : {}), + ...(options.project !== undefined && options.project.length > 0 + ? { project: options.project } + : {}), ...(options.force === true ? { force: true } : {}), ...(options.dryRun === true ? { dryRun: true } : {}), }, diff --git a/packages/cli/src/lib/provenance-actions.ts b/packages/cli/src/lib/provenance-actions.ts index 9231ec55..bce7da2e 100644 --- a/packages/cli/src/lib/provenance-actions.ts +++ b/packages/cli/src/lib/provenance-actions.ts @@ -59,7 +59,7 @@ export type RefreshResult = { }; export type RefreshActionOptions = { - project?: string; + project?: string[]; force?: boolean; dryRun?: boolean; }; diff --git a/packages/cli/src/lib/view-server.ts b/packages/cli/src/lib/view-server.ts index 303d1493..a1e38ebe 100644 --- a/packages/cli/src/lib/view-server.ts +++ b/packages/cli/src/lib/view-server.ts @@ -372,12 +372,21 @@ async function handoffView(deps: ViewServerDeps): Promise): RefreshActionOptions { const options: RefreshActionOptions = {}; - if (typeof body.project === "string" && body.project.length > 0) options.project = body.project; + // Accept `project` as a single string (the UI sends one) or an array of + // strings (multi-root callers); normalize to a non-empty string[]. + const project = normalizeProject(body.project); + if (project.length > 0) options.project = project; if (body.force === true) options.force = true; if (body.dryRun === true) options.dryRun = true; return options; } +/** Coerce a request body `project` field into a list of non-empty path strings. */ +function normalizeProject(value: unknown): string[] { + const raw = Array.isArray(value) ? value : [value]; + return raw.filter((p): p is string => typeof p === "string" && p.length > 0); +} + function hostAllowed(req: IncomingMessage, port: number): boolean { const host = req.headers.host; return host === `127.0.0.1:${port}` || host === `localhost:${port}`; diff --git a/packages/cli/src/lib/view-ui.ts b/packages/cli/src/lib/view-ui.ts index 0cd64273..c435fcfc 100644 --- a/packages/cli/src/lib/view-ui.ts +++ b/packages/cli/src/lib/view-ui.ts @@ -55,7 +55,7 @@ export const VIEW_HTML = `

basou view

- + @@ -209,7 +209,10 @@ export const VIEW_HTML = ` detail.appendChild(el('p', { class: 'muted', text: 'Workspace not initialized.' })); return; } - $('project').value = $('project').value || d.repoRoot || ''; + // Leave the project field empty by default so refresh / import use the + // manifest's import.source_roots (then the repo root) -- pre-filling the + // repo root here would send it as an explicit --project and silently + // override multi-root source roots. The field is an optional override. state.repoRoot = d.repoRoot || ''; detail.appendChild(el('p', {}, [ el('strong', { text: d.workspace.name }), ' ', diff --git a/packages/core/schemas/manifest.schema.json b/packages/core/schemas/manifest.schema.json index aaf0a613..04b2afb1 100644 --- a/packages/core/schemas/manifest.schema.json +++ b/packages/core/schemas/manifest.schema.json @@ -135,6 +135,20 @@ ] } } + }, + "import": { + "type": "object", + "properties": { + "source_roots": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "pattern": "^(?![~/\\\\])(?![A-Za-z]:)[^\\0\\\\]+$" + } + } + } } }, "required": [ diff --git a/packages/core/src/schemas/manifest.schema.test.ts b/packages/core/src/schemas/manifest.schema.test.ts index 4d576a50..df756d67 100644 --- a/packages/core/src/schemas/manifest.schema.test.ts +++ b/packages/core/src/schemas/manifest.schema.test.ts @@ -63,4 +63,55 @@ describe("ManifestSchema", () => { const variant = { ...VALID_MANIFEST, basou_version: "0.2.0" }; expect(ManifestSchema.safeParse(variant).success).toBe(false); }); + + it("accepts import.source_roots with relative paths (host '.' plus a sibling)", () => { + const variant = { + ...VALID_MANIFEST, + import: { source_roots: [".", "../basou-workspace"] }, + }; + expect(ManifestSchema.safeParse(variant).success).toBe(true); + }); + + it("accepts a manifest with no import block (backward compatible)", () => { + expect("import" in VALID_MANIFEST).toBe(false); + expect(ManifestSchema.safeParse(VALID_MANIFEST).success).toBe(true); + }); + + it("rejects an absolute path in import.source_roots", () => { + const variant = { + ...VALID_MANIFEST, + import: { source_roots: ["/Users/example/projects/basou"] }, + }; + expect(ManifestSchema.safeParse(variant).success).toBe(false); + }); + + it("rejects a '~'-prefixed (home-expansion) path in import.source_roots", () => { + const variant = { + ...VALID_MANIFEST, + import: { source_roots: ["~/projects/basou"] }, + }; + expect(ManifestSchema.safeParse(variant).success).toBe(false); + }); + + it("rejects an empty string entry in import.source_roots", () => { + const variant = { ...VALID_MANIFEST, import: { source_roots: [""] } }; + expect(ManifestSchema.safeParse(variant).success).toBe(false); + }); + + it("rejects a backslash anywhere in an import.source_roots entry", () => { + for (const bad of ["..\\basou", "foo\\bar", "\\\\unc\\share"]) { + const variant = { ...VALID_MANIFEST, import: { source_roots: [bad] } }; + expect(ManifestSchema.safeParse(variant).success).toBe(false); + } + }); + + it("rejects a Windows drive-letter path in import.source_roots", () => { + const variant = { ...VALID_MANIFEST, import: { source_roots: ["C:/projects/basou"] } }; + expect(ManifestSchema.safeParse(variant).success).toBe(false); + }); + + it("rejects an empty import.source_roots array", () => { + const variant = { ...VALID_MANIFEST, import: { source_roots: [] } }; + expect(ManifestSchema.safeParse(variant).success).toBe(false); + }); }); diff --git a/packages/core/src/schemas/manifest.schema.ts b/packages/core/src/schemas/manifest.schema.ts index beaddd5d..b254775f 100644 --- a/packages/core/src/schemas/manifest.schema.ts +++ b/packages/core/src/schemas/manifest.schema.ts @@ -29,6 +29,38 @@ const GitConfigSchema = z.object({ events_log: z.enum(["ignore", "commit"]).default("ignore"), }); +/** + * A source root is RELATIVE to the manifest's repository root (it is resolved + * to an absolute path at import time). manifest.yaml is a commit candidate, so + * absolute machine paths (`/Users/...`), home-expansion (`~`), and stray + * backslashes are rejected to keep committed manifests path-clean and + * machine-portable. A `..`-prefixed sibling (e.g. `../basou-workspace`) is + * allowed. + * + * Encoded as a regex (not a Zod refinement) so the constraint is also emitted + * into the published JSON Schema's `pattern`, letting cross-language validators + * enforce the same rule. It rejects: a leading `~` (home), a leading `/` (POSIX + * absolute), any backslash anywhere (UNC / Windows / stray), a `:` + * prefix, and null bytes; `min(1)` rejects the empty string. + */ +const SOURCE_ROOT_PATTERN = /^(?![~/\\])(?![A-Za-z]:)[^\0\\]+$/; + +const SourceRootSchema = z.string().min(1).regex(SOURCE_ROOT_PATTERN, { + message: + "source_roots entries must be relative paths (no absolute path, '~', '\\', or null byte)", +}); + +/** + * Optional import config. `source_roots` lets one `.basou/` aggregate the + * native logs of several sibling repositories (each a path relative to the + * repo root, e.g. `["."`, `"../basou"]`). `basou refresh` / `basou import` + * scan every listed root; the list is the complete set, so include `"."` to + * keep the host repository itself. Absent => the host repository root only. + */ +const ImportConfigSchema = z.object({ + source_roots: z.array(SourceRootSchema).min(1).optional(), +}); + const WorkspaceMetaSchema = z.object({ id: WorkspaceIdSchema, name: z.string().min(1), @@ -52,6 +84,7 @@ export const ManifestSchema = z.object({ approval: ApprovalConfigSchema, adapters: AdaptersSchema, git: GitConfigSchema, + import: ImportConfigSchema.optional(), }); /** Inferred runtime type for {@link ManifestSchema}. */ diff --git a/packages/core/src/storage/manifest.test.ts b/packages/core/src/storage/manifest.test.ts index 2017ae1e..c9ffeb66 100644 --- a/packages/core/src/storage/manifest.test.ts +++ b/packages/core/src/storage/manifest.test.ts @@ -100,6 +100,25 @@ describe("createManifest", () => { expect("repository_url" in manifest.project).toBe(false); }); + it("includes import.source_roots when provided (relative paths)", () => { + const manifest = createManifest({ + workspaceName: "x", + sourceRoots: [".", "../basou-workspace"], + }); + expect(manifest.import?.source_roots).toEqual([".", "../basou-workspace"]); + }); + + it("omits the import block when sourceRoots is absent or empty", () => { + expect("import" in createManifest({ workspaceName: "x" })).toBe(false); + expect("import" in createManifest({ workspaceName: "x", sourceRoots: [] })).toBe(false); + }); + + it("throws on an absolute source root (schema rejects absolute paths)", () => { + expect(() => + createManifest({ workspaceName: "x", sourceRoots: ["/Users/example/projects/basou"] }), + ).toThrow(); + }); + it("throws on empty workspaceName", () => { expect(() => createManifest({ workspaceName: "" })).toThrow(/Workspace name is empty/); }); diff --git a/packages/core/src/storage/manifest.ts b/packages/core/src/storage/manifest.ts index f8ab1fb5..7fecfb62 100644 --- a/packages/core/src/storage/manifest.ts +++ b/packages/core/src/storage/manifest.ts @@ -18,6 +18,14 @@ export type CreateManifestInput = { now?: Date; /** Override for tests; defaults to a freshly generated `ws_`. */ workspaceId?: PrefixedId<"ws">; + /** + * Import source roots, each RELATIVE to the repository root (e.g. `"."`, + * `"../basou-workspace"`). Persisted under `import.source_roots` so + * `basou refresh` / `basou import` aggregate several sibling repos into one + * `.basou/`. Validated by `ManifestSchema` (absolute paths are rejected). + * Omitted from the manifest entirely when absent or empty. + */ + sourceRoots?: string[]; }; /** @@ -59,6 +67,9 @@ export function createManifest(input: CreateManifestInput): Manifest { "claude-code": { enabled: true }, }, git: { events_log: "ignore" }, + ...(input.sourceRoots !== undefined && input.sourceRoots.length > 0 + ? { import: { source_roots: input.sourceRoots } } + : {}), }; return ManifestSchema.parse(manifest); }