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
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,38 @@
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

### Fixed

- **The commands that read or write a workspace's trail now work from its
workspace view.** From a workspace view (a directory outside git that links
its planning repo), `basou verify`, `view`, `status`, `stats`, `report
generate`, `decisions generate`, `approval list`, `show`, `approve` and
`reject`, `decision record`, and `import claude-code` and `import codex`
stopped with `Not a git repository. Run 'git init' first, then re-run
'basou <command>'.`, advice that would have made the view a repository. They
now resolve to the repository the view links, as `orient`, `refresh`,
`note`, `decision capture`, `task` and the other commands already did, and
say so on stderr (`Resolved workspace view to ...`). Run from a portfolio
member (a repository whose trail another workspace aggregates through its
`import.source_roots`), they now resolve to that workspace too, as those
commands do. A directory that is neither keeps the `git init` advice.
- **`init`, `project new`, `exec` and `run` say so when they are run from a
workspace view.** They work on the repository they run in, so they still
stop outside git, but from a workspace view they now print `Not a git
repository: this is a workspace view (it links <name>). Run 'basou
<command>' inside a repository instead.` rather than advising `git init`.
A directory that is not a view keeps the `git init` advice.
- **`basou view --portfolio --check` counts a registered workspace view or
portfolio member as the workspace it resolves to.** Its capture-coverage
report listed such an entry among those `import cannot run in` (`not a git
repository`, or `no .basou store`) and counted the logs under the roots of
the workspace it resolves to as imported by no registered workspace, although
`basou refresh --portfolio` already imported them and `basou import` now
does too. It now resolves each entry as those commands do, against the
registry it checks.

## 0.65.0 — 2026-10-08

### Added
Expand Down
47 changes: 47 additions & 0 deletions packages/cli/src/commands/approval.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1534,3 +1534,50 @@ describe.skipIf(process.platform === "win32")("an approval file that is not foll
expect(events).toContain('"type":"approval_approved"');
});
});

describe("basou approval from a workspace view", () => {
it("resolves a git-untracked view to the repo it links", async () => {
const repo = await setupInitedRepo();
const view = await mkdtemp(join(tmpdir(), "basou-approval-view-"));
try {
await symlink(repo, join(view, "fixture-planning"));
const out = captureStdout();
const err = captureStderr();
await doRunApprovalList({}, { cwd: view });
expect(joinCalls(out)).toBe("No approvals found.");
expect(joinCalls(err)).toContain("Resolved workspace view to");
} finally {
await rm(view, { recursive: true, force: true });
}
});

it("shows, approves and rejects from a view, writing into the linked repo", async () => {
const repo = await setupInitedRepo();
const toApprove = APPR("P01");
const toReject = APPR("P02");
await createApproval(repo, { id: toApprove, sessionId: SES("S01") });
await appendRequestedEvent(repo, SES("S01"), toApprove, "2026-05-04T10:00:00+09:00", "E01");
await createApproval(repo, { id: toReject, sessionId: SES("S02") });
await appendRequestedEvent(repo, SES("S02"), toReject, "2026-05-04T10:00:00+09:00", "E02");
const view = await mkdtemp(join(tmpdir(), "basou-approval-view-"));
try {
await symlink(repo, join(view, "fixture-planning"));
const out = captureStdout();
const err = captureStderr();
await doRunApprovalShow(toApprove, {}, { cwd: view });
expect(joinCalls(out)).toContain(`Approval: ${toApprove}`);
await runApprovalApprove(toApprove, {}, { cwd: view });
await runApprovalReject(toReject, { reason: "Not allowed" }, { cwd: view });
expect(
err.mock.calls.flat().filter((c) => String(c).includes("Resolved workspace view to")),
).toHaveLength(3);
const resolved = await readdir(basouPaths(repo).approvals.resolved);
expect(resolved).toEqual(expect.arrayContaining([`${toApprove}.yaml`, `${toReject}.yaml`]));
expect(await readdir(basouPaths(repo).approvals.pending)).toEqual([]);
expect(await readdir(view)).toEqual(["fixture-planning"]);
expect(process.exitCode ?? 0).toBe(0);
} finally {
await rm(view, { recursive: true, force: true });
}
});
});
25 changes: 4 additions & 21 deletions packages/cli/src/commands/approval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ import {
readSessionYaml,
readYamlFile,
replayEvents,
resolveRepositoryRoot,
type UnfollowedApprovalEntry,
} from "@basou/core";
import type { Command } from "commander";
import { isVerbose, printReplayWarning, renderCliError } from "../lib/error-render.js";
import { resolveBasouRootForCommand } from "../lib/repo-root.js";

const APPR_PREFIX = "appr_";
const SHORT_ID_BASE_LEN = 6;
Expand Down Expand Up @@ -156,7 +156,7 @@ export async function doRunApprovalList(
ctx: ApprovalContext,
): Promise<void> {
const cwd = ctx.cwd ?? process.cwd();
const repositoryRoot = await resolveRepositoryRootForApproval(cwd, "list");
const repositoryRoot = await resolveBasouRootForCommand(cwd, "approval list");
const paths = basouPaths(repositoryRoot);
await assertWorkspaceInitialized(paths.root);

Expand Down Expand Up @@ -267,7 +267,7 @@ export async function doRunApprovalShow(
ctx: ApprovalContext,
): Promise<void> {
const cwd = ctx.cwd ?? process.cwd();
const repositoryRoot = await resolveRepositoryRootForApproval(cwd, "show");
const repositoryRoot = await resolveBasouRootForCommand(cwd, "approval show");
const paths = basouPaths(repositoryRoot);
await assertWorkspaceInitialized(paths.root);

Expand Down Expand Up @@ -364,7 +364,7 @@ async function doRunApprovalResolve(
}

const cwd = ctx.cwd ?? process.cwd();
const repositoryRoot = await resolveRepositoryRootForApproval(cwd, decision);
const repositoryRoot = await resolveBasouRootForCommand(cwd, `approval ${decision}`);
const paths = basouPaths(repositoryRoot);
await assertWorkspaceInitialized(paths.root);

Expand Down Expand Up @@ -826,23 +826,6 @@ function truncate(value: string, maxLength: number): string {
return `${value.slice(0, maxLength - 3)}...`;
}

async function resolveRepositoryRootForApproval(
cwd: string,
subcmd: "list" | "show" | "approve" | "reject",
): Promise<string> {
try {
return await resolveRepositoryRoot(cwd);
} catch (error: unknown) {
if (error instanceof Error && error.message === "Not a git repository") {
throw new Error(
`Not a git repository. Run 'git init' first, then re-run 'basou approval ${subcmd}'.`,
{ cause: error },
);
}
throw error;
}
}

async function assertWorkspaceInitialized(basouRoot: string): Promise<void> {
try {
await assertBasouRootSafe(basouRoot);
Expand Down
81 changes: 79 additions & 2 deletions packages/cli/src/commands/decision.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
import {
mkdir,
mkdtemp,
readdir,
readFile,
realpath,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { devNull, tmpdir } from "node:os";
import { join } from "node:path";
import { join, relative } from "node:path";
import { promisify } from "node:util";
import {
basouPaths,
Expand Down Expand Up @@ -1516,3 +1525,71 @@ describe("basou decision void", () => {
).rejects.toThrow(/cannot supersede itself/);
});
});

describe("doRunDecisionRecord from a workspace view", () => {
it("resolves a git-untracked view to the repo it links and records there", async () => {
const repo = await setupInitedRepo();
const view = await mkdtemp(join(tmpdir(), "basou-decision-view-"));
try {
await symlink(repo, join(view, "fixture-planning"));
const out = captureStdout();
const err = captureStderr();
await doRunDecisionRecord({ title: "recorded from a view" }, { cwd: view, ...FIXED_CTX });
expect(joinCalls(out)).toContain("Recorded decision_");
expect(joinCalls(err)).toContain("Resolved workspace view to");
// The session is in the linked repo's store and records that repo as its
// working directory, never the view.
const sid = await findAdHocSessionId(repo);
const session = (await readYamlFile(
join(basouPaths(repo).sessions, sid, "session.yaml"),
)) as { session: { working_directory: string } };
expect(session.session.working_directory).toBe(repo);
expect(await readdir(view)).toEqual(["fixture-planning"]);
} finally {
await rm(view, { recursive: true, force: true });
}
});
});

describe("doRunDecisionRecord from a portfolio member", () => {
it("resolves a storeless member to the workspace that aggregates it and records there", async () => {
const repo = await setupInitedRepo();
const root = await realpath(await mkdtemp(join(tmpdir(), "basou-decision-member-")));
try {
const member = join(root, "fixture-member");
await mkdir(member);
await execFileAsync("git", ["-c", "init.defaultBranch=main", "init"], {
cwd: member,
env: ENV,
});
await writeManifest(
basouPaths(repo),
createManifest({
workspaceName: "decision-ws",
now: FIXED_DATE,
workspaceId: FIXED_WS_ID,
sourceRoots: [".", relative(repo, member)],
}),
{ force: true },
);
const home = join(root, "home");
await mkdir(join(home, ".basou"), { recursive: true });
await writeFile(
join(home, ".basou", "portfolio.yaml"),
`version: 1\nworkspaces:\n - path: ${repo}\n`,
"utf8",
);
vi.stubEnv("HOME", home);
const out = captureStdout();
const err = captureStderr();
await doRunDecisionRecord({ title: "recorded from a member" }, { cwd: member, ...FIXED_CTX });
expect(joinCalls(out)).toContain("Recorded decision_");
expect(joinCalls(err)).toContain("Resolved portfolio member to");
await findAdHocSessionId(repo);
expect(await readdir(member)).toEqual([".git"]);
} finally {
vi.unstubAllEnvs();
await rm(root, { recursive: true, force: true });
}
});
});
25 changes: 4 additions & 21 deletions packages/cli/src/commands/decision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import {
prefixedUlid,
readManifest,
replayEvents,
resolveRepositoryRoot,
resolveSessionId,
type SessionStatus,
sanitizePath,
Expand Down Expand Up @@ -277,7 +276,7 @@ export async function doRunDecisionRecord(
ctx: DecisionContext,
): Promise<void> {
const cwd = ctx.cwd ?? process.cwd();
const repositoryRoot = await resolveRepositoryRootForDecision(cwd);
const repositoryRoot = await resolveBasouRootForCommand(cwd, "decision record");
const paths = basouPaths(repositoryRoot);
await assertWorkspaceInitialized(paths.root);

Expand Down Expand Up @@ -432,11 +431,9 @@ export async function doRunDecisionCapture(
ctx: DecisionCaptureContext,
): Promise<void> {
const cwd = ctx.cwd ?? process.cwd();
// View-aware resolution (like orient / refresh / note) so capture works from
// a workspace-view dir, redirecting to the planning repo where decisions.md
// and orient live. `basou decision record` predates this and uses a plain
// git-root resolver; aligning record is a separate, behavior-changing
// follow-up, not in scope for the capture slice.
// View-aware resolution (like orient / refresh / note / decision record) so
// capture works from a workspace-view dir, redirecting to the planning repo
// where decisions.md and orient live.
const repositoryRoot = await resolveBasouRootForCommand(cwd, "decision capture");
const paths = basouPaths(repositoryRoot);
await assertWorkspaceInitialized(paths.root);
Expand Down Expand Up @@ -1269,20 +1266,6 @@ function printDecisionResult(options: DecisionRecordOptions, result: DecisionPri
}
}

async function resolveRepositoryRootForDecision(cwd: string): Promise<string> {
try {
return await resolveRepositoryRoot(cwd);
} catch (error: unknown) {
if (error instanceof Error && error.message === "Not a git repository") {
throw new Error(
"Not a git repository. Run 'git init' first, then re-run 'basou decision record'.",
{ cause: error },
);
}
throw error;
}
}

async function assertWorkspaceInitialized(basouRoot: string): Promise<void> {
try {
await assertBasouRootSafe(basouRoot);
Expand Down
31 changes: 30 additions & 1 deletion packages/cli/src/commands/decisions.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { execFile } from "node:child_process";
import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
import {
chmod,
mkdir,
mkdtemp,
readdir,
readFile,
realpath,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { devNull, tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
Expand Down Expand Up @@ -442,3 +452,22 @@ describe("basou decisions generate", () => {
expect(generate).toBeDefined();
});
});

describe("basou decisions generate from a workspace view", () => {
it("resolves a git-untracked view to the repo it links and writes there", async () => {
const repo = await setupInitedRepo();
const view = await mkdtemp(join(tmpdir(), "basou-decisions-view-"));
try {
await symlink(repo, join(view, "fixture-planning"));
const out = captureStdout();
const err = captureStderr();
await doRunDecisionsGenerate({}, { cwd: view, nowProvider: () => FIXED_DATE });
expect(joinCalls(out)).toContain("decisions: 0");
expect(joinCalls(err)).toContain("Resolved workspace view to");
expect(await readFile(basouPaths(repo).files.decisions, "utf8")).toContain(GENERATED_START);
expect(await readdir(view)).toEqual(["fixture-planning"]);
} finally {
await rm(view, { recursive: true, force: true });
}
});
});
18 changes: 2 additions & 16 deletions packages/cli/src/commands/decisions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
readMarkdownFile,
renderDecisions,
renderWithMarkers,
resolveRepositoryRoot,
writeMarkdownFile,
} from "@basou/core";
import type { Command } from "commander";
Expand All @@ -15,6 +14,7 @@ import {
printSessionSkip,
renderCliError,
} from "../lib/error-render.js";
import { resolveBasouRootForCommand } from "../lib/repo-root.js";

export type DecisionsGenerateOptions = { verbose?: boolean };

Expand Down Expand Up @@ -56,7 +56,7 @@ export async function doRunDecisionsGenerate(
): Promise<void> {
void options;
const cwd = ctx.cwd ?? process.cwd();
const repositoryRoot = await resolveRepositoryRootForDecisions(cwd);
const repositoryRoot = await resolveBasouRootForCommand(cwd, "decisions generate");
const paths = basouPaths(repositoryRoot);
await assertWorkspaceInitialized(paths.root);

Expand All @@ -75,20 +75,6 @@ export async function doRunDecisionsGenerate(
console.log(`Generated .basou/decisions.md (decisions: ${result.decisionCount})`);
}

async function resolveRepositoryRootForDecisions(cwd: string): Promise<string> {
try {
return await resolveRepositoryRoot(cwd);
} catch (error: unknown) {
if (error instanceof Error && error.message === "Not a git repository") {
throw new Error(
"Not a git repository. Run 'git init' first, then re-run 'basou decisions generate'.",
{ cause: error },
);
}
throw error;
}
}

async function assertWorkspaceInitialized(basouRoot: string): Promise<void> {
try {
await assertBasouRootSafe(basouRoot);
Expand Down
Loading
Loading