feat(features): add boulder-state persistence
Add boulder-state feature for persisting workflow state: - storage.ts: File I/O operations for state persistence - types.ts: State interfaces - Includes test coverage 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
parent
c1fa8d5212
commit
f9325c2d89
13
src/features/boulder-state/constants.ts
Normal file
13
src/features/boulder-state/constants.ts
Normal file
@ -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"
|
||||||
3
src/features/boulder-state/index.ts
Normal file
3
src/features/boulder-state/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./types"
|
||||||
|
export * from "./constants"
|
||||||
|
export * from "./storage"
|
||||||
250
src/features/boulder-state/storage.test.ts
Normal file
250
src/features/boulder-state/storage.test.ts
Normal file
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
150
src/features/boulder-state/storage.ts
Normal file
150
src/features/boulder-state/storage.ts
Normal file
@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/features/boulder-state/types.ts
Normal file
26
src/features/boulder-state/types.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user