diff --git a/CHANGELOG.md b/CHANGELOG.md index e3071f7f..05bac6f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,26 @@ All notable changes to **basou** are recorded here. The project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) starting with v0.1.0. +## Unreleased + +### Added + +- `basou orient --refresh` imports every adapter first (writing provenance), then + renders a guaranteed-fresh position — a one-command way for a SessionStart hook + to close the freshness gate. Bare `basou orient` stays **read-only** (it never + imports; the dry-run probe only reports staleness), preserving the invariant + that a plain orient never mutates the store. + +### Changed + +- When `basou orient` has **confirmed** staleness (the probe counted uncaptured / + grown native sessions), the warning is now asserted, not hedged: 「古いです(未取り込み N 件)— + 着手前に必ず `basou refresh`」instead of 「古いかもしれません」. basou knows there is + uncaptured work, so it states it plainly with an imperative remediation rather + than understating the urgency. The genuinely-uncertain cases keep their hedged + wording (an un-re-importable grown session: 「最新か確認できません」; an unrun probe: + 「最新か確認するには…」). + ## 0.22.0 — 2026-06-24 ### Added diff --git a/packages/cli/src/commands/orient.test.ts b/packages/cli/src/commands/orient.test.ts index aaba6294..85543f12 100644 --- a/packages/cli/src/commands/orient.test.ts +++ b/packages/cli/src/commands/orient.test.ts @@ -258,10 +258,23 @@ describe("basou orient", () => { const out = captureStdout(); await doRunOrient({}, ctxFor(repo)); const stdout = joinCalls(out); - expect(stdout).toContain("⚠️ 古いかもしれません。"); + expect(stdout).toContain("⚠️ 古いです。"); expect(stdout).toContain("`basou refresh`"); }); + it("--refresh imports first, so the same uncaptured session renders ✅ current (not stale)", async () => { + const repo = await setupInitedRepo(); + // The exact stale scenario above, but --refresh pulls it in before rendering. + await writeCodexRolloutAt(repo, "codex-uncaptured"); + const out = captureStdout(); + await doRunOrient({ refresh: true }, ctxFor(repo)); + const stdout = joinCalls(out); + expect(stdout).toContain("✅ 取り込みは最新です。"); + expect(stdout).not.toContain("⚠️ 古いです。"); + // The session was actually imported (the arc/position reflects 1 session). + expect(stdout).toContain("codex"); + }); + it("--verbose appends raw freshness telemetry; the default view omits it", async () => { const repo = await setupInitedRepo(); await placeSession(repo, { id: SES("S01"), source: "claude-code-import" }); diff --git a/packages/cli/src/commands/orient.ts b/packages/cli/src/commands/orient.ts index f42f83cf..687e5c75 100644 --- a/packages/cli/src/commands/orient.ts +++ b/packages/cli/src/commands/orient.ts @@ -15,11 +15,11 @@ import { renderCliError, } from "../lib/error-render.js"; import { loadHostsConfig } from "../lib/hosts-config.js"; -import { probeStaleness } from "../lib/provenance-actions.js"; +import { probeStaleness, refreshAll } from "../lib/provenance-actions.js"; import { resolveBasouRootForCommand } from "../lib/repo-root.js"; import type { ImportContext } from "./import.js"; -export type OrientOptions = { verbose?: boolean; quiet?: boolean }; +export type OrientOptions = { verbose?: boolean; quiet?: boolean; refresh?: boolean }; export type OrientContext = ImportContext & { /** Defaults to `() => new Date()`. Injectable for tests. */ @@ -31,16 +31,22 @@ export type OrientContext = ImportContext & { /** * Wire `basou orient` onto `program`. A read-first "where am I" command: it * renders the current position, writes `.basou/orientation.md`, and prints the - * body to stdout by default. It writes NO provenance — a read-only dry-run probe - * checks for uncaptured native work so the "これは最新か" verdict is honest (use - * `basou refresh` to actually re-import). `--verbose` appends raw freshness - * telemetry under the verdict. + * body to stdout by default. By default it writes NO provenance — a read-only + * dry-run probe checks for uncaptured native work so the "これは最新か" verdict is + * honest (use `basou refresh` to actually re-import). `--refresh` opts into + * importing first (so a SessionStart hook can guarantee a fresh position in one + * command) while bare `orient` stays read-only. `--verbose` appends raw + * freshness telemetry under the verdict. */ export function registerOrientCommand(program: Command): void { program .command("orient") .description("Show the workspace's current position (also writes .basou/orientation.md)") .option("-q, --quiet", "Write the file without printing the body") + .option( + "--refresh", + "Import all adapters first (writes provenance), then show a guaranteed-fresh position; bare orient is read-only", + ) .option("-v, --verbose", "Show error causes") .action(async (opts: OrientOptions) => { await runOrient(opts); @@ -77,6 +83,14 @@ export async function doRunOrient(options: OrientOptions, ctx: OrientContext): P const probeCtx: ImportContext = { cwd: repositoryRoot }; if (ctx.claudeProjectsDir !== undefined) probeCtx.claudeProjectsDir = ctx.claudeProjectsDir; if (ctx.codexSessionsDir !== undefined) probeCtx.codexSessionsDir = ctx.codexSessionsDir; + + // `--refresh` (opt-in) imports every adapter first so the position is + // guaranteed current — the moat-safe way for a SessionStart hook to close the + // freshness gate without bare `orient` ever becoming a writer. The subsequent + // dry-run probe then reports fresh, so the verdict reads "current" honestly. + if (options.refresh === true) { + await refreshAll({ options: {}, ctx: probeCtx, paths, nowIso }); + } const staleness = await probeStaleness({ ctx: probeCtx, paths, nowIso }); // Federation (zero-network): merge other hosts' trails listed in diff --git a/packages/core/src/orientation/orientation-renderer.test.ts b/packages/core/src/orientation/orientation-renderer.test.ts index 84b2f743..47e13ac2 100644 --- a/packages/core/src/orientation/orientation-renderer.test.ts +++ b/packages/core/src/orientation/orientation-renderer.test.ts @@ -368,10 +368,10 @@ describe("orientation-renderer", () => { nowIso: FIXED_NOW_ISO, staleness: { newSessions: 2, updatedSessions: 1 }, }); - expect(result.body).toContain("⚠️ 古いかもしれません。"); + expect(result.body).toContain("⚠️ 古いです。"); expect(result.body).toContain("新規 2 件"); expect(result.body).toContain("更新 1 件"); - expect(result.body).toContain("`basou refresh`"); + expect(result.body).toContain("着手前に必ず `basou refresh`"); }); it("これは最新か: an unrun probe (null) says it cannot confirm rather than claiming current", async () => { @@ -445,7 +445,9 @@ describe("orientation-renderer", () => { // The banner is uniquely identified by its trailing pointer to the verdict. const bannerMarker = "(詳細は末尾「これは最新か」)"; expect(result.body).toContain(bannerMarker); - expect(result.body).toContain("> ⚠️ **古いかもしれません**"); + // Confirmed stale: asserted, not hedged, with the uncaptured counts inline. + expect(result.body).toContain("> ⚠️ **古いです(未取り込み 新規 2 件・更新 1 件)**"); + expect(result.body).toContain("着手前に必ず `basou refresh`"); // 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("## 今どこにいる")); diff --git a/packages/core/src/orientation/orientation-renderer.ts b/packages/core/src/orientation/orientation-renderer.ts index bfbf5359..450f3a1f 100644 --- a/packages/core/src/orientation/orientation-renderer.ts +++ b/packages/core/src/orientation/orientation-renderer.ts @@ -1031,8 +1031,11 @@ function stalenessBanner( const parts: string[] = []; if (staleness.newSessions > 0) parts.push(`新規 ${staleness.newSessions} 件`); if (staleness.updatedSessions > 0) parts.push(`更新 ${staleness.updatedSessions} 件`); + // Confirmed stale (the probe counted uncaptured work), so assert it rather + // than hedge with "かもしれません" — basou KNOWS there is work to pull in, and + // an imperative remediation is the high-salience signal the consumer needs. return [ - `> ⚠️ **古いかもしれません** — 未取り込みの作業があります(${parts.join("・")})。着手前に \`basou refresh\` で更新してください(詳細は末尾「これは最新か」)。`, + `> ⚠️ **古いです(未取り込み ${parts.join("・")})** — 着手前に必ず \`basou refresh\` を実行してください(詳細は末尾「これは最新か」)。`, ]; } return []; @@ -1069,9 +1072,13 @@ function freshnessVerdict( const parts: string[] = []; if (staleness.newSessions > 0) parts.push(`新規 ${staleness.newSessions} 件`); if (staleness.updatedSessions > 0) parts.push(`更新 ${staleness.updatedSessions} 件`); + // Confirmed stale: the probe found uncaptured/grown native work, so state it + // plainly (not "かもしれません") with an imperative remediation. The hedged + // wording is reserved for the genuinely-uncertain branches (unverifiable, or + // an unrun probe) below/above. return [ - `⚠️ 古いかもしれません。最後の取り込み以降に未取り込みの作業があります(${parts.join("・")})。`, - "`basou refresh` で更新してください。", + `⚠️ 古いです。最後の取り込み以降に未取り込みの作業があります(${parts.join("・")})。`, + "着手前に必ず `basou refresh` を実行してください。", ]; }