diff --git a/src/features/boulder-state/constants.ts b/src/features/boulder-state/constants.ts new file mode 100644 index 00000000..b0de70db --- /dev/null +++ b/src/features/boulder-state/constants.ts @@ -0,0 +1,13 @@ +/** + * Boulder State Constants + */ + +export const BOULDER_DIR = ".sisyphus" +export const BOULDER_FILE = "boulder.json" +export const BOULDER_STATE_PATH = `${BOULDER_DIR}/${BOULDER_FILE}` + +export const NOTEPAD_DIR = "notepads" +export const NOTEPAD_BASE_PATH = `${BOULDER_DIR}/${NOTEPAD_DIR}` + +/** Prometheus plan directory pattern */ +export const PROMETHEUS_PLANS_DIR = ".sisyphus/plans" diff --git a/src/features/boulder-state/index.ts b/src/features/boulder-state/index.ts new file mode 100644 index 00000000..f404e4e0 --- /dev/null +++ b/src/features/boulder-state/index.ts @@ -0,0 +1,3 @@ +export * from "./types" +export * from "./constants" +export * from "./storage" diff --git a/src/features/boulder-state/storage.test.ts b/src/features/boulder-state/storage.test.ts new file mode 100644 index 00000000..b8c17f18 --- /dev/null +++ b/src/features/boulder-state/storage.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test" +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { + readBoulderState, + writeBoulderState, + appendSessionId, + clearBoulderState, + getPlanProgress, + getPlanName, + createBoulderState, + findPrometheusPlans, +} from "./storage" +import type { BoulderState } from "./types" + +describe("boulder-state", () => { + const TEST_DIR = join(tmpdir(), "boulder-state-test-" + Date.now()) + const SISYPHUS_DIR = join(TEST_DIR, ".sisyphus") + + beforeEach(() => { + if (!existsSync(TEST_DIR)) { + mkdirSync(TEST_DIR, { recursive: true }) + } + if (!existsSync(SISYPHUS_DIR)) { + mkdirSync(SISYPHUS_DIR, { recursive: true }) + } + clearBoulderState(TEST_DIR) + }) + + afterEach(() => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }) + } + }) + + describe("readBoulderState", () => { + test("should return null when no boulder.json exists", () => { + // #given - no boulder.json file + // #when + const result = readBoulderState(TEST_DIR) + // #then + expect(result).toBeNull() + }) + + test("should read valid boulder state", () => { + // #given - valid boulder.json + const state: BoulderState = { + active_plan: "/path/to/plan.md", + started_at: "2026-01-02T10:00:00Z", + session_ids: ["session-1", "session-2"], + plan_name: "my-plan", + } + writeBoulderState(TEST_DIR, state) + + // #when + const result = readBoulderState(TEST_DIR) + + // #then + expect(result).not.toBeNull() + expect(result?.active_plan).toBe("/path/to/plan.md") + expect(result?.session_ids).toEqual(["session-1", "session-2"]) + expect(result?.plan_name).toBe("my-plan") + }) + }) + + describe("writeBoulderState", () => { + test("should write state and create .sisyphus directory if needed", () => { + // #given - state to write + const state: BoulderState = { + active_plan: "/test/plan.md", + started_at: "2026-01-02T12:00:00Z", + session_ids: ["ses-123"], + plan_name: "test-plan", + } + + // #when + const success = writeBoulderState(TEST_DIR, state) + const readBack = readBoulderState(TEST_DIR) + + // #then + expect(success).toBe(true) + expect(readBack).not.toBeNull() + expect(readBack?.active_plan).toBe("/test/plan.md") + }) + }) + + describe("appendSessionId", () => { + test("should append new session id to existing state", () => { + // #given - existing state with one session + const state: BoulderState = { + active_plan: "/plan.md", + started_at: "2026-01-02T10:00:00Z", + session_ids: ["session-1"], + plan_name: "plan", + } + writeBoulderState(TEST_DIR, state) + + // #when + const result = appendSessionId(TEST_DIR, "session-2") + + // #then + expect(result).not.toBeNull() + expect(result?.session_ids).toEqual(["session-1", "session-2"]) + }) + + test("should not duplicate existing session id", () => { + // #given - state with session-1 already + const state: BoulderState = { + active_plan: "/plan.md", + started_at: "2026-01-02T10:00:00Z", + session_ids: ["session-1"], + plan_name: "plan", + } + writeBoulderState(TEST_DIR, state) + + // #when + appendSessionId(TEST_DIR, "session-1") + const result = readBoulderState(TEST_DIR) + + // #then + expect(result?.session_ids).toEqual(["session-1"]) + }) + + test("should return null when no state exists", () => { + // #given - no boulder.json + // #when + const result = appendSessionId(TEST_DIR, "new-session") + // #then + expect(result).toBeNull() + }) + }) + + describe("clearBoulderState", () => { + test("should remove boulder.json", () => { + // #given - existing state + const state: BoulderState = { + active_plan: "/plan.md", + started_at: "2026-01-02T10:00:00Z", + session_ids: ["session-1"], + plan_name: "plan", + } + writeBoulderState(TEST_DIR, state) + + // #when + const success = clearBoulderState(TEST_DIR) + const result = readBoulderState(TEST_DIR) + + // #then + expect(success).toBe(true) + expect(result).toBeNull() + }) + + test("should succeed even when no file exists", () => { + // #given - no boulder.json + // #when + const success = clearBoulderState(TEST_DIR) + // #then + expect(success).toBe(true) + }) + }) + + describe("getPlanProgress", () => { + test("should count completed and uncompleted checkboxes", () => { + // #given - plan file with checkboxes + const planPath = join(TEST_DIR, "test-plan.md") + writeFileSync(planPath, `# Plan +- [ ] Task 1 +- [x] Task 2 +- [ ] Task 3 +- [X] Task 4 +`) + + // #when + const progress = getPlanProgress(planPath) + + // #then + expect(progress.total).toBe(4) + expect(progress.completed).toBe(2) + expect(progress.isComplete).toBe(false) + }) + + test("should return isComplete true when all checked", () => { + // #given - all tasks completed + const planPath = join(TEST_DIR, "complete-plan.md") + writeFileSync(planPath, `# Plan +- [x] Task 1 +- [X] Task 2 +`) + + // #when + const progress = getPlanProgress(planPath) + + // #then + expect(progress.total).toBe(2) + expect(progress.completed).toBe(2) + expect(progress.isComplete).toBe(true) + }) + + test("should return isComplete true for empty plan", () => { + // #given - plan with no checkboxes + const planPath = join(TEST_DIR, "empty-plan.md") + writeFileSync(planPath, "# Plan\nNo tasks here") + + // #when + const progress = getPlanProgress(planPath) + + // #then + expect(progress.total).toBe(0) + expect(progress.isComplete).toBe(true) + }) + + test("should handle non-existent file", () => { + // #given - non-existent file + // #when + const progress = getPlanProgress("/non/existent/file.md") + // #then + expect(progress.total).toBe(0) + expect(progress.isComplete).toBe(true) + }) + }) + + describe("getPlanName", () => { + test("should extract plan name from path", () => { + // #given + const path = "/home/user/.sisyphus/plans/project/my-feature.md" + // #when + const name = getPlanName(path) + // #then + expect(name).toBe("my-feature") + }) + }) + + describe("createBoulderState", () => { + test("should create state with correct fields", () => { + // #given + const planPath = "/path/to/auth-refactor.md" + const sessionId = "ses-abc123" + + // #when + const state = createBoulderState(planPath, sessionId) + + // #then + expect(state.active_plan).toBe(planPath) + expect(state.session_ids).toEqual([sessionId]) + expect(state.plan_name).toBe("auth-refactor") + expect(state.started_at).toBeDefined() + }) + }) +}) diff --git a/src/features/boulder-state/storage.ts b/src/features/boulder-state/storage.ts new file mode 100644 index 00000000..99aed010 --- /dev/null +++ b/src/features/boulder-state/storage.ts @@ -0,0 +1,150 @@ +/** + * Boulder State Storage + * + * Handles reading/writing boulder.json for active plan tracking. + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs" +import { dirname, join, basename } from "node:path" +import type { BoulderState, PlanProgress } from "./types" +import { BOULDER_DIR, BOULDER_FILE, PROMETHEUS_PLANS_DIR } from "./constants" + +export function getBoulderFilePath(directory: string): string { + return join(directory, BOULDER_DIR, BOULDER_FILE) +} + +export function readBoulderState(directory: string): BoulderState | null { + const filePath = getBoulderFilePath(directory) + + if (!existsSync(filePath)) { + return null + } + + try { + const content = readFileSync(filePath, "utf-8") + return JSON.parse(content) as BoulderState + } catch { + return null + } +} + +export function writeBoulderState(directory: string, state: BoulderState): boolean { + const filePath = getBoulderFilePath(directory) + + try { + const dir = dirname(filePath) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + + writeFileSync(filePath, JSON.stringify(state, null, 2), "utf-8") + return true + } catch { + return false + } +} + +export function appendSessionId(directory: string, sessionId: string): BoulderState | null { + const state = readBoulderState(directory) + if (!state) return null + + if (!state.session_ids.includes(sessionId)) { + state.session_ids.push(sessionId) + if (writeBoulderState(directory, state)) { + return state + } + } + + return state +} + +export function clearBoulderState(directory: string): boolean { + const filePath = getBoulderFilePath(directory) + + try { + if (existsSync(filePath)) { + const { unlinkSync } = require("node:fs") + unlinkSync(filePath) + } + return true + } catch { + return false + } +} + +/** + * Find Prometheus plan files for this project. + * Prometheus stores plans at: {project}/.sisyphus/plans/{name}.md + */ +export function findPrometheusPlans(directory: string): string[] { + const plansDir = join(directory, PROMETHEUS_PLANS_DIR) + + if (!existsSync(plansDir)) { + return [] + } + + try { + const files = readdirSync(plansDir) + return files + .filter((f) => f.endsWith(".md")) + .map((f) => join(plansDir, f)) + .sort((a, b) => { + // Sort by modification time, newest first + const aStat = require("node:fs").statSync(a) + const bStat = require("node:fs").statSync(b) + return bStat.mtimeMs - aStat.mtimeMs + }) + } catch { + return [] + } +} + +/** + * Parse a plan file and count checkbox progress. + */ +export function getPlanProgress(planPath: string): PlanProgress { + if (!existsSync(planPath)) { + return { total: 0, completed: 0, isComplete: true } + } + + try { + const content = readFileSync(planPath, "utf-8") + + // Match markdown checkboxes: - [ ] or - [x] or - [X] + const uncheckedMatches = content.match(/^[-*]\s*\[\s*\]/gm) || [] + const checkedMatches = content.match(/^[-*]\s*\[[xX]\]/gm) || [] + + const total = uncheckedMatches.length + checkedMatches.length + const completed = checkedMatches.length + + return { + total, + completed, + isComplete: total === 0 || completed === total, + } + } catch { + return { total: 0, completed: 0, isComplete: true } + } +} + +/** + * Extract plan name from file path. + */ +export function getPlanName(planPath: string): string { + return basename(planPath, ".md") +} + +/** + * Create a new boulder state for a plan. + */ +export function createBoulderState( + planPath: string, + sessionId: string +): BoulderState { + return { + active_plan: planPath, + started_at: new Date().toISOString(), + session_ids: [sessionId], + plan_name: getPlanName(planPath), + } +} diff --git a/src/features/boulder-state/types.ts b/src/features/boulder-state/types.ts new file mode 100644 index 00000000..b231e165 --- /dev/null +++ b/src/features/boulder-state/types.ts @@ -0,0 +1,26 @@ +/** + * Boulder State Types + * + * Manages the active work plan state for Sisyphus orchestrator. + * Named after Sisyphus's boulder - the eternal task that must be rolled. + */ + +export interface BoulderState { + /** Absolute path to the active plan file */ + active_plan: string + /** ISO timestamp when work started */ + started_at: string + /** Session IDs that have worked on this plan */ + session_ids: string[] + /** Plan name derived from filename */ + plan_name: string +} + +export interface PlanProgress { + /** Total number of checkboxes */ + total: number + /** Number of completed checkboxes */ + completed: number + /** Whether all tasks are done */ + isComplete: boolean +}