diff --git a/CHANGELOG.md b/CHANGELOG.md index 73238ab..d97b61c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ All notable changes to **basou** are recorded here. The project follows ### Added +- The generated views — `handoff.md`, `orientation.md`, `decisions.md`, and + `basou report generate` — now localize their tool-generated chrome (section + headings, labels, freshness-verdict prose) per workspace: the view language + follows the manifest anchor repo's declared `language` ("the workspace speaks + the anchor's language"). `language: ja` on the anchor (the `repos[]` entry + whose `path` is `.`) renders the Japanese chrome exactly as before; `en`, + `en+ja`, an undeclared language, or no roster render English (the new + default). User data — decision titles, notes, labels, file paths — always + passes through verbatim; only the chrome is localized, and there is no + env-var / CLI-flag override (committed team artifacts speak the project's + declared language, not the caller's). All view strings now live in a single + `@basou/core` locale table (`viewStrings` / `resolveViewLanguage`, both + exported), which also shrinks the language-lint E-5 allowlist from four + renderers to that one module. The Codex context channel (`~/.codex/AGENTS.md`) + follows automatically since it carries the orientation body. + +### Changed + +- **Default view language is now English.** A workspace that never declared a + roster (or whose anchor has no `language`) previously rendered Japanese + headings; it now renders English. To keep the Japanese chrome, declare + `language: ja` on the anchor entry (`path: "."`) under `repos:` in + `.basou/manifest.yaml`. Rendered prose is presentation, not contract + (see `docs/spec/compatibility.md`). + - `basou view --check` now flags `redundant` portfolio entries — a registered `~/.basou/portfolio.yaml` path that resolves to the same planning master as another entry (its workspace view, or a member / source-root repo the master diff --git a/docs/spec/generated-markdown.md b/docs/spec/generated-markdown.md index 65bc2cb..67eafed 100644 --- a/docs/spec/generated-markdown.md +++ b/docs/spec/generated-markdown.md @@ -15,8 +15,33 @@ reading provenance. They fall into two categories: region to preserve) and it is also printed to stdout. See §10.7. All four share the same surface convention: an English document title, a -`> Generated at ` line, and Japanese `##` section headings with -mostly-English inline content. +`> Generated at ` line, and localized `##` section headings (see the view +language rule below). + +## §10.0 View language ("the workspace speaks the anchor's language") + +The tool-generated chrome of all four views — section headings, labels, and +verdict prose — is localized per workspace, resolved from the manifest roster: + +- The view language follows the **anchor repo** — the `repos[]` entry whose + `path` is `.` (the planning/trail home the views live in). This is a + deliberate, documented coupling between the anchor's declared audience and + the views' audience; other repos' languages do not participate. +- `language: ja` on the anchor renders Japanese chrome; `en` renders English. +- `en+ja` resolves to **English**: a generated view has exactly one chrome + language, and English is the shared floor of a bilingual audience. +- When no roster, no anchor entry, or no `language` is declared — or the + manifest is missing or unreadable — the fallback is **English**, silently: + rendering never fails or warns over language resolution. + +Only the tool-generated strings are localized. User data — decision titles, +notes, session labels, file paths — always passes through verbatim, whatever +language it is written in. There is no environment-variable or CLI-flag +override: `handoff.md` / `decisions.md` are committed team artifacts, so their +language is a project declaration (the manifest), not a caller preference. + +The rendered prose itself (headings, wording) is presentation, not contract — +see `compatibility.md`. The templates below show the English chrome. ## §10.1 Living-artifact policy (handoff.md, decisions.md) @@ -56,31 +81,36 @@ text on the marker line is accepted to support legacy variants. ## Current state -- Latest session: ses_01HX...C (completed) -- Latest task: task_01HX_lp_form (in_progress) +- Last session: ses_01HX...C (completed) +- Last task: task_01HX_lp_form (in_progress) ## Recently changed files - src/components/ContactForm.tsx - src/styles/contact.css -## Recent decisions +## Latest decision -- Adopted zod for ContactForm validation (decision_01HX...) +- Adopted zod for ContactForm validation [decision_01HX...] -## Open questions +## Unresolved items -- Redirect destination after submission success +- 1 pending approvals ## Files to read next -- src/components/ContactForm.tsx - .basou/decisions.md +- src/components/ContactForm.tsx + +## Work to do next + +- Finalize the post-submission UI flow (planned) [task_01HX...] -## Next steps +## Sessions -- Finalize the post-submission UI flow -- Add an E2E test +| short_id | status | started_at | label | +|---|---|---|---| +| 01HX...C | completed | 2026-05-04T12:00:00+09:00 | lp form | @@ -104,16 +134,16 @@ These fields originate from `decision_recorded` events. ## decision_01HX...: ContactForm validation -- **Date**: 2026-05-04 -- **session**: ses_01HX... -- **Decision**: adopt zod -- **rationale**: integrates with the TypeScript type system; error messages +- date: 2026-05-04 +- session: 01HX... +- decision: adopt zod +- rationale: integrates with the TypeScript type system; error messages are easy to customize -- **alternatives**: yup, joi, hand-written validation -- **rejected_reason**: yup's TypeScript integration is weak; joi is +- alternatives: yup, joi, hand-written validation +- rejected_reason: yup's TypeScript integration is weak; joi is overkill; hand-written is maintenance-heavy -- **linked_events**: evt_01HX..., evt_01HX... -- **linked_files**: src/components/ContactForm.tsx +- linked_events: evt_01HX..., evt_01HX... +- linked_files: src/components/ContactForm.tsx @@ -156,10 +186,9 @@ stdout (JSON-only, pipe-safe). `--title ` adds a subject line. A successfu render always exits 0 — integrity verdicts inside the report are informational and never fail the command (unlike `basou verify`). -**Sections** (in order): `## 概要` (summary), `## 作業量` (volume + time), -`## 判断` (decisions), `## 承認` (approvals), `## タスク` (tasks), -`## 変更ファイル` (changed files), `## セッション一覧` (sessions), `## 整合性` -(integrity). The markdown caps long lists with a `... +N more` line; the `--json` +**Sections** (in order): `## Summary`, `## Work volume` (volume + time), +`## Decisions`, `## Approvals`, `## Tasks`, `## Changed files`, `## Sessions`, +`## Integrity` (localized per §10.0). The markdown caps long lists with a `... +N more` line; the `--json` shape always carries the full set. Changed files union only **non-import** sessions (matching handoff), so cross-workspace round-trip imports do not dominate. @@ -168,19 +197,19 @@ shape always carries the full set. Changed files union only **non-import** sessi > Generated at 2026-05-09T03:00:00.000Z (2026-05-04..2026-05-08) -## 概要 +## Summary - Sessions: 12 (completed 9, failed 1, imported 2) - Active time 6h 12m, 412,300 output tokens -## 作業量 +## Work volume - Output tokens: 412,300 - Actions: 84 commands, 37 files, 9 decisions - Active time: 6h 12m (union; idle gaps > 5m excluded; tz UTC) - Span: 31h 40m (total elapsed) -## 整合性 +## Integrity Provenance internally tamper-checked: 10 verified, 2 unchained, 0 empty, 0 incomplete, 0 in_progress, 0 tampered (of 12 sessions). @@ -216,6 +245,7 @@ next intent only. It MUST NOT show per-agent scorecards, productivity comparisons, or utilization — orientation is self-orientation about your own product, not surveillance of the fleet. -**Sections** (in order): `## 今どこにいる` (where am I now), `## 何が動く` -(what is in flight — structured facts), `## どこへ向かう` (where am I heading), -`## これは最新か` (is this current — capture freshness / coverage). +**Sections** (in order): `## Where you are now`, `## Recent direction (last N +sessions)`, `## What is in flight` (structured facts), `## Where you are +heading`, `## Is this current` (capture freshness / coverage) — localized per +§10.0. diff --git a/packages/cli/src/commands/decisions.test.ts b/packages/cli/src/commands/decisions.test.ts index d0948fb..b8022f2 100644 --- a/packages/cli/src/commands/decisions.test.ts +++ b/packages/cli/src/commands/decisions.test.ts @@ -367,8 +367,8 @@ describe("basou decisions generate", () => { await doRunDecisionsGenerate({}, { cwd: repo, nowProvider: () => FIXED_DATE }); const body = await readFile(basouPaths(repo).files.decisions, "utf8"); expect(body).toContain(`## ${did}: use zod`); - expect(body).toContain("- 決定日: 2026-05-08"); - expect(body).toContain("- 判断: use zod"); + expect(body).toContain("- date: 2026-05-08"); + expect(body).toContain("- decision: use zod"); }); it("case 12: multiple decisions across sessions render chronologically", async () => { diff --git a/packages/cli/src/commands/handoff.test.ts b/packages/cli/src/commands/handoff.test.ts index fe7e456..73e846b 100644 --- a/packages/cli/src/commands/handoff.test.ts +++ b/packages/cli/src/commands/handoff.test.ts @@ -196,7 +196,7 @@ describe("basou handoff generate", () => { expect(joinCalls(out)).toContain("sessions: 1"); }); - it("case 3: 直近の変更ファイル shows only the most recent session's related_files", async () => { + it("case 3: Recently changed files shows only the most recent session's related_files", async () => { const repo = await setupInitedRepo(); // Older session — its files must NOT appear once a newer session supersedes it. await placeSession(repo, { id: SES("X02"), relatedFiles: ["src/a.ts"] }); @@ -356,7 +356,7 @@ describe("basou handoff generate", () => { expect(joinCalls(err)).toContain("Markers mismatched in handoff.md"); }); - it("case 11: pending approvals surface as a count in the 未決事項 section", async () => { + it("case 11: pending approvals surface as a count in the Unresolved items section", async () => { const repo = await setupInitedRepo(); await placeSession(repo, { id: SES("X0A") }); await placePendingApproval(repo, APPR("A01")); diff --git a/packages/cli/src/commands/orient.test.ts b/packages/cli/src/commands/orient.test.ts index 85543f1..86c3f4b 100644 --- a/packages/cli/src/commands/orient.test.ts +++ b/packages/cli/src/commands/orient.test.ts @@ -192,8 +192,8 @@ describe("basou orient", () => { await doRunOrient({}, ctxFor(repo)); const stdout = joinCalls(out); expect(stdout).toContain("# Orientation"); - expect(stdout).toContain("## 今どこにいる"); - expect(stdout).toContain("## これは最新か"); + expect(stdout).toContain("## Where you are now"); + expect(stdout).toContain("## Is this current"); const body = await readFile(basouPaths(repo).files.orientation, "utf8"); expect(body).toContain("# Orientation"); @@ -207,7 +207,7 @@ describe("basou orient", () => { await doRunOrient({ quiet: true }, ctxFor(repo)); const stdout = joinCalls(out); expect(stdout).toContain("Generated .basou/orientation.md"); - expect(stdout).not.toContain("## 今どこにいる"); + expect(stdout).not.toContain("## Where you are now"); const body = await readFile(basouPaths(repo).files.orientation, "utf8"); expect(body).toContain("# Orientation"); }); @@ -243,22 +243,22 @@ describe("basou orient", () => { expect(joinCalls(err)).toContain("Workspace not initialized. Run 'basou init' first."); }); - it("これは最新か: a clean capture (no uncaptured native logs) prints the ✅ current verdict", async () => { + it("Is this current: a clean capture (no uncaptured native logs) prints the ✅ current verdict", async () => { const repo = await setupInitedRepo(); await placeSession(repo, { id: SES("S01"), source: "claude-code-import" }); const out = captureStdout(); await doRunOrient({}, ctxFor(repo)); - expect(joinCalls(out)).toContain("✅ 取り込みは最新です。"); + expect(joinCalls(out)).toContain("✅ The capture is current."); }); - it("これは最新か: an uncaptured native session flips the verdict to ⚠️ stale → run refresh", async () => { + it("Is this current: an uncaptured native session flips the verdict to ⚠️ stale → run refresh", async () => { const repo = await setupInitedRepo(); // A Codex rollout for this repo exists in the native logs but was never imported. await writeCodexRolloutAt(repo, "codex-uncaptured"); const out = captureStdout(); await doRunOrient({}, ctxFor(repo)); const stdout = joinCalls(out); - expect(stdout).toContain("⚠️ 古いです。"); + expect(stdout).toContain("⚠️ Stale."); expect(stdout).toContain("`basou refresh`"); }); @@ -269,8 +269,8 @@ describe("basou orient", () => { const out = captureStdout(); await doRunOrient({ refresh: true }, ctxFor(repo)); const stdout = joinCalls(out); - expect(stdout).toContain("✅ 取り込みは最新です。"); - expect(stdout).not.toContain("⚠️ 古いです。"); + expect(stdout).toContain("✅ The capture is current."); + expect(stdout).not.toContain("⚠️ Stale."); // The session was actually imported (the arc/position reflects 1 session). expect(stdout).toContain("codex"); }); diff --git a/packages/cli/src/commands/report.test.ts b/packages/cli/src/commands/report.test.ts index 092547a..98780cd 100644 --- a/packages/cli/src/commands/report.test.ts +++ b/packages/cli/src/commands/report.test.ts @@ -151,7 +151,7 @@ describe("basou report generate", () => { const text = joinCalls(out); expect(text).toContain("# Report"); - expect(text).toContain("## 整合性"); + expect(text).toContain("## Integrity"); expect(text).toContain("1 verified, 1 unchained"); expect(process.exitCode ?? 0).toBe(0); }); diff --git a/packages/core/src/decisions/decisions-renderer.test.ts b/packages/core/src/decisions/decisions-renderer.test.ts index e94adc7..4895d70 100644 --- a/packages/core/src/decisions/decisions-renderer.test.ts +++ b/packages/core/src/decisions/decisions-renderer.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { stringify } from "yaml"; import { type BasouPaths, ensureBasouDirectory } from "../storage/basou-dir.js"; +import { createManifest, writeManifest } from "../storage/manifest.js"; import { renderDecisions } from "./decisions-renderer.js"; const FIXED_WS_ID = "ws_01HXABCDEF1234567890ABCDEF" as const; @@ -114,9 +115,9 @@ describe("decisions-renderer", () => { const result = await renderDecisions({ paths, nowIso: FIXED_NOW_ISO }); expect(result.decisionCount).toBe(1); expect(result.body).toContain(`## ${did}: use zod`); - expect(result.body).toContain("- 決定日: 2026-05-08"); + expect(result.body).toContain("- date: 2026-05-08"); expect(result.body).toContain("- session:"); - expect(result.body).toContain("- 判断: use zod"); + expect(result.body).toContain("- decision: use zod"); }); it("case 3: aggregates decisions across multiple sessions in chronological order", async () => { @@ -325,8 +326,8 @@ describe("decisions-renderer", () => { ); const result = await renderDecisions({ paths, nowIso: FIXED_NOW_ISO }); expect(result.body).toContain(`## ${did}: legacy`); - expect(result.body).toContain("- 決定日: 2026-05-08"); - expect(result.body).toContain("- 判断: legacy"); + expect(result.body).toContain("- date: 2026-05-08"); + expect(result.body).toContain("- decision: legacy"); expect(result.body).not.toContain("rationale:"); expect(result.body).not.toContain("alternatives:"); expect(result.body).not.toContain("linked_events:"); @@ -388,7 +389,7 @@ describe("decisions-renderer (voided decisions)", () => { }); describe("decisions-renderer (track decisions)", () => { - it("marks an open track with [TRACK] and the 種別 line", async () => { + it("marks an open track with [TRACK] and the kind line", async () => { const paths = await setupPaths(); const sid = SES("T01"); const did = DEC("T01"); @@ -403,11 +404,11 @@ describe("decisions-renderer (track decisions)", () => { ); const result = await renderDecisions({ paths, nowIso: FIXED_NOW_ISO }); expect(result.body).toContain(`## ${did}: admin form coverage [TRACK]`); - expect(result.body).toContain("- 種別: track (close まで orient/handoff に継続表示)"); + expect(result.body).toContain("- kind: track (stays in orient/handoff until closed)"); expect(result.body).toContain("- rationale: raw JSON is a stopgap"); }); - it("a plain decision carries neither the [TRACK] marker nor the 種別 line", async () => { + it("a plain decision carries neither the [TRACK] marker nor the kind line", async () => { const paths = await setupPaths(); const sid = SES("T02"); const did = DEC("T02"); @@ -420,10 +421,10 @@ describe("decisions-renderer (track decisions)", () => { const result = await renderDecisions({ paths, nowIso: FIXED_NOW_ISO }); expect(result.body).toContain(`## ${did}: use zod`); expect(result.body).not.toContain("[TRACK]"); - expect(result.body).not.toContain("- 種別: track"); + expect(result.body).not.toContain("- kind: track"); }); - it("a voided track shows both [VOIDED] and [TRACK] and drops the 種別 (still-surfaced) line", async () => { + it("a voided track shows both [VOIDED] and [TRACK] and drops the kind (still-surfaced) line", async () => { const paths = await setupPaths(); const sid = SES("T03"); const did = DEC("T03"); @@ -436,6 +437,25 @@ describe("decisions-renderer (track decisions)", () => { expect(result.body).toContain(`## ~~${did}: closed track~~ [VOIDED] [TRACK]`); expect(result.body).toContain("VOIDED: shipped"); // A closed track is no longer resurfaced, so it must not claim it is. - expect(result.body).not.toContain("close まで orient/handoff に継続表示"); + expect(result.body).not.toContain("stays in orient/handoff until closed"); + }); +}); + +describe("renderDecisions (view language)", () => { + it("renders Japanese chrome when the manifest anchor declares language: ja", async () => { + const paths = await setupPaths(); + const manifest = createManifest({ workspaceName: "fixture" }); + manifest.repos = [{ path: ".", language: "ja" }]; + await writeManifest(paths, manifest); + await placeSession( + paths, + SES("S01"), + "2026-05-08T11:00:00+09:00", + decisionLine(SES("S01"), "E01", DEC("D01"), "use zod", "2026-05-08T12:00:00+09:00"), + ); + const result = await renderDecisions({ paths, nowIso: FIXED_NOW_ISO }); + expect(result.body).toContain("- 決定日: 2026-05-08"); + expect(result.body).toContain("- 判断: use zod"); + expect(result.body).not.toContain("- decision:"); }); }); diff --git a/packages/core/src/decisions/decisions-renderer.ts b/packages/core/src/decisions/decisions-renderer.ts index 1526bdf..d7c4d2c 100644 --- a/packages/core/src/decisions/decisions-renderer.ts +++ b/packages/core/src/decisions/decisions-renderer.ts @@ -1,6 +1,12 @@ import { lstat } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { type ReplayWarning, replayEvents } from "../events/event-replay.js"; +import { + resolveViewLanguageFromPaths, + type ViewLanguage, + type ViewStrings, + viewStrings, +} from "../lib/view-strings.js"; import type { BasouPaths } from "../storage/basou-dir.js"; import { loadSessionEntries, type SessionSkipReason } from "../storage/sessions.js"; @@ -9,6 +15,12 @@ export type DecisionsRendererInput = { nowIso: string; onWarning?: (warning: ReplayWarning, sessionId: string) => void; onSessionSkip?: (sessionId: string, reason: SessionSkipReason) => void; + /** + * Generated-view chrome language. Omitted (the normal path) = resolved from + * the manifest's anchor repo via {@link resolveViewLanguageFromPaths}; the + * override exists for tests and programmatic callers that already resolved it. + */ + language?: ViewLanguage; }; export type DecisionsRendererResult = { @@ -148,7 +160,9 @@ export async function renderDecisions( return exists; } + const language = input.language ?? (await resolveViewLanguageFromPaths(input.paths)); const body = await formatDecisionsBody({ + language, nowIso: input.nowIso, decisions, knownEventIds, @@ -158,11 +172,13 @@ export async function renderDecisions( } async function formatDecisionsBody(args: { + language: ViewLanguage; nowIso: string; decisions: ReadonlyArray; knownEventIds: ReadonlySet; fileExists: (relPath: string) => Promise; }): Promise { + const t: ViewStrings = viewStrings(args.language); const lines: string[] = []; lines.push("# Decisions"); lines.push(""); @@ -174,7 +190,7 @@ async function formatDecisionsBody(args: { } for (const d of args.decisions) { // A track marker rides on the heading so the audit shows the decision was a - // strategic direction; for an open track it precedes the `- 種別` line below. + // strategic direction; for an open track it precedes the kind line below. const trackMark = d.kind === "track" ? " [TRACK]" : ""; if (d.voided !== undefined) { // Struck heading + a void line; the decision body is kept for the audit @@ -193,15 +209,15 @@ async function formatDecisionsBody(args: { lines.push(""); } const occurredDate = d.occurredAt.slice(0, 10); // YYYY-MM-DD - lines.push(`- 決定日: ${occurredDate}`); + lines.push(`- ${t.decisions.dateLabel}: ${occurredDate}`); // An OPEN track keeps resurfacing in orientation/handoff; note that here so // the full record explains why it is still surfaced (a voided track is closed // and carries the VOIDED line instead). if (d.kind === "track" && d.voided === undefined) { - lines.push("- 種別: track (close まで orient/handoff に継続表示)"); + lines.push(t.decisions.trackKindLine); } lines.push(`- session: ${shortDecisionSessionId(d.sessionId)}`); - lines.push(`- 判断: ${d.title}`); + lines.push(`- ${t.decisions.decisionLabel}: ${d.title}`); if (typeof d.rationale === "string" && d.rationale.length > 0) { lines.push(`- rationale: ${d.rationale}`); } diff --git a/packages/core/src/handoff/handoff-renderer.test.ts b/packages/core/src/handoff/handoff-renderer.test.ts index 416a9d1..3aa3dbf 100644 --- a/packages/core/src/handoff/handoff-renderer.test.ts +++ b/packages/core/src/handoff/handoff-renderer.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { stringify } from "yaml"; import type { TaskStatus } from "../schemas/task.schema.js"; import { type BasouPaths, ensureBasouDirectory } from "../storage/basou-dir.js"; +import { createManifest, writeManifest } from "../storage/manifest.js"; import { renderHandoff } from "./handoff-renderer.js"; const FIXED_WS_ID = "ws_01HXABCDEF1234567890ABCDEF" as const; @@ -259,7 +260,7 @@ describe("handoff-renderer", () => { expect(result.body).toContain(`from ${SHORT(id)}..${SHORT(id)}`); }); - it("case 3: 直近の変更ファイル shows only the most recent session's related_files", async () => { + it("case 3: Recently changed files shows only the most recent session's related_files", async () => { const paths = await setupPaths(); // Older session — its unique files must NOT appear once a newer session // supersedes it. @@ -275,7 +276,7 @@ describe("handoff-renderer", () => { }); const result = await renderHandoff({ paths, nowIso: FIXED_NOW_ISO }); expect(result.sessionCount).toBe(2); - const recentSection = sliceSection(result.body, "## 直近の変更ファイル", "##"); + const recentSection = sliceSection(result.body, "## Recently changed files", "##"); // src/a.ts is only in the older session, so it is excluded; the newer // session's files appear, sorted asc. expect(recentSection).not.toContain("- src/a.ts"); @@ -286,7 +287,7 @@ describe("handoff-renderer", () => { expect(recentSection.split("- src/b.ts\n").length - 1).toBe(1); }); - it("case 4: includes the latest decision_recorded in the 直近の判断 section", async () => { + it("case 4: includes the latest decision_recorded in the Latest decision section", async () => { const paths = await setupPaths(); const id = SES("X04"); const dec1 = DEC("D01"); @@ -326,7 +327,7 @@ describe("handoff-renderer", () => { expect(result.body).toContain("2 suspect sessions detected"); }); - it("case 6: pending approvals surface as a count in the 未決事項 section", async () => { + it("case 6: pending approvals surface as a count in the Unresolved items section", async () => { const paths = await setupPaths(); await placeSession(paths, { id: SES("X07") }); await placePendingApproval(paths, APPR("A01")); @@ -390,7 +391,7 @@ describe("handoff-renderer", () => { const result = await renderHandoff({ paths, nowIso: FIXED_NOW_ISO }); expect(result.body).toContain("# Handoff"); expect(result.body).toContain( - `- 最終 session: fixture ${id.slice(-3)} (completed) [${SHORT(id)}]`, + `- Last session: fixture ${id.slice(-3)} (completed) [${SHORT(id)}]`, ); expect(result.body).toContain("- src/x.ts"); expect(result.body).toContain(`- pick A [${SHORT(dec)}]`); @@ -398,15 +399,15 @@ describe("handoff-renderer", () => { expect(result.body).toContain("Sessions: 1 (completed 1). Tasks: 0."); }); - it("case 11b: 最終 session falls back to the short id when the session has no label", async () => { + it("case 11b: Last session falls back to the short id when the session has no label", async () => { const paths = await setupPaths(); const id = SES("X0R"); await placeSession(paths, { id, status: "completed", omitLabel: true }); const result = await renderHandoff({ paths, nowIso: FIXED_NOW_ISO }); // No label -> the short id becomes the primary text and the trailing [id] // bracket is dropped so the same id is not repeated. - expect(result.body).toContain(`- 最終 session: ${SHORT(id)} (completed)`); - const stateSection = sliceSection(result.body, "## 現在の状態", "## "); + expect(result.body).toContain(`- Last session: ${SHORT(id)} (completed)`); + const stateSection = sliceSection(result.body, "## Current state", "## "); expect(stateSection).not.toContain(`[${SHORT(id)}]`); }); @@ -417,16 +418,16 @@ describe("handoff-renderer", () => { expect(result.body).toContain(`> Generated at ${customNow}`); }); - it("case 13: 最終 task / 次に実行すべき作業 stay placeholder when no tasks exist", async () => { + it("case 13: Last task / Work to do next stay placeholder when no tasks exist", async () => { const paths = await setupPaths(); const result = await renderHandoff({ paths, nowIso: FIXED_NOW_ISO }); expect(result.taskCount).toBe(0); expect(result.pendingTaskCount).toBe(0); - expect(result.body).toContain("- 最終 task: (no tasks recorded yet)"); + expect(result.body).toContain("- Last task: (no tasks recorded yet)"); expect(result.body).toContain("(no pending tasks)"); }); - it("case 14: a single planned task surfaces as 最終 task and in 次に実行すべき作業", async () => { + it("case 14: a single planned task surfaces as Last task and in Work to do next", async () => { const paths = await setupPaths(); const sid = SES("X0D"); const taskId = TASK("T01"); @@ -442,12 +443,12 @@ describe("handoff-renderer", () => { const result = await renderHandoff({ paths, nowIso: FIXED_NOW_ISO }); expect(result.taskCount).toBe(1); expect(result.pendingTaskCount).toBe(1); - expect(result.body).toContain(`- 最終 task: form revamp (planned) [${SHORT(taskId)}]`); + expect(result.body).toContain(`- Last task: form revamp (planned) [${SHORT(taskId)}]`); expect(result.body).toContain(`- form revamp (planned) [${SHORT(taskId)}]`); expect(result.body).toContain("Sessions: 1 (running 1). Tasks: 1."); }); - it("case 15: multi tasks select the latest task_created event for 最終 task", async () => { + it("case 15: multi tasks select the latest task_created event for Last task", async () => { const paths = await setupPaths(); const sid = SES("X0E"); const t1 = TASK("T02"); @@ -471,7 +472,7 @@ describe("handoff-renderer", () => { sessionId: sid, }); const result = await renderHandoff({ paths, nowIso: FIXED_NOW_ISO }); - expect(result.body).toContain(`- 最終 task: second task (planned) [${SHORT(t2)}]`); + expect(result.body).toContain(`- Last task: second task (planned) [${SHORT(t2)}]`); }); it("case 15b: latest task_status_changed wins over task_created when both exist", async () => { @@ -499,11 +500,11 @@ describe("handoff-renderer", () => { sessionId: sid, }); const result = await renderHandoff({ paths, nowIso: FIXED_NOW_ISO }); - // The status-change wins: t1 (now done) surfaces in 最終 task, NOT t2. - expect(result.body).toContain(`- 最終 task: older task (done) [${SHORT(t1)}]`); + // The status-change wins: t1 (now done) surfaces in Last task, NOT t2. + expect(result.body).toContain(`- Last task: older task (done) [${SHORT(t1)}]`); // t2 collapses to the same short id as t1 in this fixture set, so - // distinguish by title: the newer task's title must not appear in 現在の状態. - const stateSection = sliceSection(result.body, "## 現在の状態", "## "); + // distinguish by title: the newer task's title must not appear in Current state. + const stateSection = sliceSection(result.body, "## Current state", "## "); expect(stateSection).not.toContain("newer task"); }); @@ -532,7 +533,7 @@ describe("handoff-renderer", () => { sessionId: sid, }); const result = await renderHandoff({ paths, nowIso: FIXED_NOW_ISO }); - expect(result.body).toContain(`- 最終 task: (title unknown) (in_progress) [${SHORT(taskId)}]`); + expect(result.body).toContain(`- Last task: (title unknown) (in_progress) [${SHORT(taskId)}]`); }); it("case 15c: multi-session task surfaces a (linked_sessions: N) suffix", async () => { @@ -562,7 +563,7 @@ describe("handoff-renderer", () => { }); const result = await renderHandoff({ paths, nowIso: FIXED_NOW_ISO }); expect(result.body).toContain( - `- 最終 task: spans 3 sessions (in_progress, linked_sessions: 3) [${SHORT(taskId)}]`, + `- Last task: spans 3 sessions (in_progress, linked_sessions: 3) [${SHORT(taskId)}]`, ); }); @@ -581,7 +582,7 @@ describe("handoff-renderer", () => { // linkedSessions defaults to [sid] = single-session — suffix MUST NOT appear. }); const result = await renderHandoff({ paths, nowIso: FIXED_NOW_ISO }); - expect(result.body).toContain(`- 最終 task: lone task (planned) [${SHORT(taskId)}]`); + expect(result.body).toContain(`- Last task: lone task (planned) [${SHORT(taskId)}]`); expect(result.body).not.toContain("linked_sessions:"); }); @@ -595,7 +596,7 @@ describe("handoff-renderer", () => { await placeSession(paths, { id: sid, status: "running" }, events); const result = await renderHandoff({ paths, nowIso: FIXED_NOW_ISO }); expect(result.body).toContain( - `- 最終 task: orphaned (status unknown — task.md missing or invalid) [${SHORT(taskId)}]`, + `- Last task: orphaned (status unknown — task.md missing or invalid) [${SHORT(taskId)}]`, ); // The fabricated "planned" status must not leak in: there is no task.md, so // no pending task exists and "(planned)" should appear nowhere. @@ -639,8 +640,8 @@ describe("handoff-renderer", () => { expect(result.pendingTaskCount).toBe(1); // Pending list shows only the in_progress task; done / cancelled excluded. // Scope to the section because the latest-activity task (t3, cancelled) - // still surfaces in 最終 task above with its title. - const pendingSection = sliceSection(result.body, "## 次に実行すべき作業", "## "); + // still surfaces in Last task above with its title. + const pendingSection = sliceSection(result.body, "## Work to do next", "## "); expect(pendingSection).toContain(`- ongoing (in_progress) [${SHORT(t1)}]`); expect(pendingSection).not.toContain("completed"); expect(pendingSection).not.toContain("abandoned"); @@ -683,7 +684,7 @@ describe("handoff-renderer", () => { expect(result.body).not.toContain("running 0"); }); - it("case 18a: imported session related_files stay out of 直近の変更ファイル", async () => { + it("case 18a: imported session related_files stay out of Recently changed files", async () => { const paths = await setupPaths(); const liveSid = SES("X2A"); const importedSid = SES("X2B"); @@ -699,7 +700,7 @@ describe("handoff-renderer", () => { relatedFiles: ["src/imported-from-elsewhere.ts"], }); const result = await renderHandoff({ paths, nowIso: FIXED_NOW_ISO }); - const filesSection = sliceSection(result.body, "## 直近の変更ファイル", "## "); + const filesSection = sliceSection(result.body, "## Recently changed files", "## "); expect(filesSection).toContain("- src/live-only.ts"); expect(filesSection).not.toContain("src/imported-from-elsewhere.ts"); }); @@ -722,11 +723,11 @@ describe("handoff-renderer", () => { const result = await renderHandoff({ paths, nowIso: FIXED_NOW_ISO }); // Live session row sits under the main table, imported session under the // separated subsection. - expect(result.body).toContain("## セッション一覧"); + expect(result.body).toContain("## Sessions"); expect(result.body).toContain("### Imported sessions"); const importedSection = sliceSection(result.body, "### Imported sessions", "Sessions:"); expect(importedSection).toContain("from-external"); - const liveSection = sliceSection(result.body, "## セッション一覧", "### Imported sessions"); + const liveSection = sliceSection(result.body, "## Sessions", "### Imported sessions"); expect(liveSection).toContain("live work"); expect(liveSection).not.toContain("from-external"); }); @@ -734,7 +735,7 @@ describe("handoff-renderer", () => { // Resume coherence (HypArt triage): handoff must carry a staleness caveat on a // trailing decision (F-A, which handoff previously lacked entirely), represent -// 最終 session with a substantive session (F-B), and flag a cross-session +// Last session with a substantive session (F-B), and flag a cross-session // decision (F-C). SES/EVT/DEC suffixes must be 3 Crockford chars (no I/L/O/U). describe("renderHandoff (resume coherence)", () => { it("F-A: a trailing (stale) latest decision carries a staleness caveat", async () => { @@ -754,8 +755,8 @@ describe("renderHandoff (resume coherence)", () => { decisionRecordedLine(s, "HA1", DEC("HA1"), "apply migration?", "2026-05-08T12:00:00Z"), ); const { body } = await renderHandoff({ paths, nowIso: FIXED_NOW_ISO }); - expect(body).toContain("最終活動はこの判断より後です"); - expect(body).toContain("継続点を確認してください"); + expect(body).toContain("the latest activity postdates this decision"); + expect(body).toContain("confirm the continuation point"); }); it("F-A: a fresh latest decision carries no staleness caveat", async () => { @@ -776,10 +777,10 @@ describe("renderHandoff (resume coherence)", () => { ); const { body } = await renderHandoff({ paths, nowIso: FIXED_NOW_ISO }); expect(body).toContain("use pnpm"); - expect(body).not.toContain("最終活動はこの判断より後です"); + expect(body).not.toContain("the latest activity postdates this decision"); }); - it("F-B: 最終 session is the substantive session, not a newer empty resume session", async () => { + it("F-B: Last session is the substantive session, not a newer empty resume session", async () => { const paths = await setupPaths(); const work = SES("HWK"); const resume = SES("HRS"); @@ -800,20 +801,20 @@ describe("renderHandoff (resume coherence)", () => { label: "bare resume", }); const { body } = await renderHandoff({ paths, nowIso: FIXED_NOW_ISO }); - // The 現在の状態 section's 最終 session line should name the substantive session. - const stateSection = body.slice(body.indexOf("## 現在の状態")); - expect(stateSection).toContain("最終 session: real work"); - expect(stateSection).not.toContain("最終 session: bare resume"); - // 直近の変更ファイル is coupled to 最終 session, so it shows the substantive + // The Current state section's Last session line should name the substantive session. + const stateSection = body.slice(body.indexOf("## Current state")); + expect(stateSection).toContain("Last session: real work"); + expect(stateSection).not.toContain("Last session: bare resume"); + // Recently changed files is coupled to Last session, so it shows the substantive // session's files (not the bare resume's empty list). - const filesSection = sliceSection(body, "## 直近の変更ファイル", "## "); + const filesSection = sliceSection(body, "## Recently changed files", "## "); expect(filesSection).toContain("src/a.ts"); expect(filesSection).not.toContain("(no related files recorded)"); }); - it("F-C: flags when the latest decision is from a different session than 最終 session", async () => { + it("F-C: flags when the latest decision is from a different session than Last session", async () => { const paths = await setupPaths(); - const work = SES("HC2"); // substantive + newest -> 最終 session + const work = SES("HC2"); // substantive + newest -> Last session const older = SES("HP2"); // prior session that holds the decision await placeSession(paths, { id: work, @@ -834,7 +835,7 @@ describe("renderHandoff (resume coherence)", () => { decisionRecordedLine(older, "HE2", DEC("HD2"), "an older decision", "2026-05-08T09:30:00Z"), ); const { body } = await renderHandoff({ paths, nowIso: FIXED_NOW_ISO }); - expect(body).toContain("この判断は最終 session とは別の session"); + expect(body).toContain("this decision comes from a different session"); }); }); @@ -858,7 +859,7 @@ function decisionVoidedLine( } describe("renderHandoff (voided decisions)", () => { - it("skips a voided decision when surfacing 直近の判断", async () => { + it("skips a voided decision when surfacing Latest decision", async () => { const paths = await setupPaths(); const id = SES("VH1"); const kept = DEC("HK1"); @@ -892,7 +893,7 @@ describe("renderHandoff (voided decisions)", () => { }); describe("renderHandoff (open tracks)", () => { - it("surfaces an open track with its rationale in a 未完トラック section", async () => { + it("surfaces an open track with its rationale in a Open tracks section", async () => { const paths = await setupPaths(); const id = SES("HT1"); await placeSession( @@ -916,9 +917,9 @@ describe("renderHandoff (open tracks)", () => { ), ); const result = await renderHandoff({ paths, nowIso: FIXED_NOW_ISO }); - expect(result.body).toContain("## 未完トラック (close まで継続表示)"); + expect(result.body).toContain("## Open tracks (shown until closed)"); expect(result.body).toContain("admin form coverage"); - expect(result.body).toContain("理由: raw JSON is a stopgap"); + expect(result.body).toContain("Why: raw JSON is a stopgap"); expect(result.body).toContain("basou decision void"); }); @@ -936,7 +937,7 @@ describe("renderHandoff (open tracks)", () => { kind: "track", }, ) + - // A newer PLAIN decision: under the old model this became 直近の判断 and the + // A newer PLAIN decision: under the old model this became Latest decision and the // track sank into the flat list. The track must still surface. decisionRecordedLine( id, @@ -956,13 +957,13 @@ describe("renderHandoff (open tracks)", () => { events, ); const result = await renderHandoff({ paths, nowIso: FIXED_NOW_ISO }); - // Both coexist: the track in its own section, the later decision as 直近の判断. - expect(result.body).toContain("## 未完トラック"); + // Both coexist: the track in its own section, the later decision as Latest decision. + expect(result.body).toContain("## Open tracks"); expect(result.body).toContain("the strategic track"); expect(result.body).toContain("a later tactical call"); }); - it("omits the 未完トラック section once the track is voided", async () => { + it("omits the Open tracks section once the track is voided", async () => { const paths = await setupPaths(); const id = SES("HT2"); const did = DEC("HT2"); @@ -981,11 +982,11 @@ describe("renderHandoff (open tracks)", () => { events, ); const result = await renderHandoff({ paths, nowIso: FIXED_NOW_ISO }); - expect(result.body).not.toContain("## 未完トラック"); + expect(result.body).not.toContain("## Open tracks"); expect(result.body).not.toContain("closed track"); }); - it("a plain decision does not produce a 未完トラック section", async () => { + it("a plain decision does not produce a Open tracks section", async () => { const paths = await setupPaths(); const id = SES("HT3"); await placeSession( @@ -999,6 +1000,25 @@ describe("renderHandoff (open tracks)", () => { decisionRecordedLine(id, "HT4", DEC("HT4"), "ordinary decision", "2026-05-08T10:00:00.000Z"), ); const result = await renderHandoff({ paths, nowIso: FIXED_NOW_ISO }); - expect(result.body).not.toContain("## 未完トラック"); + expect(result.body).not.toContain("## Open tracks"); + }); +}); + +describe("renderHandoff (view language)", () => { + it("renders Japanese chrome when the manifest anchor declares language: ja", async () => { + const paths = await setupPaths(); + const manifest = createManifest({ workspaceName: "fixture" }); + manifest.repos = [{ path: ".", language: "ja" }]; + await writeManifest(paths, manifest); + await placeSession(paths, { id: SES("S01"), status: "completed" }); + const result = await renderHandoff({ paths, nowIso: FIXED_NOW_ISO }); + expect(result.body).toContain("## 現在の状態"); + expect(result.body).toContain("## 直近の変更ファイル"); + expect(result.body).toContain("## 直近の判断"); + expect(result.body).toContain("## 未決事項"); + expect(result.body).toContain("## 次に読むべきファイル"); + expect(result.body).toContain("## 次に実行すべき作業"); + expect(result.body).toContain("## セッション一覧"); + expect(result.body).toContain("- 最終 session: "); }); }); diff --git a/packages/core/src/handoff/handoff-renderer.ts b/packages/core/src/handoff/handoff-renderer.ts index 9391594..6f612b2 100644 --- a/packages/core/src/handoff/handoff-renderer.ts +++ b/packages/core/src/handoff/handoff-renderer.ts @@ -2,6 +2,11 @@ import { join } from "node:path"; import { enumerateApprovals } from "../approval/approval-store.js"; import { type ReplayWarning, replayEvents } from "../events/event-replay.js"; import { isTrailingStale, pickLatestSubstantiveEntry } from "../lib/recency.js"; +import { + resolveViewLanguageFromPaths, + type ViewLanguage, + viewStrings, +} from "../lib/view-strings.js"; import type { BasouPaths } from "../storage/basou-dir.js"; import { loadSessionEntries, @@ -33,6 +38,12 @@ export type HandoffRendererInput = { onTaskSkip?: (taskId: string, reason: TaskSkipReason) => void; /** Maximum related_files entries to display before `... +N more`. Default 20. */ relatedFilesLimit?: number; + /** + * Generated-view chrome language. Omitted (the normal path) = resolved from + * the manifest's anchor repo via {@link resolveViewLanguageFromPaths}; the + * override exists for tests and programmatic callers that already resolved it. + */ + language?: ViewLanguage; }; export type HandoffRendererResult = { @@ -44,7 +55,7 @@ export type HandoffRendererResult = { suspectCount: number; /** Total number of task.md files successfully loaded. */ taskCount: number; - /** Tasks whose status is `planned` or `in_progress` (= shown in 次に実行すべき作業). */ + /** Tasks whose status is `planned` or `in_progress` (= shown in the next-work section). */ pendingTaskCount: number; }; @@ -86,16 +97,16 @@ type TaskStatusChangedRecord = { * {@link loadSessionEntries} / {@link enumerateApprovals}). It assembles the * the spec's `handoff.md` sections in order: * - * 1. `現在の状態`: latest live session (status not archived, source not import). - * 2. `直近の変更ファイル`: the most recent session's `related_files`, dedup + + * 1. Current state: latest live session (status not archived, source not import). + * 2. Recently changed files: the most recent session's `related_files`, dedup + * sorted asc + truncated to `relatedFilesLimit` (default 20). - * 3. `直近の判断`: latest `decision_recorded` event (chronological). - * 4. `未決事項`: pending-approval count + suspect-session count. - * 5. `次に読むべきファイル`: `.basou/decisions.md` + top-3 related files + * 3. Latest decision: latest `decision_recorded` event (chronological). + * 4. Unresolved items: pending-approval count + suspect-session count. + * 5. Files to read next: `.basou/decisions.md` + top-3 related files * (the same `displayedFiles` source is intentionally reused in two * sections — overview vs. resume context). - * 6. `次に実行すべき作業`: placeholder until task events land. - * 7. `セッション一覧`: all sessions newest first with inline suspect labels. + * 6. Work to do next: placeholder until task events land. + * 7. Sessions: all sessions newest first with inline suspect labels. * * Session enumeration goes through {@link loadSessionEntries} so the set of * sessions whose `decision_recorded` events we replay matches the @@ -123,9 +134,9 @@ export async function renderHandoff(input: HandoffRendererInput): Promise(); @@ -133,7 +144,7 @@ export async function renderHandoff(input: HandoffRendererInput): Promise { @@ -198,7 +209,7 @@ export async function renderHandoff(input: HandoffRendererInput): Promise= 0; i -= 1) { @@ -260,15 +271,16 @@ export async function renderHandoff(input: HandoffRendererInput): Promise e.session.session.status !== "archived" && e.session.session.source.kind !== "import", ); - // Represent 最終 session with the most recent SUBSTANTIVE session, not a bare - // resume/refresh session (e.g. 1 command, 0 files) that merely happens to be - // newest — the latter hides the real-work session and disagrees with 直近の判断. + // Represent the last session with the most recent SUBSTANTIVE session, not a + // bare resume/refresh session (e.g. 1 command, 0 files) that merely happens to + // be newest — the latter hides the real-work session and disagrees with the + // latest decision. const latestSession = pickLatestSubstantiveEntry(liveEntries); - // 「直近の変更ファイル」 shows the files touched by the most recent SUBSTANTIVE - // session — the same session surfaced as 最終 session above — so the section + // "Recently changed files" shows the files touched by the most recent + // SUBSTANTIVE session — the same session surfaced as the last session above — so the section // reflects the latest real activity rather than the whole history. (A bare - // resume session has no related_files anyway, so following 最終 session here + // resume session has no related_files anyway, so following the last session here // shows the substantive work's files instead of an empty list.) Unioning every // session's related_files turned this into a whole-history dump once transcript // imports became the primary source, since each import carries a full day of @@ -287,7 +299,9 @@ export async function renderHandoff(input: HandoffRendererInput): Promise; totalTaskCount: number; }): string { + const t = viewStrings(args.language); const lines: string[] = []; lines.push("# Handoff"); lines.push(""); @@ -347,8 +363,8 @@ function formatHandoffBody(args: { } lines.push(""); - // 現在の状態 - lines.push("## 現在の状態"); + // Current state + lines.push(t.handoff.headingCurrentState); lines.push(""); if (args.latestSession !== undefined) { const status = args.latestSession.session.session.status; @@ -359,12 +375,12 @@ function formatHandoffBody(args: { // available, so it becomes the primary text and the bracket is dropped to // avoid repeating it. if (label !== undefined && label !== "") { - lines.push(`- 最終 session: ${label} (${status}) [${shortId}]`); + lines.push(`- ${t.common.lastSessionLabel}: ${label} (${status}) [${shortId}]`); } else { - lines.push(`- 最終 session: ${shortId} (${status})`); + lines.push(`- ${t.common.lastSessionLabel}: ${shortId} (${status})`); } } else { - lines.push("- 最終 session: (no live sessions)"); + lines.push(`- ${t.common.lastSessionLabel}: (no live sessions)`); } if (args.latestActivityRecord !== undefined) { // Status comes from task.md when available. If the task_created event @@ -385,15 +401,15 @@ function formatHandoffBody(args: { // Lead with the task title; the raw id is demoted to a trailing [short id] // and linked_sessions rides alongside the status. lines.push( - `- 最終 task: ${args.latestActivityRecord.title} (${statusLabel}${linkedSuffix}) [${shortIdWithPrefix(args.latestActivityRecord.taskId)}]`, + `- ${t.handoff.lastTaskLabel}: ${args.latestActivityRecord.title} (${statusLabel}${linkedSuffix}) [${shortIdWithPrefix(args.latestActivityRecord.taskId)}]`, ); } else { - lines.push("- 最終 task: (no tasks recorded yet)"); + lines.push(`- ${t.handoff.lastTaskLabel}: (no tasks recorded yet)`); } lines.push(""); - // 直近の変更ファイル - lines.push("## 直近の変更ファイル"); + // Recently changed files + lines.push(t.handoff.headingRecentFiles); lines.push(""); if (args.displayedFiles.length === 0) { lines.push("(no related files recorded)"); @@ -403,8 +419,8 @@ function formatHandoffBody(args: { } lines.push(""); - // 直近の判断 - lines.push("## 直近の判断"); + // Latest decision + lines.push(t.handoff.headingLatestDecision); lines.push(""); if (args.latestDecision === undefined) { // Either no decisions, or every recorded decision has been voided — in @@ -418,26 +434,22 @@ function formatHandoffBody(args: { // Staleness caveat (mirrors orientation): when real work continued well past // this decision, it may already be resolved/executed — do not let a resume // treat it as the current next step. handoff had no such note before, so a - // stale recorded decision posed unguarded as "直近の判断". + // stale recorded decision posed unguarded as the latest decision. if (args.latestActivityAt !== null && isTrailingStale(args.latestActivityAt, last.occurredAt)) { - lines.push( - " - 注: 最終活動はこの判断より後です。会話で既に解決済みの可能性があるため、再開前に継続点を確認してください(会話での意思決定は自動記録されません。`basou decision capture` で記録できます)。", - ); + lines.push(` - ${t.handoff.decisionStaleNote}`); } - // When the latest decision is from a DIFFERENT session than 最終 session, the + // When the latest decision is from a DIFFERENT session than the last session, the // two "latest" pointers disagree; surface it so the timeline is unambiguous. if (args.latestSession !== undefined && last.sessionId !== args.latestSession.sessionId) { - lines.push( - ` - 注: この判断は最終 session とは別の session [${shortIdWithPrefix(last.sessionId)}] のものです。`, - ); + lines.push(` - ${t.common.decisionOtherSessionNote(shortIdWithPrefix(last.sessionId))}`); } lines.push(""); lines.push(`(${args.decisions.length} decisions total — see decisions.md)`); } lines.push(""); - // 未完トラック — open strategic directions that resurface until closed. Placed - // right after 直近の判断 (both decision-derived) and ahead of the mechanical + // Open tracks — open strategic directions that resurface until closed. Placed + // right after the latest decision (both decision-derived) and ahead of the mechanical // task list: an open track is the strongest "where to resume" signal, carrying // the next essential direction + why across the session boundary. Mirrors the // orientation renderer's forward section. Omitted entirely when none are open. @@ -445,22 +457,22 @@ function formatHandoffBody(args: { const TRACK_DISPLAY_LIMIT = 10; const shown = args.openTracks.slice(0, TRACK_DISPLAY_LIMIT); const overflow = args.openTracks.length - shown.length; - lines.push("## 未完トラック (close まで継続表示)"); + lines.push(t.handoff.headingOpenTracks); lines.push(""); - for (const t of shown) { - lines.push(`- ${t.title} [${shortIdWithPrefix(t.decisionId)}]`); - if (t.rationale !== null && t.rationale.trim() !== "") { - lines.push(` - 理由: ${handoffRationale(t.rationale)}`); + for (const track of shown) { + lines.push(`- ${track.title} [${shortIdWithPrefix(track.decisionId)}]`); + if (track.rationale !== null && track.rationale.trim() !== "") { + lines.push(` - ${t.common.trackWhyLabel}: ${handoffRationale(track.rationale)}`); } } if (overflow > 0) lines.push(`- ... +${overflow} more (see decisions.md)`); lines.push(""); - lines.push("完了したら `basou decision void ` で閉じてください。"); + lines.push(t.handoff.trackCloseInstruction); lines.push(""); } - // 未決事項 - lines.push("## 未決事項"); + // Unresolved items + lines.push(t.handoff.headingUnresolved); lines.push(""); if (args.pendingApprovalsCount > 0) { lines.push(`- ${args.pendingApprovalsCount} pending approvals`); @@ -473,19 +485,19 @@ function formatHandoffBody(args: { } lines.push(""); - // 次に読むべきファイル + // Files to read next // Drop self-reference to handoff.md, include `.basou/decisions.md` + the // top-3 of `displayedFiles` so the section points to concrete files. The // same `displayedFiles` source is reused intentionally (overview vs. // resume context). - lines.push("## 次に読むべきファイル"); + lines.push(t.handoff.headingReadNext); lines.push(""); lines.push("- .basou/decisions.md"); for (const f of args.displayedFiles.slice(0, 3)) lines.push(`- ${f}`); lines.push(""); - // 次に実行すべき作業 - lines.push("## 次に実行すべき作業"); + // Work to do next + lines.push(t.handoff.headingNextWork); lines.push(""); if (args.pendingTasks.length === 0) { lines.push("(no pending tasks)"); @@ -499,11 +511,11 @@ function formatHandoffBody(args: { } lines.push(""); - // セッション一覧 — the main table lists the operator's own sessions newest + // Sessions — the main table lists the operator's own sessions newest // first. This deliberately includes `claude-code-import` sessions: a // transcript captured after the fact via `basou import claude-code` is still // the operator's own work, so it belongs here rather than below. The separate - // 「Imported sessions」 sub-section holds ONLY cross-workspace round-trips + // "Imported sessions" sub-section holds ONLY cross-workspace round-trips // brought in via `basou session import` (source.kind === "import"), so it is // absent whenever there are none. The "(no sessions yet)" placeholder fires // only when the workspace is completely empty; "(no live sessions; …)" fires @@ -512,7 +524,7 @@ function formatHandoffBody(args: { const importedTableEntries = args.entries.filter( (e) => e.session.session.source.kind === "import", ); - lines.push("## セッション一覧"); + lines.push(t.handoff.headingSessions); lines.push(""); if (args.entries.length === 0) { lines.push("(no sessions yet)"); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 925bbe1..ed0b5c4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -100,6 +100,12 @@ export { } from "./lib/path-sanitizer.js"; export type { SourceRootScope } from "./lib/source-root-scope.js"; export { AGENT_INFRA_DIRS, classifyFilesBySourceRoot } from "./lib/source-root-scope.js"; +export type { ViewLanguage, ViewStrings } from "./lib/view-strings.js"; +export { + resolveViewLanguage, + resolveViewLanguageFromPaths, + viewStrings, +} from "./lib/view-strings.js"; export type { OrientationRendererInput, OrientationRendererResult, diff --git a/packages/core/src/lib/view-strings.test.ts b/packages/core/src/lib/view-strings.test.ts new file mode 100644 index 0000000..c0cdd2e --- /dev/null +++ b/packages/core/src/lib/view-strings.test.ts @@ -0,0 +1,164 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +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 { resolveViewLanguage, resolveViewLanguageFromPaths, viewStrings } from "./view-strings.js"; + +type RepoEntry = NonNullable[number]; + +function manifestWithRepos(repos: RepoEntry[]): Pick { + return { repos }; +} + +describe("resolveViewLanguage", () => { + it("defaults to en when there is no manifest", () => { + expect(resolveViewLanguage(null)).toBe("en"); + }); + + it("defaults to en when the manifest declares no roster", () => { + expect(resolveViewLanguage({})).toBe("en"); + }); + + it("defaults to en when the anchor declares no language", () => { + expect(resolveViewLanguage(manifestWithRepos([{ path: "." }]))).toBe("en"); + }); + + it("resolves ja from the anchor repo's declared language", () => { + expect(resolveViewLanguage(manifestWithRepos([{ path: ".", language: "ja" }]))).toBe("ja"); + }); + + it("normalizes the anchor path before matching (./ is the anchor)", () => { + expect(resolveViewLanguage(manifestWithRepos([{ path: "./", language: "ja" }]))).toBe("ja"); + }); + + it("resolves en from an en anchor", () => { + expect(resolveViewLanguage(manifestWithRepos([{ path: ".", language: "en" }]))).toBe("en"); + }); + + it("resolves en+ja to en (one chrome language; en is the shared floor)", () => { + expect(resolveViewLanguage(manifestWithRepos([{ path: ".", language: "en+ja" }]))).toBe("en"); + }); + + it("ignores non-anchor repos' languages (the workspace speaks the anchor's language)", () => { + expect( + resolveViewLanguage( + manifestWithRepos([ + { path: ".", language: "en" }, + { path: "../sibling", language: "ja" }, + ]), + ), + ).toBe("en"); + expect(resolveViewLanguage(manifestWithRepos([{ path: "../sibling", language: "ja" }]))).toBe( + "en", + ); + }); +}); + +describe("resolveViewLanguageFromPaths", () => { + let workDir: string | undefined; + + beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), "basou-view-strings-test-")); + }); + + afterEach(async () => { + if (workDir !== undefined) { + await rm(workDir, { recursive: true, force: true }); + workDir = undefined; + } + }); + + async function setupPaths(): Promise { + if (workDir === undefined) throw new Error("workDir not initialized"); + return ensureBasouDirectory(workDir); + } + + it("defaults to en when the manifest is missing (never breaks a render)", async () => { + const paths = await setupPaths(); + expect(await resolveViewLanguageFromPaths(paths)).toBe("en"); + }); + + it("resolves ja from a manifest whose anchor declares ja", async () => { + const paths = await setupPaths(); + const manifest = createManifest({ workspaceName: "fixture" }); + manifest.repos = [{ path: ".", language: "ja" }]; + await writeManifest(paths, manifest); + expect(await resolveViewLanguageFromPaths(paths)).toBe("ja"); + }); +}); + +describe("viewStrings", () => { + it("returns distinct chrome per language while sharing the key shape", () => { + const en = viewStrings("en"); + const ja = viewStrings("ja"); + expect(en.orientation.headingWhere).toBe("## Where you are now"); + expect(ja.orientation.headingWhere).toBe("## 今どこにいる"); + expect(en.handoff.headingSessions).toBe("## Sessions"); + expect(ja.handoff.headingSessions).toBe("## セッション一覧"); + }); + + it("localizes relative ages", () => { + const now = new Date("2026-05-09T03:00:00.000Z"); + const en = viewStrings("en"); + const ja = viewStrings("ja"); + expect(en.relativeAge("2026-05-08T00:30:00.000Z", now)).toBe("1d 2h ago"); + expect(ja.relativeAge("2026-05-08T00:30:00.000Z", now)).toBe("1日2時間前"); + expect(en.relativeAge(null, now)).toBe("(unknown)"); + expect(ja.relativeAge(null, now)).toBe("(不明)"); + expect(en.relativeAge("2026-05-09T02:59:30.000Z", now)).toBe("just now"); + expect(ja.relativeAge("2026-05-09T02:59:30.000Z", now)).toBe("たった今"); + }); + + // Sweep every parameterized table entry in BOTH languages: a table entry that + // throws or renders empty would corrupt a view at runtime, and the renderers + // only exercise the branches a given fixture hits. This also keeps the two + // tables honest — each function must produce a non-empty line for plausible + // inputs, whichever language is selected. + it("every parameterized entry renders a non-empty line in both languages", () => { + const now = new Date("2026-05-09T03:00:00.000Z"); + for (const lang of ["en", "ja"] as const) { + const t = viewStrings(lang); + const rendered: string[] = [ + // relativeAge branch matrix: unknown / future / just-now / m / h / h+m / d / d+h + t.relativeAge(null, now), + t.relativeAge("2026-05-09T04:00:00.000Z", now), + t.relativeAge("2026-05-09T02:59:30.000Z", now), + t.relativeAge("2026-05-09T02:30:00.000Z", now), + t.relativeAge("2026-05-09T01:00:00.000Z", now), + t.relativeAge("2026-05-09T01:30:30.000Z", now), + t.relativeAge("2026-05-07T03:00:00.000Z", now), + t.relativeAge("2026-05-07T01:00:00.000Z", now), + t.common.decisionOtherSessionNote("ses_0123456789"), + t.orientation.headingRecent(5), + t.orientation.inFlightTasksHeading(1), + t.orientation.pendingApprovalsHeading(1), + t.orientation.suspectSessionsHeading(1), + t.orientation.openTracksHeading(1), + t.orientation.decisionStaleNote("1h ago"), + t.orientation.outOfRootWarning(1, "a.ts"), + t.orientation.nextStepRecordedLabel("1h ago"), + t.orientation.noteStaleNote("1h ago"), + t.orientation.bannerUnverifiable(1), + t.orientation.bannerStale("2 new"), + t.orientation.partNew(2), + t.orientation.partUpdated(1), + ...t.orientation.verdictUnverifiable(1), + ...t.orientation.verdictStale("2 new"), + ...t.orientation.verdictUpdatedOnly(1), + t.orientation.verdictSuspectsAlso(1), + ...t.orientation.verdictEmpty, + ...t.orientation.verdictUnprobed("1h ago", "terminal"), + t.orientation.verdictCurrent("1h ago", "terminal", false), + t.orientation.verdictCurrent("1h ago", "terminal", true), + t.orientation.verdictSuspectsCaveat(1), + ]; + for (const line of rendered) { + expect(typeof line).toBe("string"); + expect(line.length).toBeGreaterThan(0); + } + } + }); +}); diff --git a/packages/core/src/lib/view-strings.ts b/packages/core/src/lib/view-strings.ts new file mode 100644 index 0000000..55952fb --- /dev/null +++ b/packages/core/src/lib/view-strings.ts @@ -0,0 +1,412 @@ +import { normalizeRelativePath } from "../project/relative-path.js"; +import type { Manifest } from "../schemas/manifest.schema.js"; +import type { BasouPaths } from "../storage/basou-dir.js"; +import { readManifest } from "../storage/manifest.js"; + +/** + * The language of the GENERATED-VIEW chrome (headings, labels, verdict prose) + * in handoff.md / orientation.md / decisions.md / report output. + * + * This is deliberately narrower than the manifest's repo `language` axis + * (`en | ja | en+ja`): a generated view has exactly one chrome language, so + * `en+ja` resolves to `en`. User data (decision titles, notes, labels, file + * paths) always passes through verbatim — only the tool-generated strings are + * localized, which is exactly the split this type exists to keep honest. + */ +export type ViewLanguage = "en" | "ja"; + +/** + * Resolve the generated-view language from a manifest: the workspace speaks + * the language of its ANCHOR repo (the `repos[]` entry whose path is `.`). + * + * Rules (fixed by design): + * - anchor declares `ja` -> `ja` + * - anchor declares `en` / `en+ja` -> `en` (a bilingual surface renders one + * chrome; en is the shared floor) + * - no roster / no anchor entry / no declared language -> `en` (the default + * for basou's English-first OSS surface) + * + * Binding the view to the anchor's language is a deliberate, documented + * coupling: the anchor is the planning/trail home the views live in, so its + * declared audience is the views' audience. Other repos' languages do not + * participate. + */ +export function resolveViewLanguage(manifest: Pick | null): ViewLanguage { + if (manifest === null) return "en"; + const anchor = manifest.repos?.find((r) => normalizeRelativePath(r.path) === "."); + return anchor?.language === "ja" ? "ja" : "en"; +} + +/** + * Manifest-reading convenience for the renderers: resolve the view language + * for a workspace, defaulting to `en` when the manifest is missing or + * unreadable (mirrors the orientation renderer's tolerant source_roots read — + * a broken manifest must never break a view render). + */ +export async function resolveViewLanguageFromPaths(paths: BasouPaths): Promise { + try { + return resolveViewLanguage(await readManifest(paths)); + } catch { + return "en"; + } +} + +/** + * Every localized string the four view renderers emit, grouped per renderer + * with a small `common` set for lines that are byte-identical across views. + * Parameterized lines are functions so the two languages can order their + * parts naturally. + * + * This module is the SINGLE home for generated-view Japanese (the E-5 + * language-lint allowlist points here, not at the renderers), so "user data + * language" and "tool chrome language" can never blur together again. + */ +export type ViewStrings = { + /** Localized relative age for prose lines, e.g. "3日4時間前" / "3d 4h ago". */ + relativeAge: (startedAt: string | null, now: Date) => string; + common: { + /** "最終 session" — the latest live session pointer. */ + lastSessionLabel: string; + /** "直近の判断" — the latest recorded decision pointer. */ + latestDecisionLabel: string; + /** "直近の変更ファイル" — the latest session's related files. */ + recentFilesLabel: string; + /** "理由" — a track's rationale label. */ + trackWhyLabel: string; + /** Note that the latest decision comes from a different session. */ + decisionOtherSessionNote: (shortSessionId: string) => string; + }; + orientation: { + headingWhere: string; + headingRecent: (sessionCount: number) => string; + headingInFlight: string; + headingForward: string; + headingCurrency: string; + inFlightTasksHeading: (n: number) => string; + pendingApprovalsHeading: (n: number) => string; + suspectSessionsHeading: (n: number) => string; + openTracksHeading: (n: number) => string; + /** Stale-decision honesty note under 直近の判断. */ + decisionStaleNote: (activityAge: string) => string; + outOfRootWarning: (count: number, files: string) => string; + recentEmpty: string; + recentDecisionsLabel: string; + recentNextStepLabel: string; + recentChangedLabel: string; + trackCloseInstruction: string; + nextStepRecordedLabel: (age: string) => string; + noteStaleNote: (activityAge: string) => string; + fallbackStaleDirection: string; + fallbackStaleReferenceLabel: string; + trackNudge: string; + federatedFreshnessNote: string; + bannerUnverifiable: (n: number) => string; + bannerStale: (parts: string) => string; + partNew: (n: number) => string; + partUpdated: (n: number) => string; + partsJoiner: string; + verdictUnverifiable: (n: number) => [string, string]; + verdictStale: (parts: string) => [string, string]; + verdictUpdatedOnly: (n: number) => [string, string]; + verdictSuspectsAlso: (n: number) => string; + verdictEmpty: [string, string]; + verdictUnprobed: (rel: string, tool: string) => [string, string]; + verdictCurrent: (rel: string, tool: string, hasHosts: boolean) => string; + verdictSuspectsCaveat: (n: number) => string; + verdictScopeDisclaimer: string; + toolTerminal: string; + toolHuman: string; + toolImport: string; + toolUnknown: string; + }; + handoff: { + headingCurrentState: string; + headingRecentFiles: string; + headingLatestDecision: string; + headingOpenTracks: string; + headingUnresolved: string; + headingReadNext: string; + headingNextWork: string; + headingSessions: string; + lastTaskLabel: string; + decisionStaleNote: string; + trackCloseInstruction: string; + }; + decisions: { + dateLabel: string; + trackKindLine: string; + decisionLabel: string; + }; + report: { + headingSummary: string; + headingVolume: string; + headingDecisions: string; + headingApprovals: string; + headingTasks: string; + headingChangedFiles: string; + headingSessions: string; + headingIntegrity: string; + }; +}; + +/** Look up the string table for a resolved view language. */ +export function viewStrings(language: ViewLanguage): ViewStrings { + return language === "ja" ? JA : EN; +} + +/** "3d 4h ago" / "just now" / "(unknown)" — the en localized relative age. */ +function relativeAgeEn(startedAt: string | null, now: Date): string { + if (startedAt === null) return "(unknown)"; + const ms = now.getTime() - Date.parse(startedAt); + if (!Number.isFinite(ms) || ms < 0) return "just now"; + if (ms < 60_000) return "just now"; + const totalMin = Math.floor(ms / 60_000); + const days = Math.floor(totalMin / 1440); + const hours = Math.floor((totalMin % 1440) / 60); + const mins = totalMin % 60; + if (days > 0) return hours > 0 ? `${days}d ${hours}h ago` : `${days}d ago`; + if (hours > 0) return mins > 0 ? `${hours}h ${mins}m ago` : `${hours}h ago`; + return `${mins}m ago`; +} + +/** "3日4時間前" / "たった今" / "(不明)" — the ja localized relative age. */ +function relativeAgeJa(startedAt: string | null, now: Date): string { + if (startedAt === null) return "(不明)"; + const ms = now.getTime() - Date.parse(startedAt); + if (!Number.isFinite(ms) || ms < 0) return "たった今"; + if (ms < 60_000) return "たった今"; + const totalMin = Math.floor(ms / 60_000); + const days = Math.floor(totalMin / 1440); + const hours = Math.floor((totalMin % 1440) / 60); + const mins = totalMin % 60; + if (days > 0) return hours > 0 ? `${days}日${hours}時間前` : `${days}日前`; + if (hours > 0) return mins > 0 ? `${hours}時間${mins}分前` : `${hours}時間前`; + return `${mins}分前`; +} + +const EN: ViewStrings = { + relativeAge: relativeAgeEn, + common: { + lastSessionLabel: "Last session", + latestDecisionLabel: "Latest decision", + recentFilesLabel: "Recently changed files", + trackWhyLabel: "Why", + decisionOtherSessionNote: (sid) => + `Note: this decision comes from a different session [${sid}] than the last session.`, + }, + orientation: { + headingWhere: "## Where you are now", + headingRecent: (n) => `## Recent direction (last ${n} sessions)`, + headingInFlight: "## What is in flight", + headingForward: "## Where you are heading", + headingCurrency: "## Is this current", + inFlightTasksHeading: (n) => `### In-flight tasks (${n})`, + pendingApprovalsHeading: (n) => `### Pending approvals (${n})`, + suspectSessionsHeading: (n) => `### Suspect sessions (${n})`, + openTracksHeading: (n) => `### Open tracks (shown until closed) (${n})`, + decisionStaleNote: (age) => + `Note: this is the latest *recorded* decision. The latest activity (${age}) is more recent, so the current direction may not be reflected here (conversational decisions are not captured automatically; record this session's decisions with \`basou decision capture\`).`, + outOfRootWarning: (count, files) => + `⚠ ${count} outside source_roots (possibly another project): ${files}`, + recentEmpty: "(no records yet)", + recentDecisionsLabel: "Decisions", + recentNextStepLabel: "Next step", + recentChangedLabel: "Changed", + trackCloseInstruction: + "When finished, close it with `basou decision void `. It stays listed here every time until closed.", + nextStepRecordedLabel: (age) => `Next step (recorded, ${age})`, + noteStaleNote: (age) => + `Note: work continued after this was recorded (latest activity ${age}), so this starting point may be stale.`, + fallbackStaleDirection: + "- (no planned tasks or recorded next step — the latest activity postdates the latest decision; ask the user for the continuation point)", + fallbackStaleReferenceLabel: "Reference (possibly stale — not the current direction)", + trackNudge: + 'Once the next essential direction is settled, record it as a track with `basou decision capture` (`"kind":"track"`) / `basou decision record --track` — it stays surfaced here every session until closed.', + federatedFreshnessNote: + "Note: the freshness verdict covers only this machine's local store. Missed work on other hosts cannot be assessed here (run `basou refresh` on each host to sync).", + bannerUnverifiable: (n) => + `> ⚠️ **Re-import needed** — ${n} session(s) changed in the native logs but cannot be imported by a plain refresh. Re-import with \`basou refresh --force\` (details under "Is this current" at the bottom).`, + bannerStale: (parts) => + `> ⚠️ **Stale (uncaptured: ${parts})** — run \`basou refresh\` before starting work (details under "Is this current" at the bottom).`, + partNew: (n) => `${n} new`, + partUpdated: (n) => `${n} updated`, + partsJoiner: ", ", + verdictUnverifiable: (n) => [ + `⚠️ The native logs changed, but ${n} session(s) cannot be safely re-imported by a plain \`basou refresh\` (non-append changes, prior-chain mismatch, etc.).`, + "Re-import with `basou refresh --force`. (`basou verify` is a different check — it inspects already-imported data for tampering/corruption, a separate axis from the suspect count in the header. A clean verify can still leave uncaptured work.)", + ], + verdictStale: (parts) => [ + `⚠️ Stale. There is uncaptured work since the last import (${parts}).`, + "Run `basou refresh` before starting work.", + ], + verdictUpdatedOnly: (n) => [ + `⚠️ ${n} session(s) have been updated. \`basou refresh\` can import them.`, + "(A session still in progress keeps growing after each import, so it will keep appearing here — that is normal.)", + ], + verdictSuspectsAlso: (n) => + `There are also ${n} suspect session(s) (see "Suspect sessions" above).`, + verdictEmpty: [ + "ℹ️ No records yet.", + "Work in this workspace and your current position will appear here.", + ], + verdictUnprobed: (rel, tool) => [ + `ℹ️ Showing the last imported state. Last work: ${rel} (${tool}).`, + "Run `basou refresh` to confirm this is current.", + ], + verdictCurrent: (rel, tool, hasHosts) => + hasHosts + ? `✅ The capture on this host (local) is current. Last work: ${rel} (${tool}). No uncaptured native sessions.` + : `✅ The capture is current. Last work: ${rel} (${tool}). No uncaptured native sessions.`, + verdictSuspectsCaveat: (n) => + `However, ${n} suspect session(s) need attention (see "Suspect sessions" above).`, + verdictScopeDisclaimer: + "Note: this verdict only checks whether captured native sessions are current and whether any are suspect. It does not detect planning-implementation drift or unrecorded decisions.", + toolTerminal: "terminal", + toolHuman: "manual note", + toolImport: "another workspace", + toolUnknown: "unknown", + }, + handoff: { + headingCurrentState: "## Current state", + headingRecentFiles: "## Recently changed files", + headingLatestDecision: "## Latest decision", + headingOpenTracks: "## Open tracks (shown until closed)", + headingUnresolved: "## Unresolved items", + headingReadNext: "## Files to read next", + headingNextWork: "## Work to do next", + headingSessions: "## Sessions", + lastTaskLabel: "Last task", + decisionStaleNote: + "Note: the latest activity postdates this decision. It may already be resolved in conversation — confirm the continuation point before resuming (conversational decisions are not captured automatically; record them with `basou decision capture`).", + trackCloseInstruction: "When finished, close it with `basou decision void `.", + }, + decisions: { + dateLabel: "date", + trackKindLine: "- kind: track (stays in orient/handoff until closed)", + decisionLabel: "decision", + }, + report: { + headingSummary: "## Summary", + headingVolume: "## Work volume", + headingDecisions: "## Decisions", + headingApprovals: "## Approvals", + headingTasks: "## Tasks", + headingChangedFiles: "## Changed files", + headingSessions: "## Sessions", + headingIntegrity: "## Integrity", + }, +}; + +// E-5: the Japanese generated-view chrome. These values must stay +// byte-identical to the pre-i18n renderer output so a workspace that declares +// `language: ja` on its anchor renders exactly what it rendered before. +const JA: ViewStrings = { + relativeAge: relativeAgeJa, + common: { + lastSessionLabel: "最終 session", + latestDecisionLabel: "直近の判断", + recentFilesLabel: "直近の変更ファイル", + trackWhyLabel: "理由", + decisionOtherSessionNote: (sid) => + `注: この判断は最終 session とは別の session [${sid}] のものです。`, + }, + orientation: { + headingWhere: "## 今どこにいる", + headingRecent: (n) => `## 最近の流れ (直近 ${n} session)`, + headingInFlight: "## 何が動く", + headingForward: "## どこへ向かう", + headingCurrency: "## これは最新か", + inFlightTasksHeading: (n) => `### 進行中 task (${n})`, + pendingApprovalsHeading: (n) => `### 承認待ち (${n})`, + suspectSessionsHeading: (n) => `### 要注意 session (${n})`, + openTracksHeading: (n) => `### 未完トラック (close まで継続表示) (${n})`, + decisionStaleNote: (age) => + `注: これは最後に「記録された」判断です。最終活動 (${age}) はこれより後のため、現在の方針が反映されていない可能性があります(会話での意思決定は自動記録されません。\`basou decision capture\` でこの session の判断を記録できます)。`, + outOfRootWarning: (count, files) => + `⚠ source_roots 外 ${count} 件 (別プロジェクトの可能性): ${files}`, + recentEmpty: "(まだ記録がありません)", + recentDecisionsLabel: "判断", + recentNextStepLabel: "次の起点", + recentChangedLabel: "変更", + trackCloseInstruction: + "完了したら `basou decision void ` で閉じてください。閉じるまで毎回ここに表示されます。", + nextStepRecordedLabel: (age) => `次の起点 (記録済み, ${age})`, + noteStaleNote: (age) => + `注: この起点の記録後 (最終活動 ${age}) も作業が続いています。再開点が古い可能性があります。`, + fallbackStaleDirection: + "- (no planned tasks or recorded next step — 最終活動は直近の判断より後です。継続点をユーザに確認してください)", + fallbackStaleReferenceLabel: "参考 (古い可能性・方針ではない)", + trackNudge: + '次に作るべき本質的な方向性が定まったら `basou decision capture` (`"kind":"track"`) / `basou decision record --track` で track 化すると、close まで毎 session ここに継続表示されます。', + federatedFreshnessNote: + "注: 鮮度判定はこのマシンのローカルストアのみが対象です。他ホストの取りこぼしは判定できません(各ホストで basou refresh を実行し同期してください)。", + bannerUnverifiable: (n) => + `> ⚠️ **再取り込みが必要** — native ログが変化したが通常の refresh では取り込めないセッションが ${n} 件あります。\`basou refresh --force\` で再取り込みしてください(詳細は末尾「これは最新か」)。`, + bannerStale: (parts) => + `> ⚠️ **古いです(未取り込み ${parts})** — 着手前に必ず \`basou refresh\` を実行してください(詳細は末尾「これは最新か」)。`, + partNew: (n) => `新規 ${n} 件`, + partUpdated: (n) => `更新 ${n} 件`, + partsJoiner: "・", + verdictUnverifiable: (n) => [ + `⚠️ native ログが変化しましたが、通常の \`basou refresh\` では安全に再取り込みできないセッションが ${n} 件あります(非追記変更・前チェーン不整合など)。`, + "`basou refresh --force` で再取り込みしてください。(`basou verify` は別物=取り込み済みデータの改竄/破損検査で、ヘッダの suspect とは別軸です。verify が clean でも未取り込みは残り得ます。)", + ], + verdictStale: (parts) => [ + `⚠️ 古いです。最後の取り込み以降に未取り込みの作業があります(${parts})。`, + "着手前に必ず `basou refresh` を実行してください。", + ], + verdictUpdatedOnly: (n) => [ + `⚠️ 更新されたセッションが ${n} 件あります。\`basou refresh\` で取り込めます。`, + "(進行中のセッションがある場合、それ自身は取り込み後も増え続けるため残ります=正常です。)", + ], + verdictSuspectsAlso: (n) => + `また要注意セッションが ${n} 件あります(上記「要注意 session」参照)。`, + verdictEmpty: [ + "ℹ️ まだ記録がありません。", + "このワークスペースで作業すると、ここに現在地が表示されます。", + ], + verdictUnprobed: (rel, tool) => [ + `ℹ️ 取り込み済みの状態を表示しています。最後の作業は ${rel}(${tool})。`, + "最新か確認するには `basou refresh` を実行してください。", + ], + verdictCurrent: (rel, tool, hasHosts) => + `✅ ${hasHosts ? "このホスト(ローカル)の" : ""}取り込みは最新です。最後の作業は ${rel}(${tool})。未取り込みの native セッションはありません。`, + verdictSuspectsCaveat: (n) => + `ただし要注意セッションが ${n} 件あります(上記「要注意 session」参照)。`, + verdictScopeDisclaimer: + "注: この判定は取り込み済み native セッションの鮮度と suspect の有無だけを見ます。計画↔実装のドリフトや未記録の意思決定までは検知しません。", + toolTerminal: "ターミナル", + toolHuman: "手動メモ", + toolImport: "他ワークスペース", + toolUnknown: "不明", + }, + handoff: { + headingCurrentState: "## 現在の状態", + headingRecentFiles: "## 直近の変更ファイル", + headingLatestDecision: "## 直近の判断", + headingOpenTracks: "## 未完トラック (close まで継続表示)", + headingUnresolved: "## 未決事項", + headingReadNext: "## 次に読むべきファイル", + headingNextWork: "## 次に実行すべき作業", + headingSessions: "## セッション一覧", + lastTaskLabel: "最終 task", + decisionStaleNote: + "注: 最終活動はこの判断より後です。会話で既に解決済みの可能性があるため、再開前に継続点を確認してください(会話での意思決定は自動記録されません。`basou decision capture` で記録できます)。", + trackCloseInstruction: "完了したら `basou decision void ` で閉じてください。", + }, + decisions: { + dateLabel: "決定日", + trackKindLine: "- 種別: track (close まで orient/handoff に継続表示)", + decisionLabel: "判断", + }, + report: { + headingSummary: "## 概要", + headingVolume: "## 作業量", + headingDecisions: "## 判断", + headingApprovals: "## 承認", + headingTasks: "## タスク", + headingChangedFiles: "## 変更ファイル", + headingSessions: "## セッション一覧", + headingIntegrity: "## 整合性", + }, +}; diff --git a/packages/core/src/orientation/orientation-renderer.test.ts b/packages/core/src/orientation/orientation-renderer.test.ts index e34718e..3a21ff9 100644 --- a/packages/core/src/orientation/orientation-renderer.test.ts +++ b/packages/core/src/orientation/orientation-renderer.test.ts @@ -222,13 +222,13 @@ describe("orientation-renderer", () => { expect(result.decisionCount).toBe(0); expect(result.body).toContain("# Orientation"); - expect(result.body).toContain("## 今どこにいる"); - expect(result.body).toContain("## 何が動く"); - expect(result.body).toContain("## どこへ向かう"); - expect(result.body).toContain("## これは最新か"); - expect(result.body).toContain("- 最終 session: (no live sessions)"); + expect(result.body).toContain("## Where you are now"); + expect(result.body).toContain("## What is in flight"); + expect(result.body).toContain("## Where you are heading"); + expect(result.body).toContain("## Is this current"); + expect(result.body).toContain("- Last session: (no live sessions)"); // Plain verdict (no sessions yet) replaces the raw telemetry in the default view. - expect(result.body).toContain("まだ記録がありません。"); + expect(result.body).toContain("No records yet."); }); it("renders the pending-approval LIST with risk / action / reason (not just a count)", async () => { @@ -245,7 +245,7 @@ describe("orientation-renderer", () => { const result = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); expect(result.pendingApprovalsCount).toBe(1); - expect(result.body).toContain("### 承認待ち (1)"); + expect(result.body).toContain("### Pending approvals (1)"); expect(result.body).toContain("[critical] command: drop the production table"); expect(result.body).toMatch(/session ses_01HXABCDEF/); }); @@ -264,7 +264,7 @@ describe("orientation-renderer", () => { const result = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); expect(result.inFlightTaskCount).toBe(1); - expect(result.body).toContain("### 進行中 task (1)"); + expect(result.body).toContain("### In-flight tasks (1)"); expect(result.body).toContain("ship orientation MVP (in_progress)"); expect(result.body).toContain("linked_sessions: 3"); }); @@ -284,7 +284,7 @@ describe("orientation-renderer", () => { const result = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); expect(result.suspectCount).toBe(1); - expect(result.body).toContain("### 要注意 session (1)"); + expect(result.body).toContain("### Suspect sessions (1)"); expect(result.body).toContain("ended (yaml stale)"); }); @@ -313,7 +313,7 @@ describe("orientation-renderer", () => { const result = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO, verbose: true }); expect(result.decisionCount).toBe(1); - expect(result.body).toContain("直近の判断: adopt orientation re-centering"); + expect(result.body).toContain("Latest decision: adopt orientation re-centering"); expect(result.body).toContain("newest captured session: 2026-05-08T11:00:00+09:00"); expect(result.body).toMatch(/newest .* ago/); expect(result.body).toContain("claude-code-import 1"); @@ -331,7 +331,7 @@ describe("orientation-renderer", () => { expect(lower).not.toContain("utilization"); }); - it("これは最新か: a zero-staleness probe renders the ✅ current verdict with a translated tool name", async () => { + it("Is this current: a zero-staleness probe renders the ✅ current verdict with a translated tool name", async () => { const paths = await setupPaths(); await placeSession(paths, { id: SES("S01"), @@ -345,17 +345,19 @@ describe("orientation-renderer", () => { staleness: { newSessions: 0, updatedSessions: 0 }, }); expect(result.body).toContain( - "✅ 取り込みは最新です。最後の作業は たった今(Codex)。未取り込みの native セッションはありません。", + "✅ The capture is current. Last work: just now (Codex). No uncaptured native sessions.", ); // The verdict scopes its claim: it must NOT imply provenance is comprehensive, // and it explicitly states what it does not detect (drift / unrecorded decisions). - expect(result.body).not.toContain("取りこぼし・要注意なし"); - expect(result.body).toContain("計画↔実装のドリフトや未記録の意思決定までは検知しません"); + expect(result.body).not.toContain("no omissions"); + expect(result.body).toContain( + "It does not detect planning-implementation drift or unrecorded decisions", + ); // The default verdict translates the tool and hides the internal source enum. expect(result.body).not.toContain("codex-import"); }); - it("これは最新か: a non-zero staleness probe renders the ⚠️ stale verdict pointing at refresh", async () => { + it("Is this current: a non-zero staleness probe renders the ⚠️ stale verdict pointing at refresh", async () => { const paths = await setupPaths(); await placeSession(paths, { id: SES("S01"), @@ -368,15 +370,16 @@ describe("orientation-renderer", () => { nowIso: FIXED_NOW_ISO, staleness: { newSessions: 2, updatedSessions: 1 }, }); - expect(result.body).toContain("⚠️ 古いです。"); - expect(result.body).toContain("新規 2 件"); - expect(result.body).toContain("更新 1 件"); - expect(result.body).toContain("着手前に必ず `basou refresh`"); + expect(result.body).toContain("⚠️ Stale."); + expect(result.body).toContain("2 new"); + expect(result.body).toContain("1 updated"); + expect(result.body).toContain("Run `basou refresh` before starting work"); }); - it("これは最新か: an updated-ONLY probe drops the 必ず imperative but still offers refresh + explains the residual", async () => { - // The active session always shows as grown ("更新"), so a "必ず refresh" - // imperative there can never be satisfied (dbp_wp's learned helplessness). + it("Is this current: an updated-ONLY probe drops the before-starting-work imperative but still offers refresh + explains the residual", async () => { + // The active session always shows as grown ("updated"), so a "refresh + // before starting work" imperative there can never be satisfied (dbp_wp's + // learned helplessness). // But refresh CAN clear a finished grown session, so the action must remain // offered (not softened away) — just without the unsatisfiable imperative. const paths = await setupPaths(); @@ -391,17 +394,17 @@ describe("orientation-renderer", () => { nowIso: FIXED_NOW_ISO, staleness: { newSessions: 0, updatedSessions: 1 }, }); - expect(result.body).toContain("⚠️ 更新されたセッションが 1 件"); + expect(result.body).toContain("⚠️ 1 session(s) have been updated"); // Action still offered (finished grown sessions are real, refresh-clearable). - expect(result.body).toContain("`basou refresh` で取り込めます"); + expect(result.body).toContain("`basou refresh` can import them"); // Residual is explained as normal. - expect(result.body).toContain("残ります=正常"); + expect(result.body).toContain("that is normal"); // Crucially NOT the unsatisfiable imperative, and NOT a false ✅. - expect(result.body).not.toContain("着手前に必ず `basou refresh`"); - expect(result.body).not.toContain("✅ 取り込みは最新です。"); + expect(result.body).not.toContain("Run `basou refresh` before starting work"); + expect(result.body).not.toContain("✅ The capture is current."); }); - it("これは最新か: an updated-ONLY probe over a COMPLETED session still surfaces refresh (no over-soft regression)", async () => { + it("Is this current: an updated-ONLY probe over a COMPLETED session still surfaces refresh (no over-soft regression)", async () => { // A finished session that grew (imported mid-flight, then ended with more // events) is real refresh-clearable backlog — it must not be hidden. const paths = await setupPaths(); @@ -416,12 +419,12 @@ describe("orientation-renderer", () => { nowIso: FIXED_NOW_ISO, staleness: { newSessions: 0, updatedSessions: 1 }, }); - expect(result.body).toContain("⚠️ 更新されたセッションが 1 件"); - expect(result.body).toContain("`basou refresh` で取り込めます"); - expect(result.body).not.toContain("✅ 取り込みは最新です。"); + expect(result.body).toContain("⚠️ 1 session(s) have been updated"); + expect(result.body).toContain("`basou refresh` can import them"); + expect(result.body).not.toContain("✅ The capture is current."); }); - it("これは最新か: an updated probe over an ARCHIVED-only store is NOT mis-cleared as 'no records'", async () => { + it("Is this current: an updated probe over an ARCHIVED-only store is NOT mis-cleared as 'no records'", async () => { // newestStartedAt excludes archived sessions, so the store reads as empty for // the position sections — but the probe still counts a grown source. The // updated branch must run BEFORE the 'no records' branch (no false-clear). @@ -437,8 +440,8 @@ describe("orientation-renderer", () => { nowIso: FIXED_NOW_ISO, staleness: { newSessions: 0, updatedSessions: 1 }, }); - expect(result.body).toContain("⚠️ 更新されたセッションが 1 件"); - expect(result.body).not.toContain("ℹ️ まだ記録がありません。"); + expect(result.body).toContain("⚠️ 1 session(s) have been updated"); + expect(result.body).not.toContain("ℹ️ No records yet."); }); it("staleness banner: an updated-ONLY probe shows NO top banner (live-session growth is not actionable up top)", async () => { @@ -456,12 +459,12 @@ describe("orientation-renderer", () => { }); // No banner is emitted (the marker only appears in banner lines); the verdict // still warns at the bottom. - expect(result.body).not.toContain("(詳細は末尾「これは最新か」)"); - expect(result.body).not.toContain("> ⚠️ **古いです"); - expect(result.body).toContain("⚠️ 更新されたセッションが 1 件"); + expect(result.body).not.toContain('(details under "Is this current" at the bottom)'); + expect(result.body).not.toContain("> ⚠️ **Stale"); + expect(result.body).toContain("⚠️ 1 session(s) have been updated"); }); - it("これは最新か: an unrun probe (null) says it cannot confirm rather than claiming current", async () => { + it("Is this current: an unrun probe (null) says it cannot confirm rather than claiming current", async () => { const paths = await setupPaths(); await placeSession(paths, { id: SES("S01"), @@ -470,12 +473,12 @@ describe("orientation-renderer", () => { startedAt: FIXED_NOW_ISO, }); const result = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); - expect(result.body).toContain("ℹ️ 取り込み済みの状態を表示しています。"); - expect(result.body).toContain("最新か確認するには `basou refresh`"); - expect(result.body).not.toContain("✅ 取り込みは最新です。"); + expect(result.body).toContain("ℹ️ Showing the last imported state."); + expect(result.body).toContain("Run `basou refresh` to confirm this is current"); + expect(result.body).not.toContain("✅ The capture is current."); }); - it("これは最新か: a fresh capture with suspect sessions still cautions in the verdict", async () => { + it("Is this current: a fresh capture with suspect sessions still cautions in the verdict", async () => { const paths = await setupPaths(); const id = SES("R01"); await placeSession( @@ -489,11 +492,11 @@ describe("orientation-renderer", () => { nowIso: FIXED_NOW_ISO, staleness: { newSessions: 0, updatedSessions: 0 }, }); - expect(result.body).toContain("✅ 取り込みは最新です。"); - expect(result.body).toContain("ただし要注意セッションが 1 件あります"); + expect(result.body).toContain("✅ The capture is current."); + expect(result.body).toContain("However, 1 suspect session(s) need attention"); }); - it("これは最新か: unverifiable grown sessions block ✅ and point at verify/--force (no false-clear)", async () => { + it("Is this current: unverifiable grown sessions block ✅ and point at verify/--force (no false-clear)", async () => { const paths = await setupPaths(); await placeSession(paths, { id: SES("S01"), @@ -509,16 +512,16 @@ describe("orientation-renderer", () => { nowIso: FIXED_NOW_ISO, staleness: { newSessions: 0, updatedSessions: 0, unverifiableSessions: 2 }, }); - expect(result.body).toContain("通常の `basou refresh` では安全に再取り込みできない"); - expect(result.body).toContain("2 件"); + expect(result.body).toContain("cannot be safely re-imported by a plain `basou refresh`"); + expect(result.body).toContain("2 session(s)"); // The action is refresh --force; verify is named as a SEPARATE integrity axis // (not the action), so a clean verify is not misread as "nothing to import". expect(result.body).toContain("`basou refresh --force`"); - expect(result.body).toContain("`basou verify` は別物"); - expect(result.body).not.toContain("✅ 取り込みは最新です。"); + expect(result.body).toContain("`basou verify` is a different check"); + expect(result.body).not.toContain("✅ The capture is current."); }); - it("staleness banner: a stale probe surfaces a top banner BEFORE 今どこにいる", async () => { + it("staleness banner: a stale probe surfaces a top banner BEFORE Where you are now", async () => { const paths = await setupPaths(); await placeSession(paths, { id: SES("S01"), @@ -532,16 +535,18 @@ describe("orientation-renderer", () => { staleness: { newSessions: 2, updatedSessions: 1 }, }); // The banner is uniquely identified by its trailing pointer to the verdict. - const bannerMarker = "(詳細は末尾「これは最新か」)"; + const bannerMarker = '(details under "Is this current" at the bottom)'; expect(result.body).toContain(bannerMarker); // Confirmed stale: asserted, not hedged, with the uncaptured counts inline. - expect(result.body).toContain("> ⚠️ **古いです(未取り込み 新規 2 件・更新 1 件)**"); - expect(result.body).toContain("着手前に必ず `basou refresh`"); + expect(result.body).toContain("> ⚠️ **Stale (uncaptured: 2 new, 1 updated)**"); + expect(result.body).toContain("Run `basou refresh` before starting work"); // It must appear before the position section so a top-down reader meets it // before the direction / next-step content. - expect(result.body.indexOf(bannerMarker)).toBeLessThan(result.body.indexOf("## 今どこにいる")); + expect(result.body.indexOf(bannerMarker)).toBeLessThan( + result.body.indexOf("## Where you are now"), + ); // The full verdict still renders at the bottom (after the banner). - expect(result.body.indexOf("## これは最新か")).toBeGreaterThan( + expect(result.body.indexOf("## Is this current")).toBeGreaterThan( result.body.indexOf(bannerMarker), ); }); @@ -559,9 +564,9 @@ describe("orientation-renderer", () => { nowIso: FIXED_NOW_ISO, staleness: { newSessions: 0, updatedSessions: 0, unverifiableSessions: 2 }, }); - expect(result.body).toContain("> ⚠️ **再取り込みが必要**"); - expect(result.body.indexOf("> ⚠️ **再取り込みが必要**")).toBeLessThan( - result.body.indexOf("## 今どこにいる"), + expect(result.body).toContain("> ⚠️ **Re-import needed**"); + expect(result.body.indexOf("> ⚠️ **Re-import needed**")).toBeLessThan( + result.body.indexOf("## Where you are now"), ); }); @@ -578,8 +583,8 @@ describe("orientation-renderer", () => { nowIso: FIXED_NOW_ISO, staleness: { newSessions: 0, updatedSessions: 0 }, }); - expect(result.body).toContain("✅ 取り込みは最新です。"); - expect(result.body).not.toContain("(詳細は末尾「これは最新か」)"); + expect(result.body).toContain("✅ The capture is current."); + expect(result.body).not.toContain('(details under "Is this current" at the bottom)'); expect(result.body).not.toContain("> ⚠️"); }); @@ -592,7 +597,7 @@ describe("orientation-renderer", () => { startedAt: FIXED_NOW_ISO, }); const result = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); - expect(result.body).not.toContain("(詳細は末尾「これは最新か」)"); + expect(result.body).not.toContain('(details under "Is this current" at the bottom)'); expect(result.body).not.toContain("> ⚠️"); }); @@ -634,35 +639,35 @@ describe("orientation-renderer", () => { "", "> Generated at 2026-05-09T03:00:00.000Z · sessions 0 · newest (unknown) · pending 0 · suspect 0", "", - "## 今どこにいる", + "## Where you are now", "", - "- 最終 session: (no live sessions)", - "- 直近の判断: (no decisions recorded yet; capture with `basou decision capture`)", - "- 直近の変更ファイル: (none recorded)", + "- Last session: (no live sessions)", + "- Latest decision: (no decisions recorded yet; capture with `basou decision capture`)", + "- Recently changed files: (none recorded)", "", - "## 最近の流れ (直近 5 session)", + "## Recent direction (last 5 sessions)", "", - "- (まだ記録がありません)", + "- (no records yet)", "", - "## 何が動く", + "## What is in flight", "", - "### 進行中 task (0)", + "### In-flight tasks (0)", "- (none)", "", - "### 承認待ち (0)", + "### Pending approvals (0)", "- (none)", "", - "### 要注意 session (0)", + "### Suspect sessions (0)", "- (none)", "", - "## どこへ向かう", + "## Where you are heading", "", "- (no planned tasks or recorded next step yet)", "", - "## これは最新か", + "## Is this current", "", - "ℹ️ まだ記録がありません。", - "このワークスペースで作業すると、ここに現在地が表示されます。", + "ℹ️ No records yet.", + "Work in this workspace and your current position will appear here.", ].join("\n"), ); }); @@ -723,47 +728,47 @@ describe("orientation-renderer", () => { "", "> Generated at 2026-05-09T03:00:00.000Z · sessions 2 · newest just now · pending 1 · suspect 0", "", - "## 今どこにいる", + "## Where you are now", "", - "- 最終 session: fixture S01 (completed) [ses_01HXABCDEF]", - "- 直近の判断: wire portfolio API [decision_01HXABCDEF] (30分前)", + "- Last session: fixture S01 (completed) [ses_01HXABCDEF]", + "- Latest decision: wire portfolio API [decision_01HXABCDEF] (30m ago)", " - 2 decisions total — see decisions.md", - "- 直近の変更ファイル: src/a.ts, src/b.ts (... +2 more)", + "- Recently changed files: src/a.ts, src/b.ts (... +2 more)", "", - "## 最近の流れ (直近 5 session)", + "## Recent direction (last 5 sessions)", "", - "- fixture S01 (たった今)", - " - 判断: earlier decision; wire portfolio API", - "- fixture S02 (1日2時間前)", + "- fixture S01 (just now)", + " - Decisions: earlier decision; wire portfolio API", + "- fixture S02 (1d 2h ago)", "", - "## 何が動く", + "## What is in flight", "", - "### 進行中 task (1)", + "### In-flight tasks (1)", "- ship portfolio MVP (planned) [task_01HXABCDEF] — linked_sessions: 3", "", - "### 承認待ち (1)", + "### Pending approvals (1)", "- [high] command: deploy to production — session ses_01HXABCDEF, since 2026-05-08T11:00:00+09:00 (expired)", "", - "### 要注意 session (0)", + "### Suspect sessions (0)", "- (none)", "", - "## どこへ向かう", + "## Where you are heading", "", "- ship portfolio MVP [task_01HXABCDEF]", "", - "## これは最新か", + "## Is this current", "", - "✅ 取り込みは最新です。最後の作業は たった今(Claude Code)。未取り込みの native セッションはありません。", - "注: この判定は取り込み済み native セッションの鮮度と suspect の有無だけを見ます。計画↔実装のドリフトや未記録の意思決定までは検知しません。", + "✅ The capture is current. Last work: just now (Claude Code). No uncaptured native sessions.", + "Note: this verdict only checks whether captured native sessions are current and whether any are suspect. It does not detect planning-implementation drift or unrecorded decisions.", ].join("\n"), ); }); }); -describe("最近の流れ (recent direction arc)", () => { +describe("Recent direction (direction arc)", () => { function section(body: string): string { - const start = body.indexOf("## 最近の流れ"); - const end = body.indexOf("## 何が動く"); + const start = body.indexOf("## Recent direction"); + const end = body.indexOf("## What is in flight"); return body.slice(start, end); } @@ -782,8 +787,8 @@ describe("最近の流れ (recent direction arc)", () => { const result = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); const s = section(result.body); // Both sessions' decisions appear (the arc), newest first. - expect(s).toContain("判断: adopt pnpm"); - expect(s).toContain("判断: pick zod"); + expect(s).toContain("Decisions: adopt pnpm"); + expect(s).toContain("Decisions: pick zod"); expect(s.indexOf("adopt pnpm")).toBeLessThan(s.indexOf("pick zod")); }); @@ -797,10 +802,10 @@ describe("最近の流れ (recent direction arc)", () => { }); const result = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); const s = section(result.body); - // Sorted + capped to 3 files; no 判断 / 次の起点 line for this session. - expect(s).toContain("変更: src/w.ts, src/x.ts, src/y.ts"); + // Sorted + capped to 3 files; no Decisions / Next step line for this session. + expect(s).toContain("Changed: src/w.ts, src/x.ts, src/y.ts"); expect(s).not.toContain("src/z.ts"); - expect(s).not.toContain("判断:"); + expect(s).not.toContain("Decisions:"); }); it("excludes a voided decision from the arc but still lists the session", async () => { @@ -819,7 +824,7 @@ describe("最近の流れ (recent direction arc)", () => { expect(s).not.toContain("wrong call"); }); - it("surfaces a next_step note as 次の起点 within the arc", async () => { + it("surfaces a next_step note as Next step within the arc", async () => { const paths = await setupPaths(); await placeSession( paths, @@ -827,7 +832,7 @@ describe("最近の流れ (recent direction arc)", () => { noteLine(SES("N01"), "E01", "resume from the migration", FIXED_NOW_ISO, "next_step"), ); const result = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); - expect(section(result.body)).toContain("次の起点: resume from the migration"); + expect(section(result.body)).toContain("Next step: resume from the migration"); }); it("caps the arc at the 5 most recent non-archived sessions", async () => { @@ -904,7 +909,7 @@ describe("最近の流れ (recent direction arc)", () => { ); const result = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); const s = section(result.body); - expect(s).toContain("(まだ記録がありません)"); + expect(s).toContain("(no records yet)"); expect(s).not.toContain("archived-one"); }); }); @@ -1035,12 +1040,12 @@ describe("summarizeOrientation", () => { const result = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); // The decision still shows, now carrying its (stale) age... - expect(result.body).toContain("直近の判断: push wordpress-v0.1.1 tag"); + expect(result.body).toContain("Latest decision: push wordpress-v0.1.1 tag"); // ...and the full note, including the interpolated activity age (ended - // 2026-05-08T20:00+09 = 11:00Z; now 2026-05-09T03:00Z → 16時間前), so the + // 2026-05-08T20:00+09 = 11:00Z; now 2026-05-09T03:00Z → 16h ago), so the // stale decision does not masquerade as the current direction. expect(result.body).toContain( - "注: これは最後に「記録された」判断です。最終活動 (16時間前) はこれより後のため、現在の方針が反映されていない可能性があります(会話での意思決定は自動記録されません。`basou decision capture` でこの session の判断を記録できます)。", + "Note: this is the latest *recorded* decision. The latest activity (16h ago) is more recent, so the current direction may not be reflected here (conversational decisions are not captured automatically; record this session's decisions with `basou decision capture`).", ); }); @@ -1061,8 +1066,8 @@ describe("summarizeOrientation", () => { ); const result = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); - expect(result.body).toContain("直近の判断: adopt lockstep release"); - expect(result.body).not.toContain("注: これは最後に「記録された」判断です。"); + expect(result.body).toContain("Latest decision: adopt lockstep release"); + expect(result.body).not.toContain("Note: this is the latest *recorded* decision."); }); it("flags a trailing decision when the later activity is in another session", async () => { @@ -1101,11 +1106,11 @@ describe("summarizeOrientation", () => { expect(summary.freshness.latestActivityAt).toBe("2026-05-08T22:00:00+09:00"); const result = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); - expect(result.body).toContain("直近の判断: adopt orientation re-centering"); + expect(result.body).toContain("Latest decision: adopt orientation re-centering"); // Firing across sessions is intentional: the recorded decision predates the // most recent captured work regardless of which session that work lives in. expect(result.body).toContain( - "注: これは最後に「記録された」判断です。最終活動 (14時間前) はこれより後のため、現在の方針が反映されていない可能性があります(会話での意思決定は自動記録されません。`basou decision capture` でこの session の判断を記録できます)。", + "Note: this is the latest *recorded* decision. The latest activity (14h ago) is more recent, so the current direction may not be reflected here (conversational decisions are not captured automatically; record this session's decisions with `basou decision capture`).", ); }); @@ -1143,7 +1148,7 @@ describe("summarizeOrientation", () => { const result = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); expect(result.body).toContain( - `- 次の起点 (記録済み, 1日前): resume from: ship v0.24.0 (steps 1-6) [session ${id.slice(0, 14)}]`, + `- Next step (recorded, 1d ago): resume from: ship v0.24.0 (steps 1-6) [session ${id.slice(0, 14)}]`, ); // With a recorded next step present, the empty-direction fallback is gone. expect(result.body).not.toContain("(no planned tasks"); @@ -1166,7 +1171,7 @@ describe("summarizeOrientation", () => { const result = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); // Newlines/indents collapse to single spaces so the note stays one bullet. - expect(result.body).toContain("次の起点 (記録済み, "); + expect(result.body).toContain("Next step (recorded, "); expect(result.body).toContain("line one line two line three"); }); @@ -1182,7 +1187,7 @@ describe("summarizeOrientation", () => { const summary = await summarizeOrientation({ paths, nowIso: FIXED_NOW_ISO }); expect(summary.latestNote).toBeNull(); const result = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); - expect(result.body).not.toContain("次の起点"); + expect(result.body).not.toContain("Next step"); expect(result.body).not.toContain("stale archived note"); }); @@ -1199,7 +1204,7 @@ describe("summarizeOrientation", () => { const summary = await summarizeOrientation({ paths, nowIso: FIXED_NOW_ISO }); expect(summary.latestNote).toBeNull(); const result = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); - expect(result.body).not.toContain("次の起点"); + expect(result.body).not.toContain("Next step"); expect(result.body).not.toContain("started exploring auth.ts"); }); @@ -1220,9 +1225,9 @@ describe("summarizeOrientation", () => { ); const result = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); - expect(result.body).toContain("次の起点 (記録済み,"); + expect(result.body).toContain("Next step (recorded,"); expect(result.body).toContain( - "注: この起点の記録後 (最終活動 16時間前) も作業が続いています。再開点が古い可能性があります。", + "Note: work continued after this was recorded (latest activity 16h ago), so this starting point may be stale.", ); }); @@ -1263,7 +1268,7 @@ describe("summarizeOrientation", () => { }); // Resume coherence (HypArt triage): orient must not present a stale decision as -// direction (F-A), must represent 最終 session with a substantive session (F-B), +// direction (F-A), must represent Last session with a substantive session (F-B), // and must flag when the latest decision is from a different session (F-C). // NOTE: SES/EVT/DEC suffixes must be exactly 3 Crockford chars (no I/L/O/U) so // the synthesized ids pass ULID validation. @@ -1285,8 +1290,8 @@ describe("renderOrientation (resume coherence)", () => { decisionLine(s, "FA1", DEC("FA1"), "apply migration 0014-0018?", "2026-05-08T12:00:00Z"), ); const { body } = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); - expect(body).toContain("継続点をユーザに確認してください"); - expect(body).toContain("参考 (古い可能性"); + expect(body).toContain("ask the user for the continuation point"); + expect(body).toContain("Reference (possibly stale"); expect(body).not.toContain("direction is inferred from recent decisions"); }); @@ -1308,11 +1313,11 @@ describe("renderOrientation (resume coherence)", () => { ); const { body } = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); expect(body).toContain("direction is inferred from recent decisions"); - expect(body).toContain("直近の判断: use pnpm"); - expect(body).not.toContain("継続点をユーザに確認してください"); + expect(body).toContain("Latest decision: use pnpm"); + expect(body).not.toContain("ask the user for the continuation point"); }); - it("F-B: 最終 session is the substantive session, not a newer empty resume session", async () => { + it("F-B: Last session is the substantive session, not a newer empty resume session", async () => { const paths = await setupPaths(); const work = SES("WRK"); const resume = SES("RSM"); @@ -1337,9 +1342,9 @@ describe("renderOrientation (resume coherence)", () => { expect(summary.latestSession?.sessionId).toBe(work); }); - it("F-C: flags when the latest decision is from a different session than 最終 session", async () => { + it("F-C: flags when the latest decision is from a different session than Last session", async () => { const paths = await setupPaths(); - const work = SES("WK2"); // substantive + newest -> 最終 session + const work = SES("WK2"); // substantive + newest -> Last session const older = SES("PR2"); // prior session that holds the decision await placeSession(paths, { id: work, @@ -1360,10 +1365,10 @@ describe("renderOrientation (resume coherence)", () => { decisionLine(older, "PE2", DEC("DC2"), "an older decision", "2026-05-08T09:30:00Z"), ); const { body } = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); - expect(body).toContain("この判断は最終 session とは別の session"); + expect(body).toContain("this decision comes from a different session"); }); - it("F-C: does NOT flag when the latest decision is in 最終 session", async () => { + it("F-C: does NOT flag when the latest decision is in Last session", async () => { const paths = await setupPaths(); const s = SES("SAM"); await placeSession( @@ -1378,7 +1383,7 @@ describe("renderOrientation (resume coherence)", () => { decisionLine(s, "SE3", DEC("DC3"), "same-session decision", "2026-05-08T13:10:00Z"), ); const { body } = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); - expect(body).not.toContain("別の session"); + expect(body).not.toContain("different session"); }); }); @@ -1445,7 +1450,7 @@ describe("renderOrientation (federation / multi-host)", () => { }); expect(body).toContain("@laptop"); expect(body).toContain("> hosts: local, laptop"); - expect(body).toContain("他ホストの取りこぼしは判定できません"); + expect(body).toContain("Missed work on other hosts cannot be assessed here"); }); it("scopes the green freshness verdict to THIS host when federated (no global current claim)", async () => { @@ -1470,12 +1475,12 @@ describe("renderOrientation (federation / multi-host)", () => { federatedRoots: [{ paths: laptop, host: "laptop" }], }); // The probe is local-only, so the green claim must not read as global. - expect(body).toContain("✅ このホスト(ローカル)の取り込みは最新です"); - expect(body).not.toContain("✅ 取り込みは最新です"); - expect(body).toContain("他ホストの取りこぼしは判定できません"); + expect(body).toContain("✅ The capture on this host (local) is current"); + expect(body).not.toContain("✅ The capture is current"); + expect(body).toContain("Missed work on other hosts cannot be assessed here"); }); - it("attributes a remote suspect session to its host (@host on the 要注意 line)", async () => { + it("attributes a remote suspect session to its host (@host on the suspect line)", async () => { const local = await setupPaths(); await placeSession( local, @@ -1494,7 +1499,7 @@ describe("renderOrientation (federation / multi-host)", () => { nowIso: FIXED_NOW_ISO, federatedRoots: [{ paths: laptop, host: "laptop" }], }); - expect(body).toContain("### 要注意 session (1)"); + expect(body).toContain("### Suspect sessions (1)"); const suspectLine = body .split("\n") .find((l) => l.includes("(running)") && l.includes("@laptop")); @@ -1615,11 +1620,11 @@ describe("renderOrientation (cross-project out-of-root files)", () => { relatedFiles: ["src/in-repo.ts", "/etc/hosts", "~/.claude/plans/p.md"], }); const { body } = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); - expect(body).toContain("source_roots 外"); + expect(body).toContain("outside source_roots"); expect(body).toContain("/etc/hosts"); // The advisory line names only the out-of-root file, not the in-repo or // agent-infra ones. - const warnLine = body.split("\n").find((l) => l.includes("source_roots 外")) ?? ""; + const warnLine = body.split("\n").find((l) => l.includes("outside source_roots")) ?? ""; expect(warnLine).toContain("/etc/hosts"); expect(warnLine).not.toContain("src/in-repo.ts"); expect(warnLine).not.toContain(".claude/plans"); @@ -1642,8 +1647,8 @@ describe("renderOrientation (cross-project out-of-root files)", () => { relatedFiles: [...inRepo, "~/zzz-not-a-project/blog.md"], }); const { body } = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); - const warnLine = body.split("\n").find((l) => l.includes("source_roots 外")) ?? ""; - expect(warnLine).toContain("1 件"); + const warnLine = body.split("\n").find((l) => l.includes("outside source_roots")) ?? ""; + expect(warnLine).toContain("⚠ 1 outside source_roots"); expect(warnLine).toContain("~/zzz-not-a-project/blog.md"); }); @@ -1708,12 +1713,12 @@ describe("renderOrientation (cross-project out-of-root files)", () => { relatedFiles: ["/etc/hosts"], }); const { body } = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); - expect(body).not.toContain("source_roots 外"); + expect(body).not.toContain("outside source_roots"); }); }); describe("orientation — open tracks (strategic continuation)", () => { - it("surfaces an open track with its rationale in the 未完トラック frame", async () => { + it("surfaces an open track with its rationale in the Open tracks frame", async () => { const paths = await setupPaths(); const sid = SES("T01"); await placeSession( @@ -1733,10 +1738,10 @@ describe("orientation — open tracks (strategic continuation)", () => { ); const { body, openTrackCount } = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); expect(openTrackCount).toBe(1); - expect(body).toContain("## どこへ向かう"); - expect(body).toContain("### 未完トラック (close まで継続表示) (1)"); + expect(body).toContain("## Where you are heading"); + expect(body).toContain("### Open tracks (shown until closed) (1)"); expect(body).toContain("Phase 5 admin form coverage (6/19)"); - expect(body).toContain("理由: raw-JSON admin editing is a stopgap, not the final shape"); + expect(body).toContain("Why: raw-JSON admin editing is a stopgap, not the final shape"); expect(body).toContain("basou decision void"); }); @@ -1754,7 +1759,7 @@ describe("orientation — open tracks (strategic continuation)", () => { ); const { body, openTrackCount } = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); expect(openTrackCount).toBe(0); - expect(body).not.toContain("### 未完トラック"); + expect(body).not.toContain("### Open tracks"); expect(body).not.toContain("closed track"); }); @@ -1787,7 +1792,7 @@ describe("orientation — open tracks (strategic continuation)", () => { decisionLine(sid, "ET6", DEC("TR5"), "the track", "2026-05-08T12:00:00Z", { kind: "track" }), ); const { body } = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); - expect(body).toContain("### 未完トラック"); + expect(body).toContain("### Open tracks"); expect(body).not.toContain("no planned tasks or recorded next step"); }); @@ -1808,7 +1813,7 @@ describe("orientation — open tracks (strategic continuation)", () => { expect(summary.openTracks[0]?.rationale).toBe("why it matters"); }); - it("an open track survives a LATER plain decision and coexists with 直近の判断 (the intent-leak regression)", async () => { + it("an open track survives a LATER plain decision and coexists with Latest decision (the intent-leak regression)", async () => { const paths = await setupPaths(); const sid = SES("T07"); const events = @@ -1816,7 +1821,7 @@ describe("orientation — open tracks (strategic continuation)", () => { kind: "track", }) + // Newer PLAIN decision: pre-keystone this displaced the track from the - // single 直近の判断 pointer and the track was lost. It must still surface. + // single Latest decision pointer and the track was lost. It must still surface. decisionLine(sid, "ET9", DEC("TR8"), "a later tactical call", "2026-05-08T12:00:00Z"); await placeSession( paths, @@ -1826,9 +1831,9 @@ describe("orientation — open tracks (strategic continuation)", () => { const { body, openTrackCount } = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); expect(openTrackCount).toBe(1); // The later decision is the latest direction… - expect(body).toContain("直近の判断: a later tactical call"); + expect(body).toContain("Latest decision: a later tactical call"); // …yet the strategic track still resurfaces in its own frame. - expect(body).toContain("### 未完トラック"); + expect(body).toContain("### Open tracks"); expect(body).toContain("the strategic track"); }); @@ -1881,7 +1886,149 @@ describe("orientation — open tracks (strategic continuation)", () => { ); const { body, openTrackCount } = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); expect(openTrackCount).toBe(11); - expect(body).toContain("### 未完トラック (close まで継続表示) (11)"); + expect(body).toContain("### Open tracks (shown until closed) (11)"); expect(body).toContain("... +1 more (see decisions.md)"); }); }); + +describe("renderOrientation (view language)", () => { + it("renders Japanese chrome when the manifest anchor declares language: ja", async () => { + const paths = await setupPaths(); + const manifest = createManifest({ workspaceName: "fixture" }); + manifest.repos = [{ path: ".", language: "ja" }]; + await writeManifest(paths, manifest); + await placeSession(paths, { id: SES("S01"), status: "completed" }); + const result = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); + expect(result.body).toContain("## 今どこにいる"); + expect(result.body).toContain("## 最近の流れ (直近 5 session)"); + expect(result.body).toContain("## 何が動く"); + expect(result.body).toContain("## どこへ向かう"); + expect(result.body).toContain("## これは最新か"); + expect(result.body).toContain("- 最終 session: "); + expect(result.body).toContain("ℹ️ 取り込み済みの状態を表示しています。"); + }); + + it("renders en chrome when the anchor declares en+ja (one chrome; en floor)", async () => { + const paths = await setupPaths(); + const manifest = createManifest({ workspaceName: "fixture" }); + manifest.repos = [{ path: ".", language: "en+ja" }]; + await writeManifest(paths, manifest); + await placeSession(paths, { id: SES("S01"), status: "completed" }); + const result = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); + expect(result.body).toContain("## Where you are now"); + expect(result.body).not.toContain("## 今どこにいる"); + }); + + it("honors an explicit language override without reading the manifest", async () => { + const paths = await setupPaths(); + await placeSession(paths, { id: SES("S01"), status: "completed" }); + const result = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO, language: "ja" }); + expect(result.body).toContain("## 今どこにいる"); + }); + + // Golden full-text lock on the JA chrome: `language: ja` promises the exact + // pre-i18n bytes, so any edit to the JA table (or to how the renderer + // assembles it) must show up here as a full-body diff, not slip through a + // handful of toContain probes. The fixture deliberately exercises the wide + // JA surface: banner, all five headings, in-flight tasks/approvals, an open + // track with rationale, a next-step note, the recent-direction arc, and the + // stale freshness verdict. + it("golden: a ja workspace renders this exact body", async () => { + const paths = await setupPaths(); + const manifest = createManifest({ workspaceName: "fixture" }); + manifest.repos = [{ path: ".", language: "ja" }]; + await writeManifest(paths, manifest); + const sid = SES("G01"); + await placeSession( + paths, + { + id: sid, + status: "completed", + label: "golden fixture", + source: "claude-code-import", + startedAt: "2026-05-08T11:00:00+09:00", + endedAt: "2026-05-08T12:00:00+09:00", + relatedFiles: ["src/a.ts", "src/b.ts"], + }, + startedLine(sid, "G01", "2026-05-08T11:00:00+09:00") + + decisionLine( + sid, + "G02", + DEC("G01"), + "adopt zod for schema validation", + "2026-05-08T11:30:00+09:00", + { + kind: "track", + rationale: "single source of truth for schemas", + }, + ) + + noteLine( + sid, + "G03", + "wire the golden fixture into CI", + "2026-05-08T11:40:00+09:00", + "next_step", + ) + + endedLine(sid, "G04", "2026-05-08T12:00:00+09:00"), + ); + await placeTaskFile(paths, { + id: TASK("G01"), + title: "golden task", + status: "planned", + sessionId: sid, + }); + await placePendingApproval(paths, { id: APPR("G01"), sessionId: sid }); + const result = await renderOrientation({ + paths, + nowIso: FIXED_NOW_ISO, + staleness: { newSessions: 1, updatedSessions: 1 }, + }); + expect(result.body).toBe(JA_GOLDEN_BODY); + }); +}); + +const JA_GOLDEN_BODY = [ + "# Orientation", + "", + "> Generated at 2026-05-09T03:00:00.000Z · sessions 1 · newest 25h 00m ago · pending 1 · suspect 0", + "", + "> ⚠️ **古いです(未取り込み 新規 1 件・更新 1 件)** — 着手前に必ず `basou refresh` を実行してください(詳細は末尾「これは最新か」)。", + "", + "## 今どこにいる", + "", + "- 最終 session: golden fixture (completed) [ses_01HXABCDEF]", + "- 直近の判断: adopt zod for schema validation [decision_01HXABCDEF] (1日前)", + "- 直近の変更ファイル: src/a.ts, src/b.ts", + "", + "## 最近の流れ (直近 5 session)", + "", + "- golden fixture (1日前)", + " - 判断: adopt zod for schema validation", + " - 次の起点: wire the golden fixture into CI", + "", + "## 何が動く", + "", + "### 進行中 task (1)", + "- golden task (planned) [task_01HXABCDEF]", + "", + "### 承認待ち (1)", + "- [high] command: deploy to production — session ses_01HXABCDEF, since 2026-05-08T11:00:00+09:00", + "", + "### 要注意 session (0)", + "- (none)", + "", + "## どこへ向かう", + "", + "### 未完トラック (close まで継続表示) (1)", + "- adopt zod for schema validation [decision_01HXABCDEF] (1日前)", + " - 理由: single source of truth for schemas", + "完了したら `basou decision void ` で閉じてください。閉じるまで毎回ここに表示されます。", + "", + "- 次の起点 (記録済み, 1日前): wire the golden fixture into CI [session ses_01HXABCDEF]", + "- golden task [task_01HXABCDEF]", + "", + "## これは最新か", + "", + "⚠️ 古いです。最後の取り込み以降に未取り込みの作業があります(新規 1 件・更新 1 件)。", + "着手前に必ず `basou refresh` を実行してください。", +].join("\n"); diff --git a/packages/core/src/orientation/orientation-renderer.ts b/packages/core/src/orientation/orientation-renderer.ts index e53f088..71b43e3 100644 --- a/packages/core/src/orientation/orientation-renderer.ts +++ b/packages/core/src/orientation/orientation-renderer.ts @@ -4,6 +4,12 @@ import { type ReplayWarning, replayEvents } from "../events/event-replay.js"; import { formatDurationMs } from "../lib/format-duration.js"; import { isTrailingStale, pickLatestSubstantiveEntry } from "../lib/recency.js"; import { AGENT_INFRA_DIRS, classifyFilesBySourceRoot } from "../lib/source-root-scope.js"; +import { + resolveViewLanguageFromPaths, + type ViewLanguage, + type ViewStrings, + viewStrings, +} from "../lib/view-strings.js"; import type { BasouPaths } from "../storage/basou-dir.js"; import { readManifest } from "../storage/manifest.js"; import { @@ -28,7 +34,7 @@ export type OrientationRendererInput = { /** * Result of a read-only dry-run staleness probe (sessions a `basou refresh` * would add or update), computed by the CLI which holds the import context. - * Drives the plain "これは最新か" verdict. `null` / omitted = not probed, so + * Drives the plain "is this current" verdict. `null` / omitted = not probed, so * the verdict says it cannot confirm freshness rather than claiming current. */ staleness?: { @@ -42,6 +48,12 @@ export type OrientationRendererInput = { * reads as a verdict for a supervisor, not developer diagnostics. */ verbose?: boolean; + /** + * Generated-view chrome language. Omitted (the normal path) = resolved from + * the manifest's anchor repo via {@link resolveViewLanguageFromPaths}; the + * override exists for tests and programmatic callers that already resolved it. + */ + language?: ViewLanguage; /** * Additional trail stores to MERGE into this orientation, each a local path * (an SSHFS mount / rsync mirror of another host's `.basou`) tagged with a @@ -97,8 +109,8 @@ type TrackRecord = { type NoteRecord = { body: string; sessionId: string; occurredAt: string; host: string | null }; /** - * One recent session condensed to its direction signal, for the "最近の流れ" - * (recent direction) section. Across the last N non-archived sessions + * One recent session condensed to its direction signal, for the "recent + * direction" section. Across the last N non-archived sessions * (newest first), this surfaces the ARC of recent intent — the decision titles * and next-step notes recorded in EACH session — rather than orientation's * single latest decision/note, which can be stale or missing. When a session @@ -173,7 +185,7 @@ export type OrientationSummary = { decisionCount: number; /** * Open (non-voided) `kind: "track"` decisions — strategic, unfinished - * directions that the forward section ("どこへ向かう") resurfaces every session + * directions that the forward section ("where you are heading") resurfaces every session * until they are closed with `decision void` / supersede. Newest first. This * is the intent-continuity layer: distinct from the single latest decision * (point-in-time) and the recorded next step (`note`), an open track keeps @@ -183,12 +195,12 @@ export type OrientationSummary = { openTracks: TrackRecord[]; /** * Most recent `note_added` over non-archived sessions — the recorded next - * step / handoff ("次の起点") surfaced in the forward section; null when none. + * step / handoff ("next step") surfaced in the forward section; null when none. */ latestNote: NoteRecord | null; /** * The last N non-archived sessions (newest first) condensed to their direction - * signal — the "最近の流れ" (recent direction) arc. Distinct from the single + * signal — the "recent direction" arc. Distinct from the single * latest decision/note: it shows the trajectory of intent across recent * sessions, and falls back to each session's changed files when no decision or * note was captured, so a resuming agent has grounding even when explicit @@ -272,7 +284,7 @@ export async function summarizeOrientation( // One replay pass per session yields three facts: // - `decisions`: chronological `decision_recorded` across ALL sessions. // - `latestNote`: the most recent `note_added` over NON-archived sessions — - // the operator's recorded next step / handoff ("次の起点"), surfaced in the + // the operator's recorded next step / handoff ("next step"), surfaced in the // forward section so a free-text resume hint survives into the next session. // - `latestActivityAt`: the tail of captured activity over NON-archived // sessions = max of the session boundary (ended_at ?? started_at) AND every @@ -295,7 +307,7 @@ export async function summarizeOrientation( // "latest decision" pointer skips them so a voided decision is never // surfaced as the current direction. const voidedDecisionIds = new Set(); - // Per-session direction signals for the "最近の流れ" arc: each non-archived + // Per-session direction signals for the "recent direction" arc: each non-archived // session's decision titles (with ids so a later void can be filtered out) and // next_step note bodies, in chronological order. The recent-session slice + // file fallback are assembled after the scan (voids are resolved first). @@ -414,7 +426,7 @@ export async function summarizeOrientation( return c !== 0 ? c : b.decisionId.localeCompare(a.decisionId); }); - // "最近の流れ" arc: the last N non-archived sessions, newest first, each + // "Recent direction" arc: the last N non-archived sessions, newest first, each // condensed to the decisions/notes it recorded (voided decisions filtered out // so a struck decision is never presented as direction). A session with no // decision and no note falls back to its changed files, so the trajectory is @@ -508,10 +520,10 @@ export async function summarizeOrientation( const liveEntries = entries.filter( (e) => e.session.session.status !== "archived" && e.session.session.source.kind !== "import", ); - // Represent "最終 session" with the most recent SUBSTANTIVE session, not a bare + // Represent the last session with the most recent SUBSTANTIVE session, not a bare // resume/refresh session (e.g. 1 command, 0 files) that merely happens to be - // newest — the latter hides the real-work session and makes 最終 session and - // 直近の判断 disagree. Freshness ("newest captured session", below) still uses + // newest — the latter hides the real-work session and makes the last-session and + // latest-decision pointers disagree. Freshness ("newest captured session", below) still uses // pure recency, so the staleness signal stays honest. const latestEntry = pickLatestSubstantiveEntry(liveEntries); // `label` is `z.string().optional()` in the session schema — a parsed session @@ -647,10 +659,12 @@ export async function renderOrientation( input: OrientationRendererInput, ): Promise { const summary = await summarizeOrientation(input); + const language = input.language ?? (await resolveViewLanguageFromPaths(input.paths)); return { body: formatOrientationBody(summary, { staleness: input.staleness ?? null, verbose: input.verbose === true, + language, }), sessionCount: summary.sessionCount, pendingApprovalsCount: summary.pendingApprovals.length, @@ -670,8 +684,10 @@ function formatOrientationBody( unverifiableSessions?: number; } | null; verbose: boolean; + language: ViewLanguage; }, ): string { + const t = viewStrings(opts.language); const lines: string[] = []; const now = new Date(summary.generatedAt); const newestRel = relativeAge(summary.freshness.newestStartedAt ?? undefined, now); @@ -691,34 +707,36 @@ function formatOrientationBody( // Staleness banner up top: when there is uncaptured/grown native work, a // reader grounding top-down should meet the warning BEFORE the direction / - // "next step" sections, not only in the "これは最新か" verdict at the very + // "next step" sections, not only in the "is this current" verdict at the very // bottom (which is easy to start working before ever reaching). Shown only for // the actionable-stale states; the full verdict still renders at the end. - const banner = stalenessBanner(opts.staleness); + const banner = stalenessBanner(opts.staleness, t); if (banner.length > 0) { for (const line of banner) lines.push(line); lines.push(""); } // "where am I now" - lines.push("## 今どこにいる"); + lines.push(t.orientation.headingWhere); lines.push(""); if (summary.latestSession !== null) { const s = summary.latestSession; const sid = shortId(s.sessionId); if (s.label !== null && s.label !== "") { - lines.push(`- 最終 session: ${s.label} (${s.status}) [${sid}]${hostSuffix(s.host)}`); + lines.push( + `- ${t.common.lastSessionLabel}: ${s.label} (${s.status}) [${sid}]${hostSuffix(s.host)}`, + ); } else { - lines.push(`- 最終 session: ${sid} (${s.status})${hostSuffix(s.host)}`); + lines.push(`- ${t.common.lastSessionLabel}: ${sid} (${s.status})${hostSuffix(s.host)}`); } } else { - lines.push("- 最終 session: (no live sessions)"); + lines.push(`- ${t.common.lastSessionLabel}: (no live sessions)`); } if (summary.latestDecision !== null) { const dec = summary.latestDecision; - const decAge = relativeAgeJa(dec.occurredAt, now); + const decAge = t.relativeAge(dec.occurredAt, now); lines.push( - `- 直近の判断: ${dec.title} [${shortId(dec.decisionId)}] (${decAge})${hostSuffix(dec.host)}`, + `- ${t.common.latestDecisionLabel}: ${dec.title} [${shortId(dec.decisionId)}] (${decAge})${hostSuffix(dec.host)}`, ); // Honesty over recency theater: this is the latest *recorded* decision, not // necessarily the latest decision. When captured activity continued well @@ -730,30 +748,28 @@ function formatOrientationBody( // honest whether the later activity is in the same session or another. const activityAt = summary.freshness.latestActivityAt; if (activityAt !== null && isTrailingStale(activityAt, dec.occurredAt)) { - lines.push( - ` - 注: これは最後に「記録された」判断です。最終活動 (${relativeAgeJa(activityAt, now)}) はこれより後のため、現在の方針が反映されていない可能性があります(会話での意思決定は自動記録されません。\`basou decision capture\` でこの session の判断を記録できます)。`, - ); + lines.push(` - ${t.orientation.decisionStaleNote(t.relativeAge(activityAt, now))}`); } // When the latest recorded decision comes from a DIFFERENT session than the // representative latest session, the two "latest" pointers disagree. Say so, // so a resume reader does not treat an older thread's decision as this // session's direction (a linear-timeline cue, not a stale claim). if (summary.latestSession !== null && dec.sessionId !== summary.latestSession.sessionId) { - lines.push( - ` - 注: この判断は最終 session とは別の session [${shortId(dec.sessionId)}] のものです。`, - ); + lines.push(` - ${t.common.decisionOtherSessionNote(shortId(dec.sessionId))}`); } if (summary.decisionCount > 1) { lines.push(` - ${summary.decisionCount} decisions total — see decisions.md`); } } else { - lines.push("- 直近の判断: (no decisions recorded yet; capture with `basou decision capture`)"); + lines.push( + `- ${t.common.latestDecisionLabel}: (no decisions recorded yet; capture with \`basou decision capture\`)`, + ); } if (summary.relatedFiles.displayed.length > 0) { const shown = summary.relatedFiles.displayed.join(", "); const more = summary.relatedFiles.overflow > 0 ? ` (... +${summary.relatedFiles.overflow} more)` : ""; - lines.push(`- 直近の変更ファイル: ${shown}${more}`); + lines.push(`- ${t.common.recentFilesLabel}: ${shown}${more}`); if (summary.relatedFiles.outOfRoot.length > 0) { // Cross-project boundary crossing: the latest session edited files // outside this project's source_roots. Flag it so a resuming agent does @@ -765,12 +781,10 @@ function formatOrientationBody( const shownOut = out.slice(0, OUT_OF_ROOT_DISPLAY).join(", "); const outMore = out.length > OUT_OF_ROOT_DISPLAY ? ` (... +${out.length - OUT_OF_ROOT_DISPLAY} more)` : ""; - lines.push( - ` - ⚠ source_roots 外 ${out.length} 件 (別プロジェクトの可能性): ${shownOut}${outMore}`, - ); + lines.push(` - ${t.orientation.outOfRootWarning(out.length, `${shownOut}${outMore}`)}`); } } else { - lines.push("- 直近の変更ファイル: (none recorded)"); + lines.push(`- ${t.common.recentFilesLabel}: (none recorded)`); } lines.push(""); @@ -778,35 +792,37 @@ function formatOrientationBody( // net so a resuming agent sees the recent trajectory of intent (decisions / // next steps), or the activity (changed files) when capture was missed, rather // than only the single latest decision/note above. - lines.push(`## 最近の流れ (直近 ${RECENT_DIRECTION_SESSIONS} session)`); + lines.push(t.orientation.headingRecent(RECENT_DIRECTION_SESSIONS)); lines.push(""); if (summary.recentDirection.length === 0) { - lines.push("- (まだ記録がありません)"); + lines.push(`- ${t.orientation.recentEmpty}`); } else { for (const s of summary.recentDirection) { const sid = shortId(s.sessionId); - const age = relativeAgeJa(s.occurredAt, now); + const age = t.relativeAge(s.occurredAt, now); const head = s.label !== null && s.label !== "" ? s.label : sid; lines.push(`- ${head} (${age})${hostSuffix(s.host)}`); if (s.decisions.length > 0) { const more = s.decisionsOverflow > 0 ? ` (+${s.decisionsOverflow})` : ""; - lines.push(` - 判断: ${s.decisions.map(noteSummary).join("; ")}${more}`); + lines.push( + ` - ${t.orientation.recentDecisionsLabel}: ${s.decisions.map(noteSummary).join("; ")}${more}`, + ); } for (const note of s.notes) { - lines.push(` - 次の起点: ${noteSummary(note)}`); + lines.push(` - ${t.orientation.recentNextStepLabel}: ${noteSummary(note)}`); } // Files appear only as the fallback (no decision and no note this session). if (s.files.length > 0) { - lines.push(` - 変更: ${s.files.join(", ")}`); + lines.push(` - ${t.orientation.recentChangedLabel}: ${s.files.join(", ")}`); } } } lines.push(""); // "what is in flight" — structured facts - lines.push("## 何が動く"); + lines.push(t.orientation.headingInFlight); lines.push(""); - lines.push(`### 進行中 task (${summary.inFlightTasks.length})`); + lines.push(t.orientation.inFlightTasksHeading(summary.inFlightTasks.length)); if (summary.inFlightTasks.length === 0) { lines.push("- (none)"); } else { @@ -816,7 +832,7 @@ function formatOrientationBody( } } lines.push(""); - lines.push(`### 承認待ち (${summary.pendingApprovals.length})`); + lines.push(t.orientation.pendingApprovalsHeading(summary.pendingApprovals.length)); if (summary.pendingApprovals.length === 0) { lines.push("- (none)"); } else { @@ -828,7 +844,7 @@ function formatOrientationBody( } } lines.push(""); - lines.push(`### 要注意 session (${summary.suspects.length})`); + lines.push(t.orientation.suspectSessionsHeading(summary.suspects.length)); if (summary.suspects.length === 0) { lines.push("- (none)"); } else { @@ -841,7 +857,7 @@ function formatOrientationBody( lines.push(""); // "where am I heading" - lines.push("## どこへ向かう"); + lines.push(t.orientation.headingForward); lines.push(""); // Open tracks lead the forward section: a strategic, unfinished direction // ("the next essential thing to build, and why") is the most important thing to @@ -854,12 +870,14 @@ function formatOrientationBody( const TRACK_DISPLAY_LIMIT = 10; const shownTracks = summary.openTracks.slice(0, TRACK_DISPLAY_LIMIT); const trackOverflow = summary.openTracks.length - shownTracks.length; - lines.push(`### 未完トラック (close まで継続表示) (${summary.openTracks.length})`); - for (const t of shownTracks) { - const trackAge = relativeAgeJa(t.occurredAt, now); - lines.push(`- ${t.title} [${shortId(t.decisionId)}] (${trackAge})${hostSuffix(t.host)}`); - if (t.rationale !== null && t.rationale.trim() !== "") { - lines.push(` - 理由: ${trackRationale(t.rationale)}`); + lines.push(t.orientation.openTracksHeading(summary.openTracks.length)); + for (const track of shownTracks) { + const trackAge = t.relativeAge(track.occurredAt, now); + lines.push( + `- ${track.title} [${shortId(track.decisionId)}] (${trackAge})${hostSuffix(track.host)}`, + ); + if (track.rationale !== null && track.rationale.trim() !== "") { + lines.push(` - ${t.common.trackWhyLabel}: ${trackRationale(track.rationale)}`); } } if (trackOverflow > 0) { @@ -867,31 +885,27 @@ function formatOrientationBody( } // Section-scoped close instruction: a top-level line (not an indented sub- // bullet) so it reads as guidance for the whole list, mirroring handoff. - lines.push( - "完了したら `basou decision void ` で閉じてください。閉じるまで毎回ここに表示されます。", - ); + lines.push(t.orientation.trackCloseInstruction); lines.push(""); } // The recorded next step (a `basou note`) is the operator's explicit resume // hint; surface it first so a free-text handoff survives into the next session // rather than living only in a decision title or an external memory file. if (summary.latestNote !== null) { - const noteAge = relativeAgeJa(summary.latestNote.occurredAt, now); + const noteAge = t.relativeAge(summary.latestNote.occurredAt, now); lines.push( - `- 次の起点 (記録済み, ${noteAge}): ${noteSummary(summary.latestNote.body)} [session ${shortId(summary.latestNote.sessionId)}]${hostSuffix(summary.latestNote.host)}`, + `- ${t.orientation.nextStepRecordedLabel(noteAge)}: ${noteSummary(summary.latestNote.body)} [session ${shortId(summary.latestNote.sessionId)}]${hostSuffix(summary.latestNote.host)}`, ); // Same honesty guard as the latest decision: if captured activity continued // well past when this resume hint was recorded, the work may have moved on, // so flag it rather than presenting a stale starting point as current. const activityAt = summary.freshness.latestActivityAt; if (activityAt !== null && isTrailingStale(activityAt, summary.latestNote.occurredAt)) { - lines.push( - ` - 注: この起点の記録後 (最終活動 ${relativeAgeJa(activityAt, now)}) も作業が続いています。再開点が古い可能性があります。`, - ); + lines.push(` - ${t.orientation.noteStaleNote(t.relativeAge(activityAt, now))}`); } } - for (const t of summary.plannedTasks) { - lines.push(`- ${t.title} [${shortId(t.id)}]`); + for (const task of summary.plannedTasks) { + lines.push(`- ${task.title} [${shortId(task.id)}]`); } // Fall back to the decision hint only when there is no open track, no recorded // next step, and no planned task — otherwise the section already says where to @@ -910,14 +924,12 @@ function formatOrientationBody( // that treats it as the next task can re-attempt completed work. Ask for the // continuation point instead, and demote the decision to a labelled // reference rather than an instruction (aligns the forward section with the - // staleness warning already shown on the 直近の判断 line above). - lines.push( - "- (no planned tasks or recorded next step — 最終活動は直近の判断より後です。継続点をユーザに確認してください)", - ); - lines.push(` - 参考 (古い可能性・方針ではない): ${dec.title}`); + // staleness warning already shown on the latest-decision line above). + lines.push(t.orientation.fallbackStaleDirection); + lines.push(` - ${t.orientation.fallbackStaleReferenceLabel}: ${dec.title}`); } else { lines.push("- (no planned tasks — direction is inferred from recent decisions)"); - lines.push(` - 直近の判断: ${dec.title}`); + lines.push(` - ${t.common.latestDecisionLabel}: ${dec.title}`); } // Discoverability nudge: fires when there ARE recorded decisions but none give // a durable forward direction (latest is stale, or just point-in-time) — the @@ -927,9 +939,7 @@ function formatOrientationBody( // yet) so it is a hint at the right time, not noise, and never shown when an // open track / note / planned task already gives direction. if (dec !== null) { - lines.push( - ' - 次に作るべき本質的な方向性が定まったら `basou decision capture` (`"kind":"track"`) / `basou decision record --track` で track 化すると、close まで毎 session ここに継続表示されます。', - ); + lines.push(` - ${t.orientation.trackNudge}`); } } lines.push(""); @@ -938,18 +948,16 @@ function formatOrientationBody( // I am looking at the latest and complete, and if not, what should I do? Raw // ISO / per-source counts / source roots / a zero suspect count are diagnostics // and move under `--verbose`. - lines.push("## これは最新か"); + lines.push(t.orientation.headingCurrency); lines.push(""); - for (const line of freshnessVerdict(summary, opts.staleness, now)) lines.push(line); + for (const line of freshnessVerdict(summary, opts.staleness, now, t)) lines.push(line); // The verdict above reflects the LOCAL store only (the dry-run probe reads // this machine's native logs). With federated hosts merged in, do not let it // imply the whole multi-host view is current — the other hosts' freshness is // unknowable here (their native logs are not on this machine). if (summary.hosts.length > 0) { lines.push(""); - lines.push( - "注: 鮮度判定はこのマシンのローカルストアのみが対象です。他ホストの取りこぼしは判定できません(各ホストで basou refresh を実行し同期してください)。", - ); + lines.push(t.orientation.federatedFreshnessNote); } if (opts.verbose) { @@ -991,7 +999,7 @@ function formatOrientationBody( * Translate an internal source kind into the tool name a supervisor recognizes. * Unknown kinds pass through verbatim so a new adapter is never silently mislabeled. */ -function toolDisplayName(kind: string | null): string { +function toolDisplayName(kind: string | null, t: ViewStrings): string { switch (kind) { case "claude-code-import": case "claude-code-adapter": @@ -999,62 +1007,61 @@ function toolDisplayName(kind: string | null): string { case "codex-import": return "Codex"; case "terminal": - return "ターミナル"; + return t.orientation.toolTerminal; case "human": - return "手動メモ"; + return t.orientation.toolHuman; case "import": - return "他ワークスペース"; + return t.orientation.toolImport; default: - return kind ?? "不明"; + return kind ?? t.orientation.toolUnknown; } } /** * A concise staleness banner for the TOP of the orientation, shown only when * there is uncaptured/grown native work to pull in (the states the full - * "これは最新か" verdict flags with ⚠️). Surfaced near the header so a reader - * grounding top-down meets it before the direction / "次の起点" sections, not + * "is this current" verdict flags with ⚠️). Surfaced near the header so a reader + * grounding top-down meets it before the direction / next-step sections, not * only at the very bottom. Returns [] when the capture is current, empty, or * unprobed — nothing actionable to flag up top (the bottom verdict still covers * those neutral states). */ function stalenessBanner( staleness: { newSessions: number; updatedSessions: number; unverifiableSessions?: number } | null, + t: ViewStrings, ): string[] { if (staleness === null) return []; if ((staleness.unverifiableSessions ?? 0) > 0) { - return [ - `> ⚠️ **再取り込みが必要** — native ログが変化したが通常の refresh では取り込めないセッションが ${staleness.unverifiableSessions} 件あります。\`basou refresh --force\` で再取り込みしてください(詳細は末尾「これは最新か」)。`, - ]; + return [t.orientation.bannerUnverifiable(staleness.unverifiableSessions ?? 0)]; } // Assert ONLY for genuinely uncaptured (never-imported) sessions: those are - // real backlog a `basou refresh` WILL pull in. A merely GROWN ("更新") session - // is dominated by the live session you are in — its transcript always advances - // past the last import, so refreshing re-grows it immediately and a "必ず - // refresh" imperative there can never be satisfied (the learned-helplessness - // dbp_wp reported). That state is the live session (normal), so it gets no top - // banner — only the calm bottom verdict explains it. + // real backlog a `basou refresh` WILL pull in. A merely GROWN ("updated") + // session is dominated by the live session you are in — its transcript always + // advances past the last import, so refreshing re-grows it immediately and a + // "must refresh" imperative there can never be satisfied (the learned- + // helplessness dbp_wp reported). That state is the live session (normal), so + // it gets no top banner — only the calm bottom verdict explains it. if (staleness.newSessions > 0) { - const parts: string[] = [`新規 ${staleness.newSessions} 件`]; - if (staleness.updatedSessions > 0) parts.push(`更新 ${staleness.updatedSessions} 件`); - return [ - `> ⚠️ **古いです(未取り込み ${parts.join("・")})** — 着手前に必ず \`basou refresh\` を実行してください(詳細は末尾「これは最新か」)。`, - ]; + const parts: string[] = [t.orientation.partNew(staleness.newSessions)]; + if (staleness.updatedSessions > 0) + parts.push(t.orientation.partUpdated(staleness.updatedSessions)); + return [t.orientation.bannerStale(parts.join(t.orientation.partsJoiner))]; } return []; } /** - * The plain "これは最新か" verdict: a status line plus one human sentence that - * answers "is this current, and if not what do I do?". Freshness comes from the - * dry-run `staleness` probe (uncaptured/grown native work); when it was not run - * the verdict says so instead of claiming current. A non-zero suspect count is - * surfaced as a caution even when the capture is fresh. + * The plain "is this current" verdict: a status line plus one human sentence + * that answers "is this current, and if not what do I do?". Freshness comes + * from the dry-run `staleness` probe (uncaptured/grown native work); when it + * was not run the verdict says so instead of claiming current. A non-zero + * suspect count is surfaced as a caution even when the capture is fresh. */ function freshnessVerdict( summary: OrientationSummary, staleness: { newSessions: number; updatedSessions: number; unverifiableSessions?: number } | null, now: Date, + t: ViewStrings, ): string[] { // Unverifiable wins absolutely first: a source that GREW but could not be // re-imported safely (broken chain / unreadable / non-append) means the @@ -1062,70 +1069,55 @@ function freshnessVerdict( // Claiming "current" here is the false-clear this verdict exists to prevent, // so it is surfaced ahead of every other state, including "no records". if (staleness !== null && (staleness.unverifiableSessions ?? 0) > 0) { - return [ - `⚠️ native ログが変化しましたが、通常の \`basou refresh\` では安全に再取り込みできないセッションが ${staleness.unverifiableSessions} 件あります(非追記変更・前チェーン不整合など)。`, - "`basou refresh --force` で再取り込みしてください。(`basou verify` は別物=取り込み済みデータの改竄/破損検査で、ヘッダの suspect とは別軸です。verify が clean でも未取り込みは残り得ます。)", - ]; + return [...t.orientation.verdictUnverifiable(staleness.unverifiableSessions ?? 0)]; } // Genuinely uncaptured (NEVER-imported) sessions are real backlog a refresh // WILL pull in, even when the store is still empty — so assert it, and check it - // before the "no records" branch. A merely GROWN ("更新") session is NOT - // asserted here: the live session you are in always shows as grown, and a "必ず - // refresh" there can never be satisfied (it re-grows immediately). That case is - // the calm "updated-only" branch below. + // before the "no records" branch. A merely GROWN ("updated") session is NOT + // asserted here: the live session you are in always shows as grown, and a + // "must refresh" there can never be satisfied (it re-grows immediately). That + // case is the calm "updated-only" branch below. if (staleness !== null && staleness.newSessions > 0) { - const parts: string[] = [`新規 ${staleness.newSessions} 件`]; - if (staleness.updatedSessions > 0) parts.push(`更新 ${staleness.updatedSessions} 件`); - return [ - `⚠️ 古いです。最後の取り込み以降に未取り込みの作業があります(${parts.join("・")})。`, - "着手前に必ず `basou refresh` を実行してください。", - ]; + const parts: string[] = [t.orientation.partNew(staleness.newSessions)]; + if (staleness.updatedSessions > 0) + parts.push(t.orientation.partUpdated(staleness.updatedSessions)); + return [...t.orientation.verdictStale(parts.join(t.orientation.partsJoiner))]; } - // Grown ("更新") sessions only — no never-imported, no unverifiable. `basou + // Grown ("updated") sessions only — no never-imported, no unverifiable. `basou // refresh` CAN import these (e.g. a finished session imported mid-flight that // then gained more events), so it stays an actionable ⚠️ and the action is // offered — NOT softened to a silent ℹ️ that would hide that backlog. But the // imperative is dropped: the LIVE session you are in always shows as grown - // (its transcript advances past the last import), so a "必ず refresh" can never - // drive the count to zero. Explaining that residual is normal removes the - // learned helplessness dbp_wp reported. Placed before the "no records" branch - // so an archived-only store with a grown source is not mis-cleared as empty. + // (its transcript advances past the last import), so a "must refresh" can + // never drive the count to zero. Explaining that residual is normal removes + // the learned helplessness dbp_wp reported. Placed before the "no records" + // branch so an archived-only store with a grown source is not mis-cleared as + // empty. if (staleness !== null && staleness.updatedSessions > 0) { - const lines = [ - `⚠️ 更新されたセッションが ${staleness.updatedSessions} 件あります。\`basou refresh\` で取り込めます。`, - "(進行中のセッションがある場合、それ自身は取り込み後も増え続けるため残ります=正常です。)", - ]; + const lines = [...t.orientation.verdictUpdatedOnly(staleness.updatedSessions)]; if (summary.suspects.length > 0) { - lines.push( - `また要注意セッションが ${summary.suspects.length} 件あります(上記「要注意 session」参照)。`, - ); + lines.push(t.orientation.verdictSuspectsAlso(summary.suspects.length)); } return lines; } if (summary.freshness.newestStartedAt === null) { - return [ - "ℹ️ まだ記録がありません。", - "このワークスペースで作業すると、ここに現在地が表示されます。", - ]; + return [...t.orientation.verdictEmpty]; } - const rel = relativeAgeJa(summary.freshness.newestStartedAt, now); - const tool = toolDisplayName(summary.freshness.newestSource); + const rel = t.relativeAge(summary.freshness.newestStartedAt, now); + const tool = toolDisplayName(summary.freshness.newestSource, t); const suspectCount = summary.suspects.length; if (staleness === null) { - return [ - `ℹ️ 取り込み済みの状態を表示しています。最後の作業は ${rel}(${tool})。`, - "最新か確認するには `basou refresh` を実行してください。", - ]; + return [...t.orientation.verdictUnprobed(rel, tool)]; } - // The probe ran and found no uncaptured/grown native sessions, so the IMPORT is - // current. Scope the claim to exactly that — the old "取りこぼし・要注意なし" - // (no omissions / nothing to worry about) overclaimed: this verdict only checks + // The probe ran and found no uncaptured/grown native sessions, so the IMPORT + // is current. Scope the claim to exactly that — the old wording ("no + // omissions / nothing to worry about") overclaimed: this verdict only checks // that captured native sessions are imported and none are suspect. It does NOT // (and from telemetry alone cannot) detect planning/implementation drift or // unrecorded decisions, so it must not imply provenance is comprehensive. @@ -1134,34 +1126,14 @@ function freshnessVerdict( // machine). Scope the green claim to THIS host so it never reads as "the whole // multi-host view is current" — the local-only-freshness caveat below adds the // per-host sync guidance. Local-only views keep the original wording. - const localScope = summary.hosts.length > 0 ? "このホスト(ローカル)の" : ""; - const lines = [ - `✅ ${localScope}取り込みは最新です。最後の作業は ${rel}(${tool})。未取り込みの native セッションはありません。`, - ]; + const lines = [t.orientation.verdictCurrent(rel, tool, summary.hosts.length > 0)]; if (suspectCount > 0) { - lines.push(`ただし要注意セッションが ${suspectCount} 件あります(上記「要注意 session」参照)。`); + lines.push(t.orientation.verdictSuspectsCaveat(suspectCount)); } - lines.push( - "注: この判定は取り込み済み native セッションの鮮度と suspect の有無だけを見ます。計画↔実装のドリフトや未記録の意思決定までは検知しません。", - ); + lines.push(t.orientation.verdictScopeDisclaimer); return lines; } -/** Japanese relative age, e.g. "7時間26分前" / "3日前" / "たった今", for the verdict line. */ -function relativeAgeJa(startedAt: string | null, now: Date): string { - if (startedAt === null) return "(不明)"; - const ms = now.getTime() - Date.parse(startedAt); - if (!Number.isFinite(ms) || ms < 0) return "たった今"; - if (ms < 60_000) return "たった今"; - const totalMin = Math.floor(ms / 60_000); - const days = Math.floor(totalMin / 1440); - const hours = Math.floor((totalMin % 1440) / 60); - const mins = totalMin % 60; - if (days > 0) return hours > 0 ? `${days}日${hours}時間前` : `${days}日前`; - if (hours > 0) return mins > 0 ? `${hours}時間${mins}分前` : `${hours}時間前`; - return `${mins}分前`; -} - /** "3h 05m ago" / "just now" / "(unknown)" for a session's age relative to `now`. */ function relativeAge(startedAt: string | undefined, now: Date): string { if (startedAt === undefined) return "(unknown)"; @@ -1172,7 +1144,7 @@ function relativeAge(startedAt: string | undefined, now: Date): string { return `${formatDurationMs(ms)} ago`; } -// "最近の流れ" caps: how many recent non-archived sessions to digest, and how +// "Recent direction" caps: how many recent non-archived sessions to digest, and how // many decisions / notes / fallback files to show per session. Kept small so the // section stays a compact trajectory, not a full log (that is `basou handoff`). const RECENT_DIRECTION_SESSIONS = 5; diff --git a/packages/core/src/report/report-renderer.test.ts b/packages/core/src/report/report-renderer.test.ts index de3121e..6efb398 100644 --- a/packages/core/src/report/report-renderer.test.ts +++ b/packages/core/src/report/report-renderer.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { stringify } from "yaml"; import type { SessionSourceKind } from "../schemas/index.js"; import { type BasouPaths, ensureBasouDirectory } from "../storage/basou-dir.js"; +import { createManifest, writeManifest } from "../storage/manifest.js"; import { renderReport } from "./report-renderer.js"; const WS_ID = "ws_01HXABCDEF1234567890ABCDEF" as const; @@ -242,14 +243,14 @@ describe("report-renderer", () => { // Title + all sections present. expect(body).toContain("# Report — Client X"); for (const h of [ - "## 概要", - "## 作業量", - "## 判断", - "## 承認", - "## タスク", - "## 変更ファイル", - "## セッション一覧", - "## 整合性", + "## Summary", + "## Work volume", + "## Decisions", + "## Approvals", + "## Tasks", + "## Changed files", + "## Sessions", + "## Integrity", ]) { expect(body).toContain(h); } @@ -403,3 +404,26 @@ describe("renderReport (track decisions)", () => { expect(data.decisions.items.find((d) => d.id === plain)?.track).toBeUndefined(); }); }); + +describe("renderReport (view language)", () => { + it("renders Japanese section headings when the manifest anchor declares language: ja", async () => { + const paths = await setupPaths(); + const manifest = createManifest({ workspaceName: "fixture" }); + manifest.repos = [{ path: ".", language: "ja" }]; + await writeManifest(paths, manifest, { force: true }); + await placeSession(paths, { id: SES("S01"), startedAt: "2026-05-08T11:00:00Z" }); + const { body } = await renderReport({ paths, nowIso: NOW_ISO, timeZone: "UTC" }); + for (const h of [ + "## 概要", + "## 作業量", + "## 判断", + "## 承認", + "## タスク", + "## 変更ファイル", + "## セッション一覧", + "## 整合性", + ]) { + expect(body).toContain(h); + } + }); +}); diff --git a/packages/core/src/report/report-renderer.ts b/packages/core/src/report/report-renderer.ts index 8b84f0e..106c7ed 100644 --- a/packages/core/src/report/report-renderer.ts +++ b/packages/core/src/report/report-renderer.ts @@ -7,6 +7,12 @@ import { import { type ReplayWarning, replayEvents } from "../events/event-replay.js"; import { type ChainVerdictStatus, verifyEventsChain } from "../events/verify.js"; import { formatDurationMs } from "../lib/format-duration.js"; +import { + resolveViewLanguageFromPaths, + type ViewLanguage, + type ViewStrings, + viewStrings, +} from "../lib/view-strings.js"; import type { ApprovalStatus, RiskLevel, @@ -27,7 +33,7 @@ import { loadTaskEntries, type TaskSkipReason } from "../storage/tasks.js"; * Caps on how many items each section lists in the rendered markdown. The * structured `ReportData` always keeps the FULL set (machine consumers get * everything); only the human-facing markdown truncates (with a `... +N more` - * line) so a report over a large workspace stays readable and "簡易". + * line) so a report over a large workspace stays readable and compact. */ const CHANGED_FILES_MARKDOWN_LIMIT = 50; const DECISIONS_MARKDOWN_LIMIT = 20; @@ -65,6 +71,12 @@ export type ReportRendererInput = { onWarning?: (warning: ReplayWarning, sessionId: string) => void; onSessionSkip?: (sessionId: string, reason: SessionSkipReason) => void; onTaskSkip?: (taskId: string, reason: TaskSkipReason) => void; + /** + * Generated-view chrome language. Omitted (the normal path) = resolved from + * the manifest's anchor repo via {@link resolveViewLanguageFromPaths}; the + * override exists for tests and programmatic callers that already resolved it. + */ + language?: ViewLanguage; }; export type ReportSessionItem = { @@ -341,7 +353,8 @@ export async function renderReport(input: ReportRendererInput): Promise): TaskStatusCount[ })); } -function formatReportBody(data: ReportData): string { +function formatReportBody(data: ReportData, t: ViewStrings): string { const lines: string[] = []; const titleSuffix = data.title !== undefined ? ` — ${data.title}` : ""; lines.push(`# Report${titleSuffix}`); @@ -389,7 +402,7 @@ function formatReportBody(data: ReportData): string { lines.push(""); // Summary - lines.push("## 概要"); + lines.push(t.report.headingSummary); lines.push(""); lines.push(`- ${formatSessionsLine(data)}`); lines.push( @@ -398,7 +411,7 @@ function formatReportBody(data: ReportData): string { lines.push(""); // Volume + time - lines.push("## 作業量"); + lines.push(t.report.headingVolume); lines.push(""); const tokenCaveat = data.volume.tokensAvailable ? "" : " (no token data captured)"; lines.push(`- Output tokens: ${formatInt(data.volume.outputTokens)}${tokenCaveat}`); @@ -422,7 +435,7 @@ function formatReportBody(data: ReportData): string { // Decisions — the most recent ones (the report explains what was decided; // ids live in --json, omitted here to keep the human narrative clean and // because batch-imported ids share a ULID timestamp prefix). - lines.push("## 判断"); + lines.push(t.report.headingDecisions); lines.push(""); if (data.decisions.items.length === 0) { lines.push("(no decisions recorded yet)"); @@ -445,7 +458,7 @@ function formatReportBody(data: ReportData): string { lines.push(""); // Approvals - lines.push("## 承認"); + lines.push(t.report.headingApprovals); lines.push(""); if (data.approvals.items.length === 0) { lines.push("(none)"); @@ -464,7 +477,7 @@ function formatReportBody(data: ReportData): string { lines.push(""); // Tasks - lines.push("## タスク"); + lines.push(t.report.headingTasks); lines.push(""); if (data.tasks.items.length === 0) { lines.push("(no tasks recorded yet)"); @@ -481,7 +494,7 @@ function formatReportBody(data: ReportData): string { lines.push(""); // Changed files - lines.push("## 変更ファイル"); + lines.push(t.report.headingChangedFiles); lines.push(""); if (data.changedFiles.length === 0) { lines.push("(no related files recorded)"); @@ -494,7 +507,7 @@ function formatReportBody(data: ReportData): string { // Sessions — newest first. The started_at is the human row key; full ids are // in --json (and would collide as short ids for batch imports). - lines.push("## セッション一覧"); + lines.push(t.report.headingSessions); lines.push(""); if (data.sessions.items.length === 0) { lines.push("(no sessions yet)"); @@ -515,7 +528,7 @@ function formatReportBody(data: ReportData): string { lines.push(""); // Integrity - lines.push("## 整合性"); + lines.push(t.report.headingIntegrity); lines.push(""); const i = data.integrity; lines.push( diff --git a/packages/sdk/src/workspace.test.ts b/packages/sdk/src/workspace.test.ts index 9a3ea37..5446697 100644 --- a/packages/sdk/src/workspace.test.ts +++ b/packages/sdk/src/workspace.test.ts @@ -247,7 +247,7 @@ describe("openWorkspace", () => { const body = await ws.renderReport({ title: "Client X", timeZone: "UTC" }); expect(typeof body).toBe("string"); expect(body).toContain("# Report — Client X"); - expect(body).toContain("## 整合性"); + expect(body).toContain("## Integrity"); }); it("injects a clock for time-sensitive reads", async () => { diff --git a/scripts/check-language.mjs b/scripts/check-language.mjs index e5526cc..08f611f 100644 --- a/scripts/check-language.mjs +++ b/scripts/check-language.mjs @@ -22,16 +22,13 @@ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); // allowed. Keep this list small — a new entry needs an explicit E-5 rationale, // not a convenience escape from translating output. const ALLOWLIST = [ - // E-5: orientation.md render output — a user-facing status surface (Japanese). - "packages/core/src/orientation/orientation-renderer.ts", - // E-5: handoff.md render output — a user-facing status surface (Japanese). - "packages/core/src/handoff/handoff-renderer.ts", - // E-5: decisions.md render output — a user-facing status surface (Japanese). - "packages/core/src/decisions/decisions-renderer.ts", - // E-5: status report render output — a user-facing status surface (Japanese). - "packages/core/src/report/report-renderer.ts", - // E-5: generated instruction-file preset — the content language is tied to the - // declared `language` of the target repo, a separate surface from CLI output. + // E-5: the single generated-view locale table (handoff/orientation/decisions/ + // report chrome). The `ja` strings live ONLY here; the renderers themselves + // are English-only and select a language via the manifest's anchor repo. + "packages/core/src/lib/view-strings.ts", + // E-5: generated instruction-file preset — a separate surface from CLI output. + // Its content is currently Japanese unconditionally; it does not yet follow the + // target repo's declared `language`. "packages/core/src/project/preset.ts", // E-5: generated anchor AGENTS.md starter — the content language matches the // sibling preset/view blocks it lives beside, a separate surface from CLI output.