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

- **`basou view`'s board page measures the workspace on the spot when there
is no record to draw.** With no record yet, or no `board/board.yaml` read
(the manifest does not declare the workspace's own repo private), `/board`
now measures what `basou board measure` measures without a declaration and
draws it, saying it was measured now and is not a record: the period and
effort, from the day of the first session in this host's time zone; each
repository the manifest declares (the workspace's own repo when it declares
none), with HEAD, its branch, the last commit, the commits, the uncommitted
paths and the commits behind `origin/main` as of the last fetch; the
decisions and open tracks; how many sessions there are and how many
`basou verify` does not find verified; the review gaps; the newest session
and the sessions not yet imported, counted by the dry run `basou orient`
runs; and the components the markers find. A page drawing a record links
to the same measurement (`/board?live=1`), and a button measures again.
Nothing on it is judged (no reach matrix, lanes or operator's turns), it
writes nothing, so it needs no private repo, and it is not served in
portfolio mode. Like every page of `basou view`, it is a local UI and not
an API.

### Fixed

- **`basou view` no longer lets two imports, or dry runs of one, spoil each
other.** An import the view runs, and the dry run that counts the sessions
a refresh would import for a portfolio card, capture the console the
import writes to, which only one can do at a time. Two at once (the
portfolio opened in two tabs, or while a refresh or import runs) could
leave a card saying its freshness could not be checked, a refresh failing
with `Import produced no parseable result`, or the import's JSON result
printed by the view. Every capture now waits its turn, the board page's
measurement included.

### Internal

- **`@basou/core` measures a workspace with no board declared.**
`measureBoardLive` runs the built-in sections of a board's measurement that
need nothing from a declaration, and `boardLivePage` lays the result out for
the board page. The effort section's start may be left out: the days then
start on the day of the first session, or today when there is none or the
sessions cannot be read (which is said under `not_found`). With no time zone
declared and none named for this host, the effort is not measured, as
before, and such a start is only a label: today in UTC. A declared start is
measured as before.

## 0.66.0 — 2026-10-08

### Fixed
Expand Down
129 changes: 128 additions & 1 deletion packages/cli/src/commands/board.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
chmod,
mkdir,
Expand All @@ -7,6 +8,7 @@ import {
readFile,
realpath,
rm,
stat,
symlink,
writeFile,
} from "node:fs/promises";
Expand All @@ -24,7 +26,13 @@ import {
} from "@basou/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { probeStaleness } from "../lib/provenance-actions.js";
import { doRunBoardMeasure, doRunBoardRecord, runBoardMeasure, runBoardRecord } from "./board.js";
import {
doRunBoardMeasure,
doRunBoardRecord,
measureLiveBoard,
runBoardMeasure,
runBoardRecord,
} from "./board.js";
import { doRunPortfolioList } from "./portfolio.js";
import { doRunReviewGaps } from "./review-gaps.js";
import { doRunStats } from "./stats.js";
Expand Down Expand Up @@ -1321,3 +1329,122 @@ describe("basou board measure and record against the previous record", () => {
expect(out.join("\n")).toContain("Components:\n 0 found, 0 unacknowledged, gone not known\n");
});
});

describe("measureLiveBoard: the board page's measurement with no board", () => {
// A Claude Code transcript of the workspace that was never imported, beside
// it where ctx() points the dry run of an import.
async function neverImported(repo: string): Promise<void> {
const projectDir = join(ctx(repo).claudeProjectsDir, repo.replace(/[^a-zA-Z0-9]/g, "-"));
await mkdir(projectDir, { recursive: true });
await writeFile(
join(projectDir, "sess-1.jsonl"),
[
{
type: "user",
timestamp: "2026-10-01T00:00:00.000Z",
cwd: repo,
sessionId: "sess-1",
message: { role: "user", content: [{ type: "text", text: "go" }] },
},
{
type: "assistant",
timestamp: "2026-10-01T00:00:01.000Z",
cwd: repo,
message: { content: [{ type: "tool_use", name: "Bash", input: { command: "ls" } }] },
},
]
.map((record) => JSON.stringify(record))
.join("\n"),
);
}

// Every path under a directory with, for a file, its size, modification
// time and content.
async function snapshot(dir: string): Promise<Record<string, string>> {
const out: Record<string, string> = {};
for (const entry of await readdir(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
out[path] = "dir";
Object.assign(out, await snapshot(path));
} else {
const s = await stat(path, { bigint: true });
const sha = createHash("sha256")
.update(await readFile(path))
.digest("hex");
out[path] = `${s.size}:${s.mtimeNs}:${sha}`;
}
}
return out;
}

it("writes nothing, the dry run of an import included", async () => {
const repo = await workspace([{ path: ".", visibility: "private" }]);
await neverImported(repo);
const before = await snapshot(repo);
const m = await measureLiveBoard(repo, ctx(repo));
expect(m.freshness.unimported).toEqual({ new: 1, updated: 0, unverifiable: 0 });
expect(m.freshness.newest_session_at).toBeNull();
expect(await snapshot(repo)).toEqual(before);
});

it("counts the same when dry runs of an import are started at once", async () => {
const repo = await workspace([{ path: ".", visibility: "private" }]);
await neverImported(repo);
const context = ctx(repo);
const args = {
ctx: {
cwd: repo,
claudeProjectsDir: context.claudeProjectsDir,
codexSessionsDir: context.codexSessionsDir,
},
paths: basouPaths(repo),
nowIso: NOW.toISOString(),
};
const log = console.log;
const error = console.error;
const [live, ...probes] = await Promise.all([
measureLiveBoard(repo, context),
...[1, 2, 3].map(() => probeStaleness(args)),
]);
expect(live?.freshness.unimported).toEqual({ new: 1, updated: 0, unverifiable: 0 });
for (const probe of probes) {
expect(probe).toEqual({ newSessions: 1, updatedSessions: 0, unverifiableSessions: 0 });
}
// Each capture put the console back as it found it.
expect(console.log).toBe(log);
expect(console.error).toBe(error);
});

it("measures the repos the manifest declares, with no declaration and no board.yaml", async () => {
const repo = await workspace([
{ path: ".", visibility: "public" },
{ path: "../elsewhere", visibility: "private" },
]);
const m = await measureLiveBoard(repo, ctx(repo));
expect(m.repos.map((r) => r.path)).toEqual([".", "../elsewhere"]);
expect(m.not_found.map((n) => n.at)).toContain("repos[../elsewhere]");
expect(m.measured_at).toBe(NOW.toISOString());
// The dry run of an import ran, over the logs beside the workspace.
expect(m.freshness.unimported).toEqual({ new: 0, updated: 0, unverifiable: 0 });
});

it("measures the workspace's own repo when the manifest declares none", async () => {
const repo = await workspace();
const m = await measureLiveBoard(repo, ctx(repo));
expect(m.repos.map((r) => r.path)).toEqual(["."]);
expect(m.not_found.map((n) => n.at)).not.toContain("repos");
});

it("measures the workspace's own repo, and says why, when the manifest cannot be read", async () => {
const repo = await workspace([{ path: ".", visibility: "private" }]);
await writeFile(join(repo, ".basou", "manifest.yaml"), "repos: [: broken\n");
const m = await measureLiveBoard(repo, ctx(repo));
expect(m.repos.map((r) => r.path)).toEqual(["."]);
expect(m.complete).toBe(false);
expect(m.not_found[0]).toEqual({
at: "repos",
reason: "the manifest could not be read, so only the workspace's own repo was measured",
});
});
});
49 changes: 49 additions & 0 deletions packages/cli/src/commands/board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type BoardCellChange,
type BoardDeclaration,
type BoardDiff,
type BoardLiveMeasurement,
type BoardMeasurement,
type BoardNotFound,
type BoardObservation,
Expand All @@ -22,6 +23,7 @@ import {
findErrorCode,
type Manifest,
measureBoard,
measureBoardLive,
NO_PREVIOUS_RECORDS,
parseBoardDeclaration,
parseRecordInput,
Expand Down Expand Up @@ -558,6 +560,53 @@ async function measureLoaded(
});
}

/**
* Measure what the workspace at `root` shows with no board declared, for the
* board page of `basou view`: the built-in sections that need nothing from a
* declaration, over the repos the manifest declares (the workspace's own repo
* alone when it declares none, or cannot be read). Writes nothing, sends
* nothing, and runs no import: the sessions not yet imported are counted by
* the dry run `basou orient` runs.
*/
export async function measureLiveBoard(
root: string,
ctx: BoardContext,
): Promise<BoardLiveMeasurement> {
const paths = basouPaths(root);
let repos: string[] = ["."];
let manifestUnread = false;
try {
const declared = ((await readManifest(paths)).repos ?? []).map((repo) => repo.path);
if (declared.length > 0) repos = declared;
} catch {
manifestUnread = true;
}
const now = ctx.nowProvider?.() ?? new Date();
const probeCtx: ImportContext = { cwd: root };
if (ctx.claudeProjectsDir !== undefined) probeCtx.claudeProjectsDir = ctx.claudeProjectsDir;
if (ctx.codexSessionsDir !== undefined) probeCtx.codexSessionsDir = ctx.codexSessionsDir;
const measured = await measureBoardLive({
root,
repos,
paths,
now,
measuredWith: { basou: BASOU_CLI_VERSION, build: BASOU_BUILD?.commit ?? null },
probeImports: () => probeStaleness({ ctx: probeCtx, paths, nowIso: now.toISOString() }),
});
if (!manifestUnread) return measured;
return {
...measured,
complete: false,
not_found: [
{
at: "repos",
reason: "the manifest could not be read, so only the workspace's own repo was measured",
},
...measured.not_found,
],
};
}

/** Where a board.yaml is, how to show it, and whether --board gave it. */
type BoardLocation = { path: string; shown: string; given: boolean };

Expand Down
Loading
Loading