refactor(packages): extract boulder-state package
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@oh-my-opencode/boulder-state",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "Pure TypeScript boulder work-tracking state machine for oh-my-opencode.",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./index.d.ts",
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"types": "./index.d.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit -p tsconfig.json",
|
||||
"test": "bun test src/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@oh-my-opencode/utils": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export const BOULDER_DIR = ".omo"
|
||||
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}`
|
||||
|
||||
export const PROMETHEUS_PLANS_DIR = ".omo/plans"
|
||||
@@ -0,0 +1,42 @@
|
||||
export { BOULDER_DIR, BOULDER_FILE, BOULDER_STATE_PATH, NOTEPAD_BASE_PATH, NOTEPAD_DIR, PROMETHEUS_PLANS_DIR } from "./constants"
|
||||
export { readCurrentTopLevelTask } from "./top-level-task"
|
||||
export {
|
||||
addBoulderWork,
|
||||
appendSessionId,
|
||||
appendSessionIdForWork,
|
||||
clearBoulderState,
|
||||
completeBoulder,
|
||||
createBoulderState,
|
||||
endTaskTimer,
|
||||
findPrometheusPlans,
|
||||
generateWorkId,
|
||||
getActiveWorks,
|
||||
getBoulderFilePath,
|
||||
getBoulderWorks,
|
||||
getPlanName,
|
||||
getPlanProgress,
|
||||
getTaskSessionState,
|
||||
getWorkById,
|
||||
getWorkByPlanName,
|
||||
getWorkForSession,
|
||||
getWorkResumeOptions,
|
||||
readBoulderState,
|
||||
resolveBoulderPlanPath,
|
||||
resolveBoulderPlanPathForWork,
|
||||
selectActiveWork,
|
||||
startTaskTimer,
|
||||
upsertTaskSessionState,
|
||||
upsertTaskSessionStateForWork,
|
||||
writeBoulderState,
|
||||
} from "./storage"
|
||||
export type {
|
||||
BoulderSessionOrigin,
|
||||
BoulderState,
|
||||
BoulderTaskStatus,
|
||||
BoulderWorkResumeOption,
|
||||
BoulderWorkState,
|
||||
BoulderWorkStatus,
|
||||
PlanProgress,
|
||||
TaskSessionState,
|
||||
TopLevelTaskRef,
|
||||
} from "./types"
|
||||
@@ -0,0 +1,15 @@
|
||||
export { getBoulderFilePath, resolveBoulderPlanPath, resolveBoulderPlanPathForWork } from "./path"
|
||||
export { findPrometheusPlans, getPlanName, getPlanProgress } from "./plan-progress"
|
||||
export {
|
||||
getActiveWorks,
|
||||
getBoulderWorks,
|
||||
getTaskSessionState,
|
||||
getWorkById,
|
||||
getWorkByPlanName,
|
||||
getWorkForSession,
|
||||
getWorkResumeOptions,
|
||||
readBoulderState,
|
||||
} from "./read-state"
|
||||
export { appendSessionId, appendSessionIdForWork } from "./session"
|
||||
export { endTaskTimer, startTaskTimer, upsertTaskSessionState, upsertTaskSessionStateForWork } from "./task"
|
||||
export { addBoulderWork, clearBoulderState, completeBoulder, createBoulderState, generateWorkId, selectActiveWork, writeBoulderState } from "./write-state"
|
||||
@@ -0,0 +1,41 @@
|
||||
import { existsSync } from "node:fs"
|
||||
import { isAbsolute, join, relative, resolve } from "node:path"
|
||||
|
||||
import { BOULDER_DIR, BOULDER_FILE } from "../constants"
|
||||
import type { BoulderState, BoulderWorkState } from "../types"
|
||||
|
||||
export function getBoulderFilePath(directory: string): string {
|
||||
return join(directory, BOULDER_DIR, BOULDER_FILE)
|
||||
}
|
||||
|
||||
function resolveTrackedPath(baseDirectory: string, trackedPath: string): string {
|
||||
return isAbsolute(trackedPath) ? resolve(trackedPath) : resolve(baseDirectory, trackedPath)
|
||||
}
|
||||
|
||||
export function resolveBoulderPlanPath(
|
||||
directory: string,
|
||||
state: Pick<BoulderState, "active_plan" | "worktree_path">,
|
||||
): string {
|
||||
const absolutePlanPath = resolveTrackedPath(directory, state.active_plan)
|
||||
const worktreePath = state.worktree_path?.trim()
|
||||
if (!worktreePath) {
|
||||
return absolutePlanPath
|
||||
}
|
||||
|
||||
const absoluteDirectory = resolve(directory)
|
||||
const relativePlanPath = relative(absoluteDirectory, absolutePlanPath)
|
||||
if (relativePlanPath.length === 0 || relativePlanPath.startsWith("..") || isAbsolute(relativePlanPath)) {
|
||||
return absolutePlanPath
|
||||
}
|
||||
|
||||
const absoluteWorktreePath = resolveTrackedPath(directory, worktreePath)
|
||||
const worktreePlanPath = resolve(absoluteWorktreePath, relativePlanPath)
|
||||
return existsSync(worktreePlanPath) ? worktreePlanPath : absolutePlanPath
|
||||
}
|
||||
|
||||
export function resolveBoulderPlanPathForWork(
|
||||
directory: string,
|
||||
work: Pick<BoulderWorkState, "active_plan" | "worktree_path">,
|
||||
): string {
|
||||
return resolveBoulderPlanPath(directory, work)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"
|
||||
import { basename, join } from "node:path"
|
||||
|
||||
import { PROMETHEUS_PLANS_DIR } from "../constants"
|
||||
import type { PlanProgress } from "../types"
|
||||
|
||||
const TODO_HEADING_PATTERN = /^##\s+TODOs\b/i
|
||||
const FINAL_VERIFICATION_HEADING_PATTERN = /^##\s+Final Verification Wave\b/i
|
||||
const SECOND_LEVEL_HEADING_PATTERN = /^##\s+/
|
||||
const UNCHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[\s*\]\s*(.+)$/
|
||||
const CHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[[xX]\]\s*(.+)$/
|
||||
const TODO_TASK_PATTERN = /^\d+\.\s+/
|
||||
const FINAL_WAVE_TASK_PATTERN = /^F\d+\.\s+/i
|
||||
|
||||
type ProgressSection = "todo" | "final-wave" | "other"
|
||||
|
||||
export function findPrometheusPlans(directory: string): string[] {
|
||||
const plansDir = join(directory, PROMETHEUS_PLANS_DIR)
|
||||
if (!existsSync(plansDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
return readdirSync(plansDir)
|
||||
.filter((file) => file.endsWith(".md"))
|
||||
.map((file) => join(plansDir, file))
|
||||
.sort((left, right) => statSync(right).mtimeMs - statSync(left).mtimeMs)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function getPlanName(planPath: string): string {
|
||||
return basename(planPath, ".md")
|
||||
}
|
||||
|
||||
export function getPlanProgress(planPath: string): PlanProgress {
|
||||
if (!existsSync(planPath)) {
|
||||
return { total: 0, completed: 0, isComplete: false }
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(planPath, "utf-8")
|
||||
const lines = content.split(/\r?\n/)
|
||||
const hasStructuredSections = lines.some(
|
||||
(line) => TODO_HEADING_PATTERN.test(line) || FINAL_VERIFICATION_HEADING_PATTERN.test(line),
|
||||
)
|
||||
if (hasStructuredSections) {
|
||||
return getStructuredPlanProgress(lines)
|
||||
}
|
||||
|
||||
return getSimplePlanProgress(content)
|
||||
} catch {
|
||||
return { total: 0, completed: 0, isComplete: false }
|
||||
}
|
||||
}
|
||||
|
||||
function getStructuredPlanProgress(lines: string[]): PlanProgress {
|
||||
let section: ProgressSection = "other"
|
||||
let total = 0
|
||||
let completed = 0
|
||||
|
||||
for (const line of lines) {
|
||||
if (SECOND_LEVEL_HEADING_PATTERN.test(line)) {
|
||||
section = TODO_HEADING_PATTERN.test(line)
|
||||
? "todo"
|
||||
: FINAL_VERIFICATION_HEADING_PATTERN.test(line)
|
||||
? "final-wave"
|
||||
: "other"
|
||||
continue
|
||||
}
|
||||
|
||||
if (section !== "todo" && section !== "final-wave") {
|
||||
continue
|
||||
}
|
||||
|
||||
const checkedMatch = line.match(CHECKED_CHECKBOX_PATTERN)
|
||||
const uncheckedMatch = checkedMatch ? null : line.match(UNCHECKED_CHECKBOX_PATTERN)
|
||||
const match = checkedMatch ?? uncheckedMatch
|
||||
if (!match || match[1].length > 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const taskBody = match[2].trim()
|
||||
const labelPattern = section === "todo" ? TODO_TASK_PATTERN : FINAL_WAVE_TASK_PATTERN
|
||||
if (!labelPattern.test(taskBody)) {
|
||||
continue
|
||||
}
|
||||
|
||||
total += 1
|
||||
if (checkedMatch) {
|
||||
completed += 1
|
||||
}
|
||||
}
|
||||
|
||||
return { total, completed, isComplete: total > 0 && completed === total }
|
||||
}
|
||||
|
||||
function getSimplePlanProgress(content: string): PlanProgress {
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
|
||||
import type { BoulderState, BoulderWorkResumeOption, BoulderWorkState, TaskSessionState } from "../types"
|
||||
import { getBoulderFilePath, resolveBoulderPlanPathForWork } from "./path"
|
||||
import { getPlanProgress } from "./plan-progress"
|
||||
import { buildWorkFromMirror, isValidWorkStatus, parseIsoToMs, projectWorkToMirror, selectMirrorWork } from "./shared"
|
||||
|
||||
export function readBoulderState(directory: string): BoulderState | null {
|
||||
const filePath = getBoulderFilePath(directory)
|
||||
if (!existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null
|
||||
}
|
||||
|
||||
normalizeState(parsed)
|
||||
const state = parsed as BoulderState
|
||||
const mirrorWork = selectMirrorWork(state)
|
||||
if (mirrorWork) {
|
||||
state.active_work_id = mirrorWork.work_id
|
||||
projectWorkToMirror(state, mirrorWork)
|
||||
}
|
||||
|
||||
return state
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeState(state: Record<string, unknown>): void {
|
||||
const sessionIds = Array.isArray(state.session_ids) ? state.session_ids : []
|
||||
state.session_ids = sessionIds
|
||||
|
||||
const sessionOrigins = state.session_origins && typeof state.session_origins === "object" && !Array.isArray(state.session_origins)
|
||||
? (state.session_origins as Record<string, unknown>)
|
||||
: {}
|
||||
state.session_origins = sessionOrigins
|
||||
|
||||
if (sessionIds.length === 1) {
|
||||
const soleSessionId = sessionIds[0]
|
||||
if (
|
||||
typeof soleSessionId === "string"
|
||||
&& sessionOrigins[soleSessionId] !== "appended"
|
||||
&& sessionOrigins[soleSessionId] !== "direct"
|
||||
) {
|
||||
sessionOrigins[soleSessionId] = "direct"
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.task_sessions || typeof state.task_sessions !== "object" || Array.isArray(state.task_sessions)) {
|
||||
state.task_sessions = {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getBoulderWorks(state: BoulderState): BoulderWorkState[] {
|
||||
if (state.works && typeof state.works === "object") {
|
||||
return Object.values(state.works)
|
||||
}
|
||||
|
||||
if (!state.active_plan || !state.plan_name || !state.started_at) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [buildWorkFromMirror(state)]
|
||||
}
|
||||
|
||||
export function getActiveWorks(directory: string): BoulderWorkState[] {
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return []
|
||||
}
|
||||
|
||||
return getBoulderWorks(state).filter((work) => work.status !== "completed" && work.status !== "abandoned")
|
||||
}
|
||||
|
||||
export function getWorkById(directory: string, workId: string): BoulderWorkState | null {
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
|
||||
return getBoulderWorks(state).find((work) => work.work_id === workId) ?? null
|
||||
}
|
||||
|
||||
export function getWorkByPlanName(
|
||||
directory: string,
|
||||
planName: string,
|
||||
options?: { worktreePath?: string },
|
||||
): BoulderWorkState | null {
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
|
||||
const worktreePath = options?.worktreePath
|
||||
return getBoulderWorks(state).find((work) => {
|
||||
if (work.plan_name !== planName) {
|
||||
return false
|
||||
}
|
||||
|
||||
return worktreePath ? work.worktree_path === worktreePath : true
|
||||
}) ?? null
|
||||
}
|
||||
|
||||
export function getWorkForSession(directory: string, sessionId: string): BoulderWorkState | null {
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
|
||||
const works = getBoulderWorks(state)
|
||||
.filter((work) => work.session_ids.includes(sessionId))
|
||||
.sort((left, right) => (parseIsoToMs(right.updated_at ?? right.started_at) ?? 0) - (parseIsoToMs(left.updated_at ?? left.started_at) ?? 0))
|
||||
|
||||
if (works.length > 0) {
|
||||
return works[0] ?? null
|
||||
}
|
||||
|
||||
return state.session_ids.includes(sessionId) ? buildWorkFromMirror(state) : null
|
||||
}
|
||||
|
||||
export function getWorkResumeOptions(directory: string): BoulderWorkResumeOption[] {
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return []
|
||||
}
|
||||
|
||||
return getBoulderWorks(state)
|
||||
.filter((work) => work.status !== "completed" && work.status !== "abandoned")
|
||||
.map((work) => {
|
||||
const progress = getPlanProgress(resolveBoulderPlanPathForWork(directory, work))
|
||||
return {
|
||||
work_id: work.work_id,
|
||||
plan_name: work.plan_name,
|
||||
active_plan: work.active_plan,
|
||||
worktree_path: work.worktree_path,
|
||||
status: work.status && isValidWorkStatus(work.status) ? work.status : "active",
|
||||
started_at: work.started_at,
|
||||
updated_at: work.updated_at ?? work.started_at,
|
||||
ended_at: work.ended_at,
|
||||
elapsed_ms: work.elapsed_ms,
|
||||
session_count: work.session_ids.length,
|
||||
progress,
|
||||
is_current_mirror: state.active_work_id === work.work_id,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getTaskSessionState(directory: string, taskKey: string): TaskSessionState | null {
|
||||
const state = readBoulderState(directory)
|
||||
if (state?.active_work_id) {
|
||||
const work = state.works?.[state.active_work_id]
|
||||
const taskSession = work?.task_sessions?.[taskKey]
|
||||
if (taskSession) {
|
||||
return taskSession
|
||||
}
|
||||
}
|
||||
|
||||
if (!state?.task_sessions) {
|
||||
return null
|
||||
}
|
||||
|
||||
return state.task_sessions[taskKey] ?? null
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { BoulderSessionOrigin, BoulderState, BoulderWorkState } from "../types"
|
||||
import { getBoulderWorks, readBoulderState } from "./read-state"
|
||||
import { nowIsoString, projectWorkToMirror } from "./shared"
|
||||
import { writeBoulderState } from "./write-state"
|
||||
|
||||
export function appendSessionId(
|
||||
directory: string,
|
||||
sessionId: string,
|
||||
origin: "direct" | "appended" = "direct",
|
||||
): BoulderState | null {
|
||||
const activeWorkId = readBoulderState(directory)?.active_work_id
|
||||
if (activeWorkId) {
|
||||
return appendSessionIdForWork(directory, activeWorkId, sessionId, origin)
|
||||
}
|
||||
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!state.session_origins || typeof state.session_origins !== "object" || Array.isArray(state.session_origins)) {
|
||||
state.session_origins = {}
|
||||
}
|
||||
|
||||
if (!state.session_ids?.includes(sessionId)) {
|
||||
if (!Array.isArray(state.session_ids)) {
|
||||
state.session_ids = []
|
||||
}
|
||||
|
||||
const originalSessionIds = [...state.session_ids]
|
||||
const originalSessionOrigins = { ...state.session_origins }
|
||||
state.session_ids.push(sessionId)
|
||||
state.session_origins[sessionId] = origin
|
||||
if (writeBoulderState(directory, state)) {
|
||||
return state
|
||||
}
|
||||
|
||||
state.session_ids = originalSessionIds
|
||||
state.session_origins = originalSessionOrigins
|
||||
return null
|
||||
}
|
||||
|
||||
if (!state.session_origins[sessionId]) {
|
||||
state.session_origins[sessionId] = origin
|
||||
if (!writeBoulderState(directory, state)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
export function appendSessionIdForWork(
|
||||
directory: string,
|
||||
workId: string,
|
||||
sessionId: string,
|
||||
origin: BoulderSessionOrigin = "direct",
|
||||
): BoulderState | null {
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
|
||||
const works = getBoulderWorks(state)
|
||||
const targetWork = works.find((work) => work.work_id === workId)
|
||||
if (!targetWork) {
|
||||
return null
|
||||
}
|
||||
|
||||
const updatedWork: BoulderWorkState = {
|
||||
...targetWork,
|
||||
session_ids: targetWork.session_ids.includes(sessionId)
|
||||
? [...targetWork.session_ids]
|
||||
: [...targetWork.session_ids, sessionId],
|
||||
session_origins: { ...(targetWork.session_origins ?? {}), [sessionId]: origin },
|
||||
updated_at: nowIsoString(),
|
||||
}
|
||||
|
||||
const nextState: BoulderState = {
|
||||
...state,
|
||||
schema_version: 2,
|
||||
works: {
|
||||
...Object.fromEntries(works.map((work) => [work.work_id, work])),
|
||||
[workId]: updatedWork,
|
||||
},
|
||||
}
|
||||
|
||||
if (state.active_work_id === workId) {
|
||||
projectWorkToMirror(nextState, updatedWork)
|
||||
}
|
||||
|
||||
return writeBoulderState(directory, nextState) ? nextState : null
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { BoulderState, BoulderWorkState, BoulderWorkStatus } from "../types"
|
||||
|
||||
export const RESERVED_KEYS = new Set(["__proto__", "prototype", "constructor"])
|
||||
|
||||
export function nowIsoString(): string {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
export function parseIsoToMs(value: string | undefined): number | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = Date.parse(value)
|
||||
return Number.isNaN(parsed) ? null : parsed
|
||||
}
|
||||
|
||||
export function getElapsedMs(startedAt: string | undefined, endedAt: string | undefined): number | undefined {
|
||||
const startedMs = parseIsoToMs(startedAt)
|
||||
const endedMs = parseIsoToMs(endedAt)
|
||||
if (startedMs === null || endedMs === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return endedMs - startedMs
|
||||
}
|
||||
|
||||
export function isValidWorkStatus(status: unknown): status is BoulderWorkStatus {
|
||||
return status === "active" || status === "completed" || status === "paused" || status === "abandoned"
|
||||
}
|
||||
|
||||
export function buildWorkFromMirror(state: BoulderState): BoulderWorkState {
|
||||
const planName = state.plan_name ?? state.active_plan
|
||||
const workId = `${planName}-legacy`
|
||||
return {
|
||||
work_id: workId,
|
||||
active_plan: state.active_plan,
|
||||
plan_name: planName,
|
||||
status: state.status,
|
||||
started_at: state.started_at,
|
||||
ended_at: state.ended_at,
|
||||
elapsed_ms: state.elapsed_ms,
|
||||
updated_at: state.updated_at,
|
||||
session_ids: Array.isArray(state.session_ids) ? [...state.session_ids] : [],
|
||||
session_origins: state.session_origins,
|
||||
agent: state.agent,
|
||||
worktree_path: state.worktree_path,
|
||||
task_sessions: state.task_sessions,
|
||||
}
|
||||
}
|
||||
|
||||
export function projectWorkToMirror(state: BoulderState, work: BoulderWorkState): void {
|
||||
state.active_plan = work.active_plan
|
||||
state.plan_name = work.plan_name
|
||||
state.status = work.status
|
||||
state.started_at = work.started_at
|
||||
state.ended_at = work.ended_at
|
||||
state.elapsed_ms = work.elapsed_ms
|
||||
state.updated_at = work.updated_at
|
||||
state.session_ids = [...work.session_ids]
|
||||
state.session_origins = work.session_origins ? { ...work.session_origins } : {}
|
||||
state.agent = work.agent
|
||||
state.worktree_path = work.worktree_path
|
||||
state.task_sessions = work.task_sessions ? { ...work.task_sessions } : {}
|
||||
}
|
||||
|
||||
export function selectMirrorWork(state: BoulderState): BoulderWorkState | null {
|
||||
const works = state.works ? Object.values(state.works) : []
|
||||
if (works.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (state.active_work_id) {
|
||||
const matched = works.find((work) => work.work_id === state.active_work_id)
|
||||
if (matched) {
|
||||
return matched
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...works].sort((left, right) => {
|
||||
const leftMs = parseIsoToMs(left.updated_at ?? left.started_at) ?? 0
|
||||
const rightMs = parseIsoToMs(right.updated_at ?? right.started_at) ?? 0
|
||||
return rightMs - leftMs
|
||||
})
|
||||
return sorted[0] ?? null
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { BoulderState, BoulderWorkState, TaskSessionState } from "../types"
|
||||
import { getBoulderWorks, readBoulderState } from "./read-state"
|
||||
import { getElapsedMs, nowIsoString, projectWorkToMirror, RESERVED_KEYS } from "./shared"
|
||||
import { writeBoulderState } from "./write-state"
|
||||
|
||||
export function upsertTaskSessionState(
|
||||
directory: string,
|
||||
input: {
|
||||
taskKey: string
|
||||
taskLabel: string
|
||||
taskTitle: string
|
||||
sessionId: string
|
||||
agent?: string
|
||||
category?: string
|
||||
},
|
||||
): BoulderState | null {
|
||||
const stateForWork = readBoulderState(directory)
|
||||
if (stateForWork?.active_work_id) {
|
||||
return upsertTaskSessionStateForWork(directory, stateForWork.active_work_id, input)
|
||||
}
|
||||
|
||||
const state = readBoulderState(directory)
|
||||
if (!state || RESERVED_KEYS.has(input.taskKey)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const taskSessions = state.task_sessions ?? {}
|
||||
taskSessions[input.taskKey] = {
|
||||
task_key: input.taskKey,
|
||||
task_label: input.taskLabel,
|
||||
task_title: input.taskTitle,
|
||||
session_id: input.sessionId,
|
||||
...(input.agent !== undefined ? { agent: input.agent } : {}),
|
||||
...(input.category !== undefined ? { category: input.category } : {}),
|
||||
updated_at: nowIsoString(),
|
||||
}
|
||||
|
||||
state.task_sessions = taskSessions
|
||||
return writeBoulderState(directory, state) ? state : null
|
||||
}
|
||||
|
||||
export function upsertTaskSessionStateForWork(
|
||||
directory: string,
|
||||
workId: string,
|
||||
input: {
|
||||
taskKey: string
|
||||
taskLabel: string
|
||||
taskTitle: string
|
||||
sessionId: string
|
||||
agent?: string
|
||||
category?: string
|
||||
},
|
||||
): BoulderState | null {
|
||||
if (RESERVED_KEYS.has(input.taskKey)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
|
||||
const works = getBoulderWorks(state)
|
||||
const targetWork = works.find((work) => work.work_id === workId)
|
||||
if (!targetWork) {
|
||||
return null
|
||||
}
|
||||
|
||||
const previousTaskSession = targetWork.task_sessions?.[input.taskKey]
|
||||
const nextTaskSession: TaskSessionState = {
|
||||
task_key: input.taskKey,
|
||||
task_label: input.taskLabel,
|
||||
task_title: input.taskTitle,
|
||||
session_id: input.sessionId,
|
||||
...(input.agent !== undefined ? { agent: input.agent } : {}),
|
||||
...(input.category !== undefined ? { category: input.category } : {}),
|
||||
...(previousTaskSession?.started_at !== undefined ? { started_at: previousTaskSession.started_at } : {}),
|
||||
...(previousTaskSession?.ended_at !== undefined ? { ended_at: previousTaskSession.ended_at } : {}),
|
||||
...(previousTaskSession?.elapsed_ms !== undefined ? { elapsed_ms: previousTaskSession.elapsed_ms } : {}),
|
||||
...(previousTaskSession?.status !== undefined ? { status: previousTaskSession.status } : {}),
|
||||
updated_at: nowIsoString(),
|
||||
}
|
||||
|
||||
const nextWork: BoulderWorkState = {
|
||||
...targetWork,
|
||||
task_sessions: { ...(targetWork.task_sessions ?? {}), [input.taskKey]: nextTaskSession },
|
||||
updated_at: nowIsoString(),
|
||||
}
|
||||
|
||||
const nextState: BoulderState = {
|
||||
...state,
|
||||
schema_version: 2,
|
||||
works: {
|
||||
...Object.fromEntries(works.map((work) => [work.work_id, work])),
|
||||
[workId]: nextWork,
|
||||
},
|
||||
}
|
||||
|
||||
if (state.active_work_id === workId) {
|
||||
projectWorkToMirror(nextState, nextWork)
|
||||
}
|
||||
|
||||
return writeBoulderState(directory, nextState) ? nextState : null
|
||||
}
|
||||
|
||||
export function startTaskTimer(
|
||||
directory: string,
|
||||
workId: string,
|
||||
input: {
|
||||
taskKey: string
|
||||
taskLabel: string
|
||||
taskTitle: string
|
||||
sessionId: string
|
||||
agent?: string
|
||||
category?: string
|
||||
startedAt?: string
|
||||
},
|
||||
): BoulderState | null {
|
||||
const nextState = upsertTaskSessionStateForWork(directory, workId, input)
|
||||
if (!nextState) {
|
||||
return null
|
||||
}
|
||||
|
||||
const work = nextState.works?.[workId]
|
||||
const taskSession = work?.task_sessions?.[input.taskKey]
|
||||
if (!work || !taskSession) {
|
||||
return null
|
||||
}
|
||||
|
||||
const startedAt = taskSession.started_at ?? input.startedAt ?? nowIsoString()
|
||||
taskSession.started_at = startedAt
|
||||
taskSession.status = "running"
|
||||
taskSession.updated_at = nowIsoString()
|
||||
work.updated_at = nowIsoString()
|
||||
return writeBoulderState(directory, nextState) ? nextState : null
|
||||
}
|
||||
|
||||
export function endTaskTimer(
|
||||
directory: string,
|
||||
workId: string,
|
||||
taskKey: string,
|
||||
endedAt?: string,
|
||||
): BoulderState | null {
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
|
||||
const work = state.works?.[workId] ?? getBoulderWorks(state).find((candidate) => candidate.work_id === workId)
|
||||
if (!work?.task_sessions?.[taskKey]) {
|
||||
return null
|
||||
}
|
||||
|
||||
const taskSession = work.task_sessions[taskKey]
|
||||
const endAt = endedAt ?? nowIsoString()
|
||||
taskSession.ended_at = endAt
|
||||
taskSession.elapsed_ms = getElapsedMs(taskSession.started_at, endAt)
|
||||
taskSession.status = "completed"
|
||||
taskSession.updated_at = nowIsoString()
|
||||
work.updated_at = nowIsoString()
|
||||
|
||||
if (state.active_work_id === workId) {
|
||||
projectWorkToMirror(state, work)
|
||||
}
|
||||
|
||||
return writeBoulderState(directory, state) ? state : null
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs"
|
||||
import { dirname } from "node:path"
|
||||
|
||||
import type { BoulderState, BoulderWorkState } from "../types"
|
||||
import { getBoulderFilePath } from "./path"
|
||||
import { getPlanName } from "./plan-progress"
|
||||
import { getBoulderWorks, readBoulderState } from "./read-state"
|
||||
import { getElapsedMs, nowIsoString, projectWorkToMirror } from "./shared"
|
||||
|
||||
export function writeBoulderState(directory: string, state: BoulderState): boolean {
|
||||
const filePath = getBoulderFilePath(directory)
|
||||
try {
|
||||
const dir = dirname(filePath)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
const stateToWrite: BoulderState = { ...state }
|
||||
if (stateToWrite.works && stateToWrite.active_work_id) {
|
||||
const activeWork = stateToWrite.works[stateToWrite.active_work_id]
|
||||
if (activeWork) {
|
||||
stateToWrite.works = {
|
||||
...stateToWrite.works,
|
||||
[stateToWrite.active_work_id]: {
|
||||
...activeWork,
|
||||
active_plan: stateToWrite.active_plan,
|
||||
plan_name: stateToWrite.plan_name,
|
||||
status: stateToWrite.status,
|
||||
started_at: stateToWrite.started_at,
|
||||
ended_at: stateToWrite.ended_at,
|
||||
elapsed_ms: stateToWrite.elapsed_ms,
|
||||
updated_at: stateToWrite.updated_at,
|
||||
session_ids: [...stateToWrite.session_ids],
|
||||
session_origins: stateToWrite.session_origins ? { ...stateToWrite.session_origins } : {},
|
||||
agent: stateToWrite.agent,
|
||||
worktree_path: stateToWrite.worktree_path,
|
||||
task_sessions: stateToWrite.task_sessions ? { ...stateToWrite.task_sessions } : {},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(filePath, JSON.stringify(stateToWrite, null, 2), "utf-8")
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function clearBoulderState(directory: string): boolean {
|
||||
const filePath = getBoulderFilePath(directory)
|
||||
try {
|
||||
if (existsSync(filePath)) {
|
||||
unlinkSync(filePath)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function generateWorkId(planName: string): string {
|
||||
const slug = planName.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")
|
||||
const randomHex = Math.floor(Math.random() * 0xffffffff).toString(16).padStart(8, "0")
|
||||
return `${slug.length > 0 ? slug : "work"}-${randomHex}`
|
||||
}
|
||||
|
||||
export function createBoulderState(planPath: string, sessionId: string, agent?: string, worktreePath?: string): BoulderState {
|
||||
const startedAt = nowIsoString()
|
||||
const workId = generateWorkId(getPlanName(planPath))
|
||||
const work: BoulderWorkState = {
|
||||
work_id: workId,
|
||||
active_plan: planPath,
|
||||
plan_name: getPlanName(planPath),
|
||||
status: "active",
|
||||
started_at: startedAt,
|
||||
updated_at: startedAt,
|
||||
session_ids: [sessionId],
|
||||
session_origins: { [sessionId]: "direct" },
|
||||
...(agent !== undefined ? { agent } : {}),
|
||||
...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}),
|
||||
task_sessions: {},
|
||||
}
|
||||
|
||||
return {
|
||||
schema_version: 2,
|
||||
active_work_id: workId,
|
||||
works: { [workId]: work },
|
||||
active_plan: planPath,
|
||||
started_at: startedAt,
|
||||
status: "active",
|
||||
updated_at: startedAt,
|
||||
session_ids: [sessionId],
|
||||
session_origins: { [sessionId]: "direct" },
|
||||
plan_name: getPlanName(planPath),
|
||||
task_sessions: {},
|
||||
...(agent !== undefined ? { agent } : {}),
|
||||
...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function selectActiveWork(directory: string, workId: string): BoulderState | null {
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
|
||||
const works = getBoulderWorks(state)
|
||||
const nextWork = works.find((work) => work.work_id === workId)
|
||||
if (!nextWork) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nextState: BoulderState = {
|
||||
...state,
|
||||
schema_version: 2,
|
||||
active_work_id: workId,
|
||||
works: state.works ?? Object.fromEntries(works.map((work) => [work.work_id, work])),
|
||||
}
|
||||
projectWorkToMirror(nextState, nextWork)
|
||||
return writeBoulderState(directory, nextState) ? nextState : null
|
||||
}
|
||||
|
||||
export function addBoulderWork(
|
||||
directory: string,
|
||||
input: { planPath: string; sessionId: string; agent?: string; worktreePath?: string; startedAt?: string },
|
||||
): BoulderState | null {
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
|
||||
const workId = generateWorkId(getPlanName(input.planPath))
|
||||
const startedAt = input.startedAt ?? nowIsoString()
|
||||
const nextWork: BoulderWorkState = {
|
||||
work_id: workId,
|
||||
active_plan: input.planPath,
|
||||
plan_name: getPlanName(input.planPath),
|
||||
status: "active",
|
||||
started_at: startedAt,
|
||||
updated_at: startedAt,
|
||||
session_ids: [input.sessionId],
|
||||
session_origins: { [input.sessionId]: "direct" },
|
||||
...(input.agent !== undefined ? { agent: input.agent } : {}),
|
||||
...(input.worktreePath !== undefined ? { worktree_path: input.worktreePath } : {}),
|
||||
task_sessions: {},
|
||||
}
|
||||
|
||||
const nextState: BoulderState = {
|
||||
...state,
|
||||
schema_version: 2,
|
||||
works: { ...Object.fromEntries(getBoulderWorks(state).map((work) => [work.work_id, work])), [workId]: nextWork },
|
||||
active_work_id: workId,
|
||||
}
|
||||
projectWorkToMirror(nextState, nextWork)
|
||||
return writeBoulderState(directory, nextState) ? nextState : null
|
||||
}
|
||||
|
||||
export function completeBoulder(directory: string, workId?: string, endedAt?: string): BoulderState | null {
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
|
||||
const targetWorkId = workId ?? state.active_work_id
|
||||
if (!targetWorkId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const work = state.works?.[targetWorkId] ?? getBoulderWorks(state).find((candidate) => candidate.work_id === targetWorkId)
|
||||
if (!work) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (work.status === "completed" && work.ended_at !== undefined && work.elapsed_ms !== undefined) {
|
||||
return state
|
||||
}
|
||||
|
||||
const endAt = endedAt ?? nowIsoString()
|
||||
work.ended_at = endAt
|
||||
work.elapsed_ms = getElapsedMs(work.started_at, endAt)
|
||||
work.status = "completed"
|
||||
work.updated_at = nowIsoString()
|
||||
|
||||
if (state.active_work_id === targetWorkId) {
|
||||
projectWorkToMirror(state, work)
|
||||
}
|
||||
|
||||
return writeBoulderState(directory, state) ? state : null
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
|
||||
import type { TopLevelTaskRef } from "./types"
|
||||
|
||||
const TODO_HEADING_PATTERN = /^##\s+TODOs\b/i
|
||||
const FINAL_VERIFICATION_HEADING_PATTERN = /^##\s+Final Verification Wave\b/i
|
||||
const SECOND_LEVEL_HEADING_PATTERN = /^##\s+/
|
||||
const UNCHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[\s*\]\s*(.+)$/
|
||||
const TODO_TASK_PATTERN = /^(\d+)\.\s+(.+)$/
|
||||
const FINAL_WAVE_TASK_PATTERN = /^(F\d+)\.\s+(.+)$/i
|
||||
|
||||
type PlanSection = "todo" | "final-wave" | "other"
|
||||
|
||||
function buildTaskRef(section: "todo" | "final-wave", taskLabel: string): TopLevelTaskRef | null {
|
||||
const pattern = section === "todo" ? TODO_TASK_PATTERN : FINAL_WAVE_TASK_PATTERN
|
||||
const match = taskLabel.match(pattern)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rawLabel = match[1]
|
||||
const title = match[2].trim()
|
||||
return {
|
||||
key: `${section}:${rawLabel.toLowerCase()}`,
|
||||
section,
|
||||
label: rawLabel,
|
||||
title,
|
||||
}
|
||||
}
|
||||
|
||||
export function readCurrentTopLevelTask(planPath: string): TopLevelTaskRef | null {
|
||||
if (!existsSync(planPath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(planPath, "utf-8")
|
||||
const lines = content.split(/\r?\n/)
|
||||
let section: PlanSection = "other"
|
||||
|
||||
for (const line of lines) {
|
||||
if (SECOND_LEVEL_HEADING_PATTERN.test(line)) {
|
||||
section = TODO_HEADING_PATTERN.test(line)
|
||||
? "todo"
|
||||
: FINAL_VERIFICATION_HEADING_PATTERN.test(line)
|
||||
? "final-wave"
|
||||
: "other"
|
||||
}
|
||||
|
||||
const uncheckedTaskMatch = line.match(UNCHECKED_CHECKBOX_PATTERN)
|
||||
if (!uncheckedTaskMatch || uncheckedTaskMatch[1].length > 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (section !== "todo" && section !== "final-wave") {
|
||||
continue
|
||||
}
|
||||
|
||||
const taskRef = buildTaskRef(section, uncheckedTaskMatch[2].trim())
|
||||
if (taskRef) {
|
||||
return taskRef
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
export interface BoulderState {
|
||||
schema_version?: 2
|
||||
active_work_id?: string
|
||||
works?: Record<string, BoulderWorkState>
|
||||
active_plan: string
|
||||
started_at: string
|
||||
ended_at?: string
|
||||
elapsed_ms?: number
|
||||
status?: BoulderWorkStatus
|
||||
updated_at?: string
|
||||
session_ids: string[]
|
||||
session_origins?: Record<string, "direct" | "appended">
|
||||
plan_name: string
|
||||
agent?: string
|
||||
worktree_path?: string
|
||||
task_sessions?: Record<string, TaskSessionState>
|
||||
}
|
||||
|
||||
export type BoulderSessionOrigin = "direct" | "appended"
|
||||
export type BoulderWorkStatus = "active" | "completed" | "paused" | "abandoned"
|
||||
export type BoulderTaskStatus = "running" | "completed" | "cancelled"
|
||||
|
||||
export interface BoulderWorkState {
|
||||
work_id: string
|
||||
active_plan: string
|
||||
plan_name: string
|
||||
status?: BoulderWorkStatus
|
||||
started_at: string
|
||||
ended_at?: string
|
||||
elapsed_ms?: number
|
||||
updated_at?: string
|
||||
session_ids: string[]
|
||||
session_origins?: Record<string, BoulderSessionOrigin>
|
||||
agent?: string
|
||||
worktree_path?: string
|
||||
task_sessions?: Record<string, TaskSessionState>
|
||||
}
|
||||
|
||||
export interface PlanProgress {
|
||||
total: number
|
||||
completed: number
|
||||
isComplete: boolean
|
||||
}
|
||||
|
||||
export interface TaskSessionState {
|
||||
task_key: string
|
||||
task_label: string
|
||||
task_title: string
|
||||
session_id: string
|
||||
agent?: string
|
||||
category?: string
|
||||
started_at?: string
|
||||
ended_at?: string
|
||||
elapsed_ms?: number
|
||||
status?: BoulderTaskStatus
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface BoulderWorkResumeOption {
|
||||
work_id: string
|
||||
plan_name: string
|
||||
active_plan: string
|
||||
worktree_path?: string
|
||||
status: BoulderWorkStatus
|
||||
started_at: string
|
||||
updated_at: string
|
||||
ended_at?: string
|
||||
elapsed_ms?: number
|
||||
session_count: number
|
||||
progress: PlanProgress
|
||||
is_current_mirror: boolean
|
||||
}
|
||||
|
||||
export interface TopLevelTaskRef {
|
||||
key: string
|
||||
section: "todo" | "final-wave"
|
||||
label: string
|
||||
title: string
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"lib": ["ESNext"],
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user