diff --git a/src/cli/boulder/boulder.test.ts b/src/cli/boulder/boulder.test.ts new file mode 100644 index 000000000..c4fb9bc91 --- /dev/null +++ b/src/cli/boulder/boulder.test.ts @@ -0,0 +1,215 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { afterEach, describe, expect, it } from "bun:test" + +import { boulder } from "./boulder" + +function createTempDirectory(): string { + return mkdtempSync(join(tmpdir(), "omo-boulder-cli-")) +} + +function seedPlanAndState(directory: string): void { + const planDirectory = join(directory, ".sisyphus", "plans") + mkdirSync(planDirectory, { recursive: true }) + + const planAPath = join(planDirectory, "alpha.md") + const planBPath = join(planDirectory, "beta.md") + + writeFileSync( + planAPath, + [ + "## TODOs", + "- [x] 1. Alpha task done", + "- [ ] 2. Alpha task running", + ].join("\n"), + "utf-8", + ) + writeFileSync( + planBPath, + [ + "## TODOs", + "- [x] 1. Beta task done", + "- [x] 2. Beta task done too", + ].join("\n"), + "utf-8", + ) + + const boulderDirectory = join(directory, ".sisyphus") + mkdirSync(boulderDirectory, { recursive: true }) + + writeFileSync( + join(boulderDirectory, "boulder.json"), + JSON.stringify( + { + schema_version: 2, + active_work_id: "work-alpha", + active_plan: planAPath, + started_at: "2026-05-10T00:00:00.000Z", + ended_at: "2026-05-10T00:30:00.000Z", + elapsed_ms: 1_800_000, + status: "active", + updated_at: "2026-05-10T00:30:00.000Z", + session_ids: ["ses-1", "ses-2"], + plan_name: "alpha", + task_sessions: { + "todo:2": { + task_key: "todo:2", + task_label: "2", + task_title: "Alpha task running", + session_id: "ses-2", + elapsed_ms: 60000, + status: "running", + updated_at: "2026-05-10T00:30:00.000Z", + }, + }, + works: { + "work-alpha": { + work_id: "work-alpha", + active_plan: planAPath, + plan_name: "alpha", + status: "active", + started_at: "2026-05-10T00:00:00.000Z", + elapsed_ms: 1_800_000, + updated_at: "2026-05-10T00:30:00.000Z", + session_ids: ["ses-1", "ses-2"], + task_sessions: { + "todo:2": { + task_key: "todo:2", + task_label: "2", + task_title: "Alpha task running", + session_id: "ses-2", + elapsed_ms: 60000, + status: "running", + updated_at: "2026-05-10T00:30:00.000Z", + }, + }, + }, + "work-beta": { + work_id: "work-beta", + active_plan: planBPath, + plan_name: "beta", + status: "completed", + started_at: "2026-05-10T01:00:00.000Z", + ended_at: "2026-05-10T01:10:00.000Z", + elapsed_ms: 600000, + updated_at: "2026-05-10T01:10:00.000Z", + session_ids: ["ses-3"], + task_sessions: {}, + }, + }, + }, + null, + 2, + ), + "utf-8", + ) +} + +describe("boulder command", () => { + const createdDirectories: string[] = [] + const outputRestores: Array<() => void> = [] + + afterEach(() => { + for (const directory of createdDirectories) { + rmSync(directory, { recursive: true, force: true }) + } + createdDirectories.length = 0 + for (const restoreOutput of outputRestores) { + restoreOutput() + } + outputRestores.length = 0 + }) + + function captureOutput(target: "stdout" | "stderr", sink: { value: string }): void { + const originalWrite = process[target].write + process[target].write = ((chunk: string | Uint8Array) => { + sink.value += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf-8") + return true + }) as typeof process.stdout.write + + outputRestores.push(() => { + process[target].write = originalWrite + }) + } + + it("prints multi-work text mode with plan names and percentages", async () => { + const directory = createTempDirectory() + createdDirectories.push(directory) + seedPlanAndState(directory) + + const stdout = { value: "" } + const stderr = { value: "" } + captureOutput("stdout", stdout) + captureOutput("stderr", stderr) + + const exitCode = await boulder({ directory }) + + expect(exitCode).toBe(0) + expect(stderr.value).toBe("") + expect(stdout.value).toContain("plan: alpha") + expect(stdout.value).toContain("plan: beta") + expect(stdout.value).toContain("progress: 50% (1/2)") + expect(stdout.value).toContain("progress: 100% (2/2)") + expect(stdout.value).toContain("elapsed:") + }) + + it("prints json mode with expected fields", async () => { + const directory = createTempDirectory() + createdDirectories.push(directory) + seedPlanAndState(directory) + + const stdout = { value: "" } + captureOutput("stdout", stdout) + + const exitCode = await boulder({ directory, json: true }) + expect(exitCode).toBe(0) + + const parsed = JSON.parse(stdout.value) + expect(parsed.works).toHaveLength(2) + expect(parsed.works[0]).toHaveProperty("work_id") + expect(parsed.works[0]).toHaveProperty("percentage") + expect(parsed.works[0]).toHaveProperty("remaining_tasks") + }) + + it("returns 1 when boulder state does not exist", async () => { + const directory = createTempDirectory() + createdDirectories.push(directory) + + const stderr = { value: "" } + captureOutput("stderr", stderr) + + const exitCode = await boulder({ directory }) + expect(exitCode).toBe(1) + expect(stderr.value).toContain("No boulder state found") + }) + + it("returns 1 when workId filter matches none", async () => { + const directory = createTempDirectory() + createdDirectories.push(directory) + seedPlanAndState(directory) + + const stderr = { value: "" } + captureOutput("stderr", stderr) + + const exitCode = await boulder({ directory, workId: "missing" }) + expect(exitCode).toBe(1) + expect(stderr.value).toContain("No boulder state found") + }) + + it("returns one work when workId filter matches", async () => { + const directory = createTempDirectory() + createdDirectories.push(directory) + seedPlanAndState(directory) + + const stdout = { value: "" } + captureOutput("stdout", stdout) + + const exitCode = await boulder({ directory, workId: "work-beta", json: true }) + expect(exitCode).toBe(0) + + const parsed = JSON.parse(stdout.value) + expect(parsed.works).toHaveLength(1) + expect(parsed.works[0].work_id).toBe("work-beta") + }) +}) diff --git a/src/cli/boulder/boulder.ts b/src/cli/boulder/boulder.ts new file mode 100644 index 000000000..7e07bf0a6 --- /dev/null +++ b/src/cli/boulder/boulder.ts @@ -0,0 +1,136 @@ +import { existsSync } from "node:fs" + +import { + getBoulderFilePath, + getBoulderWorks, + getPlanProgress, + readBoulderState, + readCurrentTopLevelTask, + resolveBoulderPlanPathForWork, +} from "../../features/boulder-state" +import type { BoulderWorkState } from "../../features/boulder-state" +import { + formatJsonOutput, + formatNoBoulderMessage, + formatReadErrorMessage, + formatTextOutput, +} from "./formatter" +import type { BoulderCliResult, BoulderCliWork, BoulderOptions } from "./types" + +function formatDurationHuman(durationMs: number): string { + if (durationMs < 1000) { + return `${durationMs}ms` + } + + const totalSeconds = Math.floor(durationMs / 1000) + const seconds = totalSeconds % 60 + const totalMinutes = Math.floor(totalSeconds / 60) + const minutes = totalMinutes % 60 + const hours = Math.floor(totalMinutes / 60) + + if (hours > 0) { + return `${hours}h ${minutes}m ${seconds}s` + } + + if (minutes > 0) { + return `${minutes}m ${seconds}s` + } + + return `${seconds}s` +} + +function getElapsedMs(work: BoulderWorkState): number | undefined { + if (work.elapsed_ms !== undefined) { + return work.elapsed_ms + } + + const startedAtMs = Date.parse(work.started_at) + if (Number.isNaN(startedAtMs)) { + return undefined + } + + const endedAtMs = work.ended_at ? Date.parse(work.ended_at) : Date.now() + if (Number.isNaN(endedAtMs)) { + return undefined + } + + return Math.max(0, endedAtMs - startedAtMs) +} + +function buildCliWork(directory: string, work: BoulderWorkState): BoulderCliWork { + const planPath = resolveBoulderPlanPathForWork(directory, work) + const progress = getPlanProgress(planPath) + const elapsedMs = getElapsedMs(work) + const currentTask = readCurrentTopLevelTask(planPath) + const taskSession = currentTask ? work.task_sessions?.[currentTask.key] : undefined + + let currentTaskElapsedHuman: string | undefined + if (taskSession?.elapsed_ms !== undefined) { + currentTaskElapsedHuman = formatDurationHuman(taskSession.elapsed_ms) + } else if (taskSession?.started_at) { + const startedAtMs = Date.parse(taskSession.started_at) + if (!Number.isNaN(startedAtMs)) { + currentTaskElapsedHuman = formatDurationHuman(Math.max(0, Date.now() - startedAtMs)) + } + } + + return { + work_id: work.work_id, + plan_name: work.plan_name, + active_plan: work.active_plan, + worktree_path: work.worktree_path, + status: work.status ?? "active", + started_at: work.started_at, + ended_at: work.ended_at, + elapsed_ms: elapsedMs, + elapsed_human: elapsedMs !== undefined ? formatDurationHuman(elapsedMs) : undefined, + total_tasks: progress.total, + completed_tasks: progress.completed, + remaining_tasks: Math.max(0, progress.total - progress.completed), + percentage: progress.total > 0 + ? Math.round((progress.completed / progress.total) * 100) + : 0, + session_count: work.session_ids.length, + current_task: currentTask + ? { + task_key: currentTask.key, + task_title: currentTask.title, + elapsed_human: currentTaskElapsedHuman, + } + : undefined, + } +} + +export async function boulder(options: BoulderOptions): Promise { + const directory = options.directory ?? process.cwd() + const boulderFilePath = getBoulderFilePath(directory) + const state = readBoulderState(directory) + if (!state) { + const message = existsSync(boulderFilePath) + ? formatReadErrorMessage(options.json) + : formatNoBoulderMessage(options.json) + + process.stderr.write(`${message}\n`) + return existsSync(boulderFilePath) ? 2 : 1 + } + + const works = getBoulderWorks(state) + const filteredWorks = options.workId + ? works.filter((work) => work.work_id === options.workId) + : works + + if (filteredWorks.length === 0) { + process.stderr.write(`${formatNoBoulderMessage(options.json)}\n`) + return 1 + } + + const cliWorks = filteredWorks.map((work) => buildCliWork(directory, work)) + const result: BoulderCliResult = { works: cliWorks } + + const output = options.json + ? formatJsonOutput(result) + : formatTextOutput(result) + + process.stdout.write(`${output}\n`) + return 0 +} diff --git a/src/cli/boulder/index.ts b/src/cli/boulder/index.ts new file mode 100644 index 000000000..1f69b2f40 --- /dev/null +++ b/src/cli/boulder/index.ts @@ -0,0 +1 @@ +export { boulder } from "./boulder" diff --git a/src/cli/cli-program.ts b/src/cli/cli-program.ts index 49256d2da..ff1b63345 100644 --- a/src/cli/cli-program.ts +++ b/src/cli/cli-program.ts @@ -5,6 +5,7 @@ import { getLocalVersion } from "./get-local-version" import { doctor } from "./doctor" import { refreshModelCapabilities } from "./refresh-model-capabilities" import { createMcpOAuthCommand } from "./mcp-oauth" +import { boulder } from "./boulder" import type { InstallArgs } from "./types" import type { RunOptions } from "./run" import type { GetLocalVersionOptions } from "./get-local-version/types" @@ -202,6 +203,21 @@ program console.log(`oh-my-opencode v${VERSION}`) }) +program + .command("boulder") + .description("Show boulder progress, elapsed time, and per-task statistics") + .option("-d, --directory ", "Working directory") + .option("-w, --work-id ", "Filter to a specific work") + .option("--json", "Output as JSON") + .action(async (options) => { + const exitCode = await boulder({ + directory: options.directory, + workId: options.workId, + json: options.json ?? false, + }) + process.exit(exitCode) + }) + program.addCommand(createMcpOAuthCommand()) export function runCli(): void {