From 32093836d6ce17a0b1ed781f9ff9892e9502855e Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Fri, 9 Oct 2026 07:35:30 +0900 Subject: [PATCH 1/3] basou: Say in the position when the workspace keeps a progress board When the manifest declares the workspace's own repo private and board/board.yaml is there, orient's "where you are now" ends with one line: when the last record was written and how long ago (or that there is none), the axis version, and to follow basou board guide. The SessionStart hooks hand the same position to a session, so an agent learns of the board and where its steps are without a skill of its own. Only the records' names and the declaration's axis.version are read, loosely: a board that cannot be read is said to be so and never fails the position, its exit code or the hooks' output. A workspace with no board gets no line. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 ++++ packages/cli/src/commands/orient.test.ts | 75 ++++++++++++++++++- packages/cli/src/commands/orient.ts | 24 ++++++ packages/core/src/board/glance.test.ts | 70 +++++++++++++++++ packages/core/src/board/glance.ts | 59 +++++++++++++++ packages/core/src/board/index.ts | 2 + packages/core/src/index.ts | 2 + packages/core/src/lib/view-strings.ts | 30 ++++++++ .../orientation/orientation-renderer.test.ts | 64 ++++++++++++++++ .../src/orientation/orientation-renderer.ts | 21 ++++++ 10 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/board/glance.test.ts create mode 100644 packages/core/src/board/glance.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d303d12..0d3ca924 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ All notable changes to **basou** are recorded here. The project follows ### Added +- **`basou orient` says when the workspace keeps a progress board, and how + to update it.** When the manifest declares the workspace's own repo + private and `board/board.yaml` is there, the position's "where you are + now" ends with one line: when the last record was written (from its + name) and how long ago, or that there is none yet; the axis's version + the declaration gives; and to follow `basou board guide`. The + SessionStart hooks hand the same position to a Claude Code or Codex + session, so an agent learns of the board, and where its steps are, with + no skill of its own. Only the records' names and the declaration's + `axis.version` are read: a board that cannot be read is said to be so + (the records cannot be read, the axis version is unknown), and never + fails the position, changes an exit code or the hooks' output beyond + that line. A workspace with no board gets no line. - **`basou board init` (experimental) prints a board.yaml to start a board from.** It is of the newest `board_version`, in the language the manifest declares for the workspace's own repo: a title from the workspace's name, diff --git a/packages/cli/src/commands/orient.test.ts b/packages/cli/src/commands/orient.test.ts index 925d2bc5..e6d02e12 100644 --- a/packages/cli/src/commands/orient.test.ts +++ b/packages/cli/src/commands/orient.test.ts @@ -13,7 +13,12 @@ import { } from "@basou/core"; import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { doRunOrient, registerOrientCommand, runOrient } from "./orient.js"; +import { + doRunOrient, + registerOrientCommand, + renderOrientationForRoot, + runOrient, +} from "./orient.js"; const execFileAsync = promisify(execFile); @@ -364,3 +369,71 @@ describe("basou orient", () => { expect(orient).toBeDefined(); }); }); + +describe("basou orient: the workspace's progress board", () => { + async function withBoard( + visibility: "private" | "public", + yaml: string, + records: string[] = [], + ): Promise { + const repo = await setupInitedRepo(); + const paths = basouPaths(repo); + const manifest = createManifest({ + workspaceName: "fixture-ws", + now: FIXED_DATE, + workspaceId: FIXED_WS_ID, + }); + await writeManifest( + paths, + { ...manifest, repos: [{ path: ".", visibility }] }, + { force: true }, + ); + await mkdir(join(repo, "board", "records"), { recursive: true }); + await writeFile(join(repo, "board", "board.yaml"), yaml); + for (const id of records) await writeFile(join(repo, "board", "records", `${id}.json`), "{}"); + return repo; + } + + it("says when the default board was last recorded, and how to update it", async () => { + const repo = await withBoard("private", "axis: { version: 2 }\n", [ + "01M4A00000000000000000000A", + ]); + const out = captureStdout(); + await doRunOrient({}, ctxFor(repo)); + const line = joinCalls(out) + .split("\n") + .find((l) => l.startsWith("- Progress board: ")); + expect(line).toMatch( + /^- Progress board: last record \d{4}-\d{2}-\d{2} \(.+\), axis v2\. To update it, follow `basou board guide`\.$/, + ); + // The hook's position says the same, writing nothing. + const rendered = await renderOrientationForRoot(repo, {}, ctxFor(repo), { write: false }); + expect(rendered.body).toContain(line); + }); + + it("says nothing of a board the manifest does not declare private", async () => { + const repo = await withBoard("public", "axis: { version: 2 }\n"); + const out = captureStdout(); + await doRunOrient({}, ctxFor(repo)); + expect(joinCalls(out)).not.toContain("Progress board"); + }); + + it("says nothing of a board where there is none", async () => { + const repo = await setupInitedRepo(); + const out = captureStdout(); + await doRunOrient({}, ctxFor(repo)); + expect(joinCalls(out)).not.toContain("Progress board"); + }); + + it("still gives the position when the board cannot be read", async () => { + const repo = await withBoard("private", "axis: [\n"); + await rm(join(repo, "board", "records"), { recursive: true }); + await writeFile(join(repo, "board", "records"), ""); + const out = captureStdout(); + await runOrient({}, ctxFor(repo)); + expect(joinCalls(out)).toContain( + "- Progress board: its records cannot be read, axis version unknown. To update it, follow `basou board guide`.", + ); + expect(process.exitCode ?? 0).toBe(0); + }); +}); diff --git a/packages/cli/src/commands/orient.ts b/packages/cli/src/commands/orient.ts index 24b37cd3..1b4f4547 100644 --- a/packages/cli/src/commands/orient.ts +++ b/packages/cli/src/commands/orient.ts @@ -1,8 +1,12 @@ +import { join } from "node:path"; import { assertBasouRootSafe, + type BoardGlance, basouPaths, type FederatedRoot, findErrorCode, + glanceBoard, + readManifest, renderOrientation, writeMarkdownFile, } from "@basou/core"; @@ -21,6 +25,7 @@ import { import { loadHostsConfig } from "../lib/hosts-config.js"; import { probeStaleness, refreshAll } from "../lib/provenance-actions.js"; import { resolveBasouRootForCommand } from "../lib/repo-root.js"; +import { DEFAULT_BOARD_PATH } from "./board.js"; import type { ImportContext } from "./import.js"; export type OrientOptions = { verbose?: boolean; quiet?: boolean; refresh?: boolean }; @@ -199,6 +204,7 @@ export async function renderOrientationForRoot( paths, nowIso, staleness, + board: await glanceDefaultBoard(repositoryRoot, paths), verbose: options.verbose === true, federatedRoots, onWarning: (w, sid) => printReplayWarning(w, sid), @@ -224,6 +230,24 @@ export async function renderOrientationForRoot( }; } +// The workspace's default board, glanced at for one line of the position: the +// board.yaml `basou board` reads by default, which it reads only when the +// manifest declares the workspace's own repo private. Null when there is +// none, or the manifest cannot be read: the position never fails on a board, +// which an experimental command wrote. +async function glanceDefaultBoard( + root: string, + paths: ReturnType, +): Promise { + try { + const own = (await readManifest(paths)).repos?.find((repo) => repo.path === "."); + if (own?.visibility !== "private") return null; + } catch { + return null; + } + return glanceBoard(join(root, DEFAULT_BOARD_PATH)); +} + async function assertWorkspaceInitialized(basouRoot: string): Promise { try { await assertBasouRootSafe(basouRoot); diff --git a/packages/core/src/board/glance.test.ts b/packages/core/src/board/glance.test.ts new file mode 100644 index 00000000..bc288915 --- /dev/null +++ b/packages/core/src/board/glance.test.ts @@ -0,0 +1,70 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { glanceBoard } from "./glance.js"; + +describe("glanceBoard", () => { + let dir: string; + let yaml: string; + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "basou-board-glance-")); + await mkdir(join(dir, "board")); + yaml = join(dir, "board", "board.yaml"); + }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("finds no board where there is no board.yaml, or a directory of that name", async () => { + expect(await glanceBoard(yaml)).toBeNull(); + await mkdir(yaml); + expect(await glanceBoard(yaml)).toBeNull(); + }); + + it("reads the axis version and when the last record was written, from its name", async () => { + await writeFile(yaml, "board_version: 2\naxis: { version: 3, review_due_days: 60 }\n"); + expect(await glanceBoard(yaml)).toEqual({ lastRecordAt: null, axisVersion: 3 }); + const records = join(dir, "board", "records"); + await mkdir(records); + expect(await glanceBoard(yaml)).toEqual({ lastRecordAt: null, axisVersion: 3 }); + await writeFile(join(records, "01M4A00000000000000000000A.json"), "{}"); + await writeFile(join(records, "01M4E6PAN7J2BJ5YK1NFPVAQAR.json"), "not even JSON"); + await writeFile(join(records, ".record-x.tmp"), ""); + await writeFile(join(records, "ZZZZ.json"), ""); + expect(await glanceBoard(yaml)).toEqual({ + lastRecordAt: "2026-10-08T16:49:07.751Z", + axisVersion: 3, + }); + }); + + it("does not know the axis of a board.yaml it cannot read, and still finds its records", async () => { + await mkdir(join(dir, "board", "records")); + await writeFile(join(dir, "board", "records", "01M4A00000000000000000000A.json"), "{}"); + for (const text of [ + "axis: [", + "axis: { version: 0 }", + 'axis: { version: "2" }', + "axis: { version: 1.5 }", + "- a list\n", + "", + ]) { + await writeFile(yaml, text); + const glance = await glanceBoard(yaml); + expect(glance?.axisVersion).toBeNull(); + expect(glance?.lastRecordAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + } + }); + + it("cannot read records that are a file or a link", async () => { + await writeFile(yaml, "axis: { version: 1 }\n"); + const records = join(dir, "board", "records"); + await writeFile(records, ""); + expect(await glanceBoard(yaml)).toEqual({ lastRecordAt: undefined, axisVersion: 1 }); + await rm(records); + const elsewhere = join(dir, "elsewhere"); + await mkdir(elsewhere); + await symlink(elsewhere, records); + expect(await glanceBoard(yaml)).toEqual({ lastRecordAt: undefined, axisVersion: 1 }); + }); +}); diff --git a/packages/core/src/board/glance.ts b/packages/core/src/board/glance.ts new file mode 100644 index 00000000..823a26ac --- /dev/null +++ b/packages/core/src/board/glance.ts @@ -0,0 +1,59 @@ +import { lstat, readdir, readFile, stat } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { decodeTime } from "ulid"; +import { parse } from "yaml"; +import { findErrorCode } from "../lib/error-codes.js"; +import { recordIds } from "./previous.js"; + +/** + * What a workspace's position says of its board, read loosely: the board is + * not checked, only looked at, so a broken one never fails the position. + */ +export type BoardGlance = { + /** + * When the last record was written, read from its ULID: null when there + * is none, undefined when the records cannot be read. + */ + lastRecordAt: string | null | undefined; + /** The declaration's `axis.version`, null when it cannot be read. */ + axisVersion: number | null; +}; + +/** + * Glance at the board declared at `boardYaml`: the names of the records + * beside it and the axis version it declares, nothing else. Null when there + * is no board there. Never throws. + */ +export async function glanceBoard(boardYaml: string): Promise { + try { + if (!(await stat(boardYaml)).isFile()) return null; + } catch { + return null; + } + let axisVersion: number | null = null; + try { + const raw: unknown = parse(await readFile(boardYaml, "utf8"), { + version: "1.2", + logLevel: "silent", + maxAliasCount: 100, + }); + const version = (raw as { axis?: { version?: unknown } } | null)?.axis?.version; + if (typeof version === "number" && Number.isInteger(version) && version >= 1) { + axisVersion = version; + } + } catch { + axisVersion = null; + } + let lastRecordAt: string | null | undefined; + try { + const records = join(dirname(boardYaml), "records"); + // A link or a file is not read, as the board page does not read one. + if (!(await lstat(records)).isDirectory()) throw new Error("not a directory"); + const ids = recordIds(await readdir(records)); + const last = ids.at(-1); + lastRecordAt = last === undefined ? null : new Date(decodeTime(last)).toISOString(); + } catch (error: unknown) { + lastRecordAt = findErrorCode(error, "ENOENT") ? null : undefined; + } + return { lastRecordAt, axisVersion }; +} diff --git a/packages/core/src/board/index.ts b/packages/core/src/board/index.ts index c4efeb26..257118b6 100644 --- a/packages/core/src/board/index.ts +++ b/packages/core/src/board/index.ts @@ -48,6 +48,8 @@ export type { BoardActiveMs, BoardEffort, BoardEffortDay } from "./effort.js"; export { BOARD_EFFORT_METHOD, effortStartOf } from "./effort.js"; export type { BoardFreshness, BoardImportProbe } from "./freshness.js"; export { BOARD_FRESHNESS_METHOD } from "./freshness.js"; +export type { BoardGlance } from "./glance.js"; +export { glanceBoard } from "./glance.js"; export type { GlobMatcher } from "./glob.js"; export { compileGlob, compileGlobs } from "./glob.js"; export type { BoardGuideInput } from "./guide.js"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 89f4e7dd..37531ecc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -100,6 +100,7 @@ export type { BoardEffort, BoardEffortDay, BoardFreshness, + BoardGlance, BoardGuideInput, BoardImportProbe, BoardInitInput, @@ -180,6 +181,7 @@ export { diffMeasurements, diffObserved, effortStartOf, + glanceBoard, isBoardTimeZone, measureBoard, measureBoardLive, diff --git a/packages/core/src/lib/view-strings.ts b/packages/core/src/lib/view-strings.ts index 94258e00..03c2c91a 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -165,6 +165,16 @@ export type ViewStrings = { toolHuman: string; toolImport: string; toolUnknown: string; + /** + * The line saying the workspace keeps a progress board: when its last + * record was written (null when there is none, undefined when the + * records cannot be read), the axis's version (null when it cannot be + * read), and how to update it. + */ + boardLine: ( + last: { date: string; age: string } | null | undefined, + axisVersion: number | null, + ) => string; }; handoff: { headingCurrentState: string; @@ -326,6 +336,16 @@ const EN: ViewStrings = { toolHuman: "manual note", toolImport: "another workspace", toolUnknown: "unknown", + boardLine: (last, axisVersion) => { + const record = + last === undefined + ? "its records cannot be read" + : last === null + ? "no record yet" + : `last record ${last.date} (${last.age})`; + const axis = axisVersion === null ? "axis version unknown" : `axis v${axisVersion}`; + return `Progress board: ${record}, ${axis}. To update it, follow \`basou board guide\`.`; + }, }, handoff: { headingCurrentState: "## Current state", @@ -449,6 +469,16 @@ const JA: ViewStrings = { toolHuman: "手動メモ", toolImport: "他ワークスペース", toolUnknown: "不明", + boardLine: (last, axisVersion) => { + const record = + last === undefined + ? "記録が読めない" + : last === null + ? "記録なし" + : `最後の記録 ${last.date}(${last.age})`; + const axis = axisVersion === null ? "軸の版は不明" : `軸 v${axisVersion}`; + return `進捗盤: ${record}・${axis}。更新は \`basou board guide\` の手順で。`; + }, }, handoff: { headingCurrentState: "## 現在の状態", diff --git a/packages/core/src/orientation/orientation-renderer.test.ts b/packages/core/src/orientation/orientation-renderer.test.ts index b9087bba..1eb0704d 100644 --- a/packages/core/src/orientation/orientation-renderer.test.ts +++ b/packages/core/src/orientation/orientation-renderer.test.ts @@ -2556,3 +2556,67 @@ describe("orientation: an out-of-root file name that carries line breaks", () => expect(body).not.toContain("\u001b"); }); }); + +describe("orientation: the workspace's progress board", () => { + const LINE = /^- Progress board: /m; + + it("says nothing of a board when there is none", async () => { + const paths = await setupPaths(); + await placeSession(paths, { id: SES("S01"), status: "completed" }); + const omitted = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); + expect(omitted.body).not.toMatch(LINE); + const none = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO, board: null }); + expect(none.body).toBe(omitted.body); + }); + + it("says when the board was last recorded, its axis, and to follow the guide, in where you are now", async () => { + const paths = await setupPaths(); + await placeSession(paths, { id: SES("S01"), status: "completed" }); + const at = new Date(Date.parse(FIXED_NOW_ISO) - 3 * 86_400_000).toISOString(); + const result = await renderOrientation({ + paths, + nowIso: FIXED_NOW_ISO, + board: { lastRecordAt: at, axisVersion: 2 }, + }); + const where = result.body.slice( + result.body.indexOf("## Where you are now"), + result.body.indexOf("## Recent direction"), + ); + const day = new Intl.DateTimeFormat("en-CA", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(new Date(at)); + expect(where).toContain( + `- Progress board: last record ${day} (3d ago), axis v2. To update it, follow \`basou board guide\`.\n`, + ); + }); + + it("says when there is no record yet, when the records cannot be read, and when the axis is not known", async () => { + const paths = await setupPaths(); + const body = async (board: { + lastRecordAt: string | null | undefined; + axisVersion: number | null; + }) => (await renderOrientation({ paths, nowIso: FIXED_NOW_ISO, board })).body; + expect(await body({ lastRecordAt: null, axisVersion: 1 })).toContain( + "- Progress board: no record yet, axis v1. To update it, follow `basou board guide`.", + ); + expect(await body({ lastRecordAt: undefined, axisVersion: null })).toContain( + "- Progress board: its records cannot be read, axis version unknown. To update it, follow `basou board guide`.", + ); + }); + + it("writes the line in the anchor's language", async () => { + const paths = await setupPaths(); + const result = await renderOrientation({ + paths, + nowIso: FIXED_NOW_ISO, + language: "ja", + board: { lastRecordAt: null, axisVersion: 3 }, + }); + const line = result.body.split("\n").find((l) => l.includes("basou board guide")) ?? ""; + expect(line.startsWith("- ")).toBe(true); + expect(line).not.toContain("Progress board"); + expect(line).toContain("v3"); + }); +}); diff --git a/packages/core/src/orientation/orientation-renderer.ts b/packages/core/src/orientation/orientation-renderer.ts index a75c4b8d..9b3ff63f 100644 --- a/packages/core/src/orientation/orientation-renderer.ts +++ b/packages/core/src/orientation/orientation-renderer.ts @@ -1,5 +1,7 @@ import { dirname, join } from "node:path"; import { enumerateApprovals, isLazyExpired, loadApproval } from "../approval/approval-store.js"; +import { todayIn } from "../board/effort.js"; +import type { BoardGlance } from "../board/glance.js"; import { countOpenDecisionGaps, type DecisionForGapCount } from "../decision-gaps/index.js"; import { type ReplayWarning, replayEvents } from "../events/event-replay.js"; import { displayPath } from "../lib/display-path.js"; @@ -71,6 +73,12 @@ export type OrientationRendererInput = { * store and other hosts still render. An absent root path is silently empty. */ onHostUnavailable?: (host: string, error: unknown) => void; + /** + * The workspace's progress board, as the CLI glanced at it: a line in "where + * am I now" says when it was last recorded and how to update it. Omitted or + * null = no board, and no line. + */ + board?: BoardGlance | null; }; export type OrientationRendererResult = { @@ -772,6 +780,7 @@ export async function renderOrientation( staleness: input.staleness ?? null, verbose: input.verbose === true, language, + board: input.board ?? null, }), sessionCount: summary.sessionCount, pendingApprovalsCount: summary.pendingApprovals.length, @@ -792,6 +801,7 @@ function formatOrientationBody( } | null; verbose: boolean; language: ViewLanguage; + board: BoardGlance | null; }, ): string { const t = viewStrings(opts.language); @@ -901,6 +911,17 @@ function formatOrientationBody( } else { lines.push(`- ${t.common.recentFilesLabel}: (none recorded)`); } + if (opts.board !== null) { + const at = opts.board.lastRecordAt; + const last = + at === null || at === undefined + ? at + : { + date: todayIn(undefined, new Date(at)) ?? at.slice(0, 10), + age: t.relativeAge(at, now), + }; + lines.push(`- ${t.orientation.boardLine(last, opts.board.axisVersion)}`); + } lines.push(""); // "recent direction" — the arc of the last N sessions, the read-side safety From 265ca9f56b38acc8839861e1a982290c9873ada1 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Fri, 9 Oct 2026 07:36:59 +0900 Subject: [PATCH 2/3] basou: Test the board line's day in the host's time zone Co-Authored-By: Claude Opus 5.5 --- .../orientation/orientation-renderer.test.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/core/src/orientation/orientation-renderer.test.ts b/packages/core/src/orientation/orientation-renderer.test.ts index 1eb0704d..b7e50896 100644 --- a/packages/core/src/orientation/orientation-renderer.test.ts +++ b/packages/core/src/orientation/orientation-renderer.test.ts @@ -1,7 +1,7 @@ import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { stringify } from "yaml"; import { LOCAL_CLI_EVENT_SOURCE } from "../schemas/shared.schema.js"; import type { TaskStatus } from "../schemas/task.schema.js"; @@ -2592,6 +2592,27 @@ describe("orientation: the workspace's progress board", () => { ); }); + it("gives the record's day in this host's time zone", async () => { + const resolved = Intl.DateTimeFormat.prototype.resolvedOptions; + const spy = vi + .spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions") + .mockImplementation(function (this: Intl.DateTimeFormat) { + return { ...resolved.call(this), timeZone: "Asia/Tokyo" }; + }); + try { + const paths = await setupPaths(); + const result = await renderOrientation({ + paths, + nowIso: FIXED_NOW_ISO, + // 05:00 on 2026-05-06 in Tokyo, though still 2026-05-05 in UTC. + board: { lastRecordAt: "2026-05-05T20:00:00.000Z", axisVersion: 1 }, + }); + expect(result.body).toContain("- Progress board: last record 2026-05-06 ("); + } finally { + spy.mockRestore(); + } + }); + it("says when there is no record yet, when the records cannot be read, and when the axis is not known", async () => { const paths = await setupPaths(); const body = async (board: { From 5846a119f99e3c46d8b73f82499f0b9daa5b1ddf Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Fri, 9 Oct 2026 08:38:12 +0900 Subject: [PATCH 3/3] basou: Settle what the review found in the position's board line - Read board.yaml as basou board reads YAML (1.2 only, no key twice, no tag left unresolved): one it would refuse gives no axis version. A board.yaml over 128 KiB is not read: the position is rendered at every session start, under a time limit. - basou refresh writes the line too, so the position is the same whichever command wrote it last. - The scan for other workspaces' names passes over the line, which is basou's own words around a date and a version: a workspace named like one of its words no longer silences the hook's position. - The record's day is the one on this host's clock, from the Date's own local fields, which follow TZ where the zone has no name. - A directory that bears a record's name is not counted as one. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 23 ++++--- packages/cli/src/commands/orient.test.ts | 65 ++++++++++++++++++- packages/cli/src/commands/orient.ts | 24 +------ packages/cli/src/lib/board-glance.ts | 24 +++++++ .../cli/src/lib/foreign-workspace-warn.ts | 19 ++++-- packages/cli/src/lib/provenance-actions.ts | 9 ++- packages/core/src/board/glance.test.ts | 33 +++++++++- packages/core/src/board/glance.ts | 43 ++++++++---- packages/core/src/index.ts | 1 + packages/core/src/lib/view-strings.test.ts | 30 +++++++++ packages/core/src/lib/view-strings.ts | 26 +++++++- .../orientation/orientation-renderer.test.ts | 38 +++++------ .../src/orientation/orientation-renderer.ts | 10 ++- 13 files changed, 264 insertions(+), 81 deletions(-) create mode 100644 packages/cli/src/lib/board-glance.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d3ca924..32584e92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,16 +10,19 @@ All notable changes to **basou** are recorded here. The project follows - **`basou orient` says when the workspace keeps a progress board, and how to update it.** When the manifest declares the workspace's own repo private and `board/board.yaml` is there, the position's "where you are - now" ends with one line: when the last record was written (from its - name) and how long ago, or that there is none yet; the axis's version - the declaration gives; and to follow `basou board guide`. The - SessionStart hooks hand the same position to a Claude Code or Codex - session, so an agent learns of the board, and where its steps are, with - no skill of its own. Only the records' names and the declaration's - `axis.version` are read: a board that cannot be read is said to be so - (the records cannot be read, the axis version is unknown), and never - fails the position, changes an exit code or the hooks' output beyond - that line. A workspace with no board gets no line. + now" ends with one line: when the last record was written (from its name, + the day on this host's clock) and how long ago, or that there is none yet; + the axis's version the declaration gives; and to follow `basou board + guide`. `basou refresh` writes the same position, and the SessionStart + hooks hand it to a Claude Code or Codex session, so an agent learns of the + board, and where its steps are, with no skill of its own. Only the + records' names and the declaration's `axis.version` are read, as `basou + board` reads YAML (a board.yaml larger than 128 KiB is not read): a board + that cannot be read is said to be so (the records cannot be read, the axis + version is unknown), and never fails the position, changes an exit code or + the hooks' output beyond that line. The line is basou's own words around a + date and a version, so the check for other workspaces' names passes it + over. A workspace with no board gets no line. - **`basou board init` (experimental) prints a board.yaml to start a board from.** It is of the newest `board_version`, in the language the manifest declares for the workspace's own repo: a title from the workspace's name, diff --git a/packages/cli/src/commands/orient.test.ts b/packages/cli/src/commands/orient.test.ts index e6d02e12..959001f8 100644 --- a/packages/cli/src/commands/orient.test.ts +++ b/packages/cli/src/commands/orient.test.ts @@ -1,7 +1,7 @@ import { execFile } from "node:child_process"; import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { devNull, tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { promisify } from "node:util"; import { basouPaths, @@ -13,6 +13,8 @@ import { } from "@basou/core"; import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { regenerateOrientation } from "../lib/provenance-actions.js"; +import { renderRegisteredWorkspacePosition } from "./hook.js"; import { doRunOrient, registerOrientCommand, @@ -395,16 +397,19 @@ describe("basou orient: the workspace's progress board", () => { } it("says when the default board was last recorded, and how to update it", async () => { + // Three days before the fixed clock. const repo = await withBoard("private", "axis: { version: 2 }\n", [ - "01M4A00000000000000000000A", + ulidAt(FIXED_DATE.getTime() - 3 * 86_400_000), ]); const out = captureStdout(); + const err = captureStderr(); await doRunOrient({}, ctxFor(repo)); + expect(joinCalls(err)).toBe(""); const line = joinCalls(out) .split("\n") .find((l) => l.startsWith("- Progress board: ")); expect(line).toMatch( - /^- Progress board: last record \d{4}-\d{2}-\d{2} \(.+\), axis v2\. To update it, follow `basou board guide`\.$/, + /^- Progress board: last record \d{4}-\d{2}-\d{2} \(3d ago\), axis v2\. To update it, follow `basou board guide`\.$/, ); // The hook's position says the same, writing nothing. const rendered = await renderOrientationForRoot(repo, {}, ctxFor(repo), { write: false }); @@ -437,3 +442,57 @@ describe("basou orient: the workspace's progress board", () => { expect(process.exitCode ?? 0).toBe(0); }); }); + +// A ULID of the time given, its random part all zeros. +function ulidAt(ms: number): string { + const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + let time = ""; + for (let rest = ms, i = 0; i < 10; i++, rest = Math.floor(rest / 32)) { + time = alphabet[rest % 32] + time; + } + return `${time}${"0".repeat(16)}`; +} + +describe("the progress-board line, wherever the position is written", () => { + async function privateBoard(): Promise { + const repo = await setupInitedRepo(); + const paths = basouPaths(repo); + const manifest = createManifest({ + workspaceName: "fixture-ws", + now: FIXED_DATE, + workspaceId: FIXED_WS_ID, + }); + await writeManifest( + paths, + { ...manifest, repos: [{ path: ".", visibility: "private" }] }, + { + force: true, + }, + ); + await mkdir(join(repo, "board", "records"), { recursive: true }); + await writeFile(join(repo, "board", "board.yaml"), "axis: { version: 4 }\n"); + return repo; + } + + it("is in the position basou refresh writes too", async () => { + const repo = await privateBoard(); + await regenerateOrientation(basouPaths(repo), FIXED_DATE.toISOString()); + expect(await readFile(basouPaths(repo).files.orientation, "utf8")).toContain( + "- Progress board: no record yet, axis v4. To update it, follow `basou board guide`.", + ); + }); + + it("does not silence the hook's position when another workspace is named like one of its words", async () => { + const repo = await privateBoard(); + const guide = join(dirname(repo), `${basename(repo)}-x`, "guide"); + await mkdir(guide, { recursive: true }); + const portfolio = join(repo, "portfolio.yaml"); + await writeFile(portfolio, `workspaces:\n - path: ${repo}\n - path: ${guide}\n`); + const { body } = await renderRegisteredWorkspacePosition(repo, portfolio); + expect(body).toContain("- Progress board: no record yet, axis v4."); + const err = captureStderr(); + captureStdout(); + await doRunOrient({}, { ...ctxFor(repo), portfolioConfigPath: portfolio }); + expect(joinCalls(err)).toBe(""); + }); +}); diff --git a/packages/cli/src/commands/orient.ts b/packages/cli/src/commands/orient.ts index 1b4f4547..0fe6f086 100644 --- a/packages/cli/src/commands/orient.ts +++ b/packages/cli/src/commands/orient.ts @@ -1,16 +1,13 @@ -import { join } from "node:path"; import { assertBasouRootSafe, - type BoardGlance, basouPaths, type FederatedRoot, findErrorCode, - glanceBoard, - readManifest, renderOrientation, writeMarkdownFile, } from "@basou/core"; import type { Command } from "commander"; +import { glanceDefaultBoard } from "../lib/board-glance.js"; import { isVerbose, printReplayWarning, @@ -25,7 +22,6 @@ import { import { loadHostsConfig } from "../lib/hosts-config.js"; import { probeStaleness, refreshAll } from "../lib/provenance-actions.js"; import { resolveBasouRootForCommand } from "../lib/repo-root.js"; -import { DEFAULT_BOARD_PATH } from "./board.js"; import type { ImportContext } from "./import.js"; export type OrientOptions = { verbose?: boolean; quiet?: boolean; refresh?: boolean }; @@ -230,24 +226,6 @@ export async function renderOrientationForRoot( }; } -// The workspace's default board, glanced at for one line of the position: the -// board.yaml `basou board` reads by default, which it reads only when the -// manifest declares the workspace's own repo private. Null when there is -// none, or the manifest cannot be read: the position never fails on a board, -// which an experimental command wrote. -async function glanceDefaultBoard( - root: string, - paths: ReturnType, -): Promise { - try { - const own = (await readManifest(paths)).repos?.find((repo) => repo.path === "."); - if (own?.visibility !== "private") return null; - } catch { - return null; - } - return glanceBoard(join(root, DEFAULT_BOARD_PATH)); -} - async function assertWorkspaceInitialized(basouRoot: string): Promise { try { await assertBasouRootSafe(basouRoot); diff --git a/packages/cli/src/lib/board-glance.ts b/packages/cli/src/lib/board-glance.ts new file mode 100644 index 00000000..9f997049 --- /dev/null +++ b/packages/cli/src/lib/board-glance.ts @@ -0,0 +1,24 @@ +import { join } from "node:path"; +import { type BasouPaths, type BoardGlance, glanceBoard, readManifest } from "@basou/core"; +import { DEFAULT_BOARD_PATH } from "../commands/board.js"; + +/** + * The workspace's default board, glanced at for one line of its position: the + * board.yaml `basou board` reads by default, which it reads only when the + * manifest declares the workspace's own repo private. Null when there is + * none, or the manifest cannot be read: a position never fails on a board, + * which an experimental command wrote. Every writer of the position calls + * this, so the line is there whichever wrote it last. + */ +export async function glanceDefaultBoard( + root: string, + paths: BasouPaths, +): Promise { + try { + const own = (await readManifest(paths)).repos?.find((repo) => repo.path === "."); + if (own?.visibility !== "private") return null; + } catch { + return null; + } + return glanceBoard(join(root, DEFAULT_BOARD_PATH)); +} diff --git a/packages/cli/src/lib/foreign-workspace-warn.ts b/packages/cli/src/lib/foreign-workspace-warn.ts index 9753037b..33567559 100644 --- a/packages/cli/src/lib/foreign-workspace-warn.ts +++ b/packages/cli/src/lib/foreign-workspace-warn.ts @@ -1,6 +1,11 @@ import { realpath } from "node:fs/promises"; import { dirname } from "node:path"; -import { type BasouPaths, loadPortfolioConfig, readMarkdownFile } from "@basou/core"; +import { + type BasouPaths, + isPositionBoardLine, + loadPortfolioConfig, + readMarkdownFile, +} from "@basou/core"; import { scanForeignWorkspaceNames } from "./foreign-workspace-scan.js"; /** @@ -73,11 +78,13 @@ export async function findForeignWorkspaceNames(args: { return null; } - const hits = scanForeignWorkspaceNames({ - text: args.text, - workspacePaths, - selfPath, - }); + // The progress-board line is basou's own words around a date and a + // version; blanked, not dropped, so the line numbers stay the text's. + const text = args.text + .split("\n") + .map((line) => (isPositionBoardLine(line) ? "" : line)) + .join("\n"); + const hits = scanForeignWorkspaceNames({ text, workspacePaths, selfPath }); if (hits.length === 0) return null; const lines = [...new Set(hits.flatMap((h) => h.lines))].sort((a, b) => a - b); diff --git a/packages/cli/src/lib/provenance-actions.ts b/packages/cli/src/lib/provenance-actions.ts index 7675dd9a..c2d89081 100644 --- a/packages/cli/src/lib/provenance-actions.ts +++ b/packages/cli/src/lib/provenance-actions.ts @@ -1,3 +1,4 @@ +import { dirname } from "node:path"; import { assertApprovalStoreSafe, assertLockStoreSafe, @@ -16,6 +17,7 @@ import { type ImportContext, type ImportOptions, } from "../commands/import.js"; +import { glanceDefaultBoard } from "./board-glance.js"; /** * Shared provenance actions reused by both `basou refresh` (one-shot CLI) and @@ -267,7 +269,12 @@ export async function regenerateOrientation( nowIso: string, callbacks?: Omit[0], "paths" | "nowIso">, ): Promise { - const result = await renderOrientation({ paths, nowIso, ...callbacks }); + const result = await renderOrientation({ + paths, + nowIso, + board: await glanceDefaultBoard(dirname(paths.root), paths), + ...callbacks, + }); await writeMarkdownFile(paths.files.orientation, `${result.body}\n`); return { sessionCount: result.sessionCount, diff --git a/packages/core/src/board/glance.test.ts b/packages/core/src/board/glance.test.ts index bc288915..18a9b4e1 100644 --- a/packages/core/src/board/glance.test.ts +++ b/packages/core/src/board/glance.test.ts @@ -1,8 +1,9 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { decodeTime } from "ulid"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { glanceBoard } from "./glance.js"; +import { BOARD_GLANCE_MAX_BYTES, glanceBoard } from "./glance.js"; describe("glanceBoard", () => { let dir: string; @@ -56,6 +57,36 @@ describe("glanceBoard", () => { } }); + it("does not know the axis of a board.yaml basou board would not read as YAML", async () => { + for (const text of [ + "axis: { version: 2 }\nlanes: [\n", + "axis: { version: 2 }\naxis: { version: 3 }\n", + "%YAML 1.1\n---\naxis: { version: 010 }\n", + "axis: !odd { version: 2 }\n", + ]) { + await writeFile(yaml, text); + expect(await glanceBoard(yaml)).toEqual({ lastRecordAt: null, axisVersion: null }); + } + }); + + it("does not read a board.yaml too large to be written by hand", async () => { + const head = "axis: { version: 2 }\n"; + await writeFile(yaml, head + "#".repeat(BOARD_GLANCE_MAX_BYTES - head.length)); + expect((await glanceBoard(yaml))?.axisVersion).toBe(2); + await writeFile(yaml, head + "#".repeat(BOARD_GLANCE_MAX_BYTES - head.length + 1)); + expect((await glanceBoard(yaml))?.axisVersion).toBeNull(); + }); + + it("does not count a directory that bears a record's name", async () => { + await writeFile(yaml, "axis: { version: 1 }\n"); + const records = join(dir, "board", "records"); + await mkdir(join(records, "01M4E6PAN7J2BJ5YK1NFPVAQAR.json"), { recursive: true }); + await writeFile(join(records, "01M4A00000000000000000000A.json"), "{}"); + expect((await glanceBoard(yaml))?.lastRecordAt).toBe( + new Date(decodeTime("01M4A00000000000000000000A")).toISOString(), + ); + }); + it("cannot read records that are a file or a link", async () => { await writeFile(yaml, "axis: { version: 1 }\n"); const records = join(dir, "board", "records"); diff --git a/packages/core/src/board/glance.ts b/packages/core/src/board/glance.ts index 823a26ac..1143fed5 100644 --- a/packages/core/src/board/glance.ts +++ b/packages/core/src/board/glance.ts @@ -1,7 +1,7 @@ import { lstat, readdir, readFile, stat } from "node:fs/promises"; import { dirname, join } from "node:path"; import { decodeTime } from "ulid"; -import { parse } from "yaml"; +import { parseDocument } from "yaml"; import { findErrorCode } from "../lib/error-codes.js"; import { recordIds } from "./previous.js"; @@ -19,28 +19,32 @@ export type BoardGlance = { axisVersion: number | null; }; +/** + * The largest board.yaml glanced at for its axis version. A position is + * rendered at every session start, under a time limit, and reading YAML + * grows faster than the file; a board of this size is far larger than any + * written by hand. + */ +export const BOARD_GLANCE_MAX_BYTES = 128 * 1024; + /** * Glance at the board declared at `boardYaml`: the names of the records * beside it and the axis version it declares, nothing else. Null when there * is no board there. Never throws. */ export async function glanceBoard(boardYaml: string): Promise { + let size: number; try { - if (!(await stat(boardYaml)).isFile()) return null; + const entry = await stat(boardYaml); + if (!entry.isFile()) return null; + size = entry.size; } catch { return null; } let axisVersion: number | null = null; try { - const raw: unknown = parse(await readFile(boardYaml, "utf8"), { - version: "1.2", - logLevel: "silent", - maxAliasCount: 100, - }); - const version = (raw as { axis?: { version?: unknown } } | null)?.axis?.version; - if (typeof version === "number" && Number.isInteger(version) && version >= 1) { - axisVersion = version; - } + if (size <= BOARD_GLANCE_MAX_BYTES) + axisVersion = axisVersionOf(await readFile(boardYaml, "utf8")); } catch { axisVersion = null; } @@ -49,7 +53,11 @@ export async function glanceBoard(boardYaml: string): Promise entry.isFile()) + .map((entry) => entry.name); + const ids = recordIds(files); const last = ids.at(-1); lastRecordAt = last === undefined ? null : new Date(decodeTime(last)).toISOString(); } catch (error: unknown) { @@ -57,3 +65,14 @@ export async function glanceBoard(boardYaml: string): Promise 0 || doc.warnings.length > 0) return null; + if (doc.directives?.yaml.version !== "1.2") return null; + const raw: unknown = doc.toJS({ maxAliasCount: 100 }); + const version = (raw as { axis?: { version?: unknown } } | null)?.axis?.version; + return typeof version === "number" && Number.isInteger(version) && version >= 1 ? version : null; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 37531ecc..ac76c4ed 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -271,6 +271,7 @@ export type { export { boardInitStrings, boardPageStrings, + isPositionBoardLine, presetStrings, resolveAnchorContentLanguage, resolveRepoContentLanguage, diff --git a/packages/core/src/lib/view-strings.test.ts b/packages/core/src/lib/view-strings.test.ts index 06412474..0d15b115 100644 --- a/packages/core/src/lib/view-strings.test.ts +++ b/packages/core/src/lib/view-strings.test.ts @@ -6,6 +6,7 @@ import type { Manifest } from "../schemas/manifest.schema.js"; import { type BasouPaths, ensureBasouDirectory } from "../storage/basou-dir.js"; import { createManifest, writeManifest } from "../storage/manifest.js"; import { + isPositionBoardLine, presetStrings, resolveAnchorContentLanguage, resolveRepoContentLanguage, @@ -161,6 +162,9 @@ describe("viewStrings", () => { t.orientation.verdictCurrent("1h ago", "terminal", false), t.orientation.verdictCurrent("1h ago", "terminal", true), t.orientation.verdictSuspectsCaveat(1), + t.orientation.boardLine(undefined, null), + t.orientation.boardLine(null, 1), + t.orientation.boardLine({ date: "2026-05-08", age: "1d ago" }, 2), ]; for (const line of rendered) { expect(typeof line).toBe("string"); @@ -246,3 +250,29 @@ describe("presetStrings", () => { } }); }); + +describe("isPositionBoardLine", () => { + it("knows the progress-board line in either language, as the position bullets it", () => { + for (const lang of ["en", "ja"] as const) { + const t = viewStrings(lang); + for (const line of [ + t.orientation.boardLine(undefined, null), + t.orientation.boardLine(null, 1), + t.orientation.boardLine({ date: "2026-05-08", age: "3d ago" }, 12), + ]) { + expect(isPositionBoardLine(`- ${line}`)).toBe(true); + expect(isPositionBoardLine(line)).toBe(false); + expect(isPositionBoardLine(` - ${line}`)).toBe(false); + } + } + }); + + it("does not take a line that only begins or ends like it, or holds more code", () => { + const line = viewStrings("en").orientation.boardLine(null, 1); + expect(isPositionBoardLine(`- ${line} and more`)).toBe(false); + expect(isPositionBoardLine(`- Latest decision: ${line}`)).toBe(false); + expect( + isPositionBoardLine("- Progress board: `x`. To update it, follow `basou board guide`."), + ).toBe(false); + }); +}); diff --git a/packages/core/src/lib/view-strings.ts b/packages/core/src/lib/view-strings.ts index 03c2c91a..b39fb413 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -245,6 +245,28 @@ function relativeAgeJa(startedAt: string | null, now: Date): string { return `${mins}分前`; } +// How the position's progress-board line begins and ends in each language. +// Between them stand only a date, an age and a version, which basou writes. +const BOARD_LINE = { + en: { head: "Progress board: ", tail: ". To update it, follow `basou board guide`." }, + ja: { head: "進捗盤: ", tail: "。更新は `basou board guide` の手順で。" }, +} as const; + +/** + * Whether a line of a position is the progress-board line, as a bullet: + * fixed words of basou's around a date, an age and a version, which carry no + * name of the operator's. A scan for other workspaces' names passes it over, + * so a workspace named like one of its words does not silence a position. + */ +export function isPositionBoardLine(line: string): boolean { + return Object.values(BOARD_LINE).some( + ({ head, tail }) => + line.startsWith(`- ${head}`) && + line.endsWith(tail) && + !line.slice(2 + head.length, line.length - tail.length).includes("`"), + ); +} + const EN: ViewStrings = { relativeAge: relativeAgeEn, common: { @@ -344,7 +366,7 @@ const EN: ViewStrings = { ? "no record yet" : `last record ${last.date} (${last.age})`; const axis = axisVersion === null ? "axis version unknown" : `axis v${axisVersion}`; - return `Progress board: ${record}, ${axis}. To update it, follow \`basou board guide\`.`; + return `${BOARD_LINE.en.head}${record}, ${axis}${BOARD_LINE.en.tail}`; }, }, handoff: { @@ -477,7 +499,7 @@ const JA: ViewStrings = { ? "記録なし" : `最後の記録 ${last.date}(${last.age})`; const axis = axisVersion === null ? "軸の版は不明" : `軸 v${axisVersion}`; - return `進捗盤: ${record}・${axis}。更新は \`basou board guide\` の手順で。`; + return `${BOARD_LINE.ja.head}${record}・${axis}${BOARD_LINE.ja.tail}`; }, }, handoff: { diff --git a/packages/core/src/orientation/orientation-renderer.test.ts b/packages/core/src/orientation/orientation-renderer.test.ts index b7e50896..f4b646fa 100644 --- a/packages/core/src/orientation/orientation-renderer.test.ts +++ b/packages/core/src/orientation/orientation-renderer.test.ts @@ -2582,34 +2582,30 @@ describe("orientation: the workspace's progress board", () => { result.body.indexOf("## Where you are now"), result.body.indexOf("## Recent direction"), ); - const day = new Intl.DateTimeFormat("en-CA", { - year: "numeric", - month: "2-digit", - day: "2-digit", - }).format(new Date(at)); + const d = new Date(at); + const day = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; expect(where).toContain( `- Progress board: last record ${day} (3d ago), axis v2. To update it, follow \`basou board guide\`.\n`, ); }); - it("gives the record's day in this host's time zone", async () => { - const resolved = Intl.DateTimeFormat.prototype.resolvedOptions; - const spy = vi - .spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions") - .mockImplementation(function (this: Intl.DateTimeFormat) { - return { ...resolved.call(this), timeZone: "Asia/Tokyo" }; - }); + it("gives the record's day on this host's clock, even in a zone with no name", async () => { + const saved = process.env.TZ; try { - const paths = await setupPaths(); - const result = await renderOrientation({ - paths, - nowIso: FIXED_NOW_ISO, - // 05:00 on 2026-05-06 in Tokyo, though still 2026-05-05 in UTC. - board: { lastRecordAt: "2026-05-05T20:00:00.000Z", axisVersion: 1 }, - }); - expect(result.body).toContain("- Progress board: last record 2026-05-06 ("); + for (const tz of ["Asia/Tokyo", "JST-9"]) { + process.env.TZ = tz; + const paths = await setupPaths(); + const result = await renderOrientation({ + paths, + nowIso: FIXED_NOW_ISO, + // 05:00 on 2026-05-06 in Tokyo, though still 2026-05-05 in UTC. + board: { lastRecordAt: "2026-05-05T20:00:00.000Z", axisVersion: 1 }, + }); + expect(result.body).toContain("- Progress board: last record 2026-05-06 ("); + } } finally { - spy.mockRestore(); + if (saved === undefined) delete process.env.TZ; + else process.env.TZ = saved; } }); diff --git a/packages/core/src/orientation/orientation-renderer.ts b/packages/core/src/orientation/orientation-renderer.ts index 9b3ff63f..dea977c7 100644 --- a/packages/core/src/orientation/orientation-renderer.ts +++ b/packages/core/src/orientation/orientation-renderer.ts @@ -1,6 +1,5 @@ import { dirname, join } from "node:path"; import { enumerateApprovals, isLazyExpired, loadApproval } from "../approval/approval-store.js"; -import { todayIn } from "../board/effort.js"; import type { BoardGlance } from "../board/glance.js"; import { countOpenDecisionGaps, type DecisionForGapCount } from "../decision-gaps/index.js"; import { type ReplayWarning, replayEvents } from "../events/event-replay.js"; @@ -917,7 +916,7 @@ function formatOrientationBody( at === null || at === undefined ? at : { - date: todayIn(undefined, new Date(at)) ?? at.slice(0, 10), + date: localDay(new Date(at)), age: t.relativeAge(at, now), }; lines.push(`- ${t.orientation.boardLine(last, opts.board.axisVersion)}`); @@ -1361,3 +1360,10 @@ function shortId(id: string): string { if (sep === -1) return id.slice(0, 10); return id.slice(0, sep + 1) + id.slice(sep + 1, sep + 1 + 10); } + +// The day of an instant on this host's clock, as YYYY-MM-DD: the Date's own +// local fields, which follow TZ even where the zone has no name. +function localDay(at: Date): string { + const pad = (n: number, width: number) => String(n).padStart(width, "0"); + return `${pad(at.getFullYear(), 4)}-${pad(at.getMonth() + 1, 2)}-${pad(at.getDate(), 2)}`; +}