diff --git a/src/hooks/atlas/atlas-hook.ts b/src/hooks/atlas/atlas-hook.ts index aa9e13c4e..4dc7c9e93 100644 --- a/src/hooks/atlas/atlas-hook.ts +++ b/src/hooks/atlas/atlas-hook.ts @@ -8,6 +8,7 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) { const sessions = new Map() const pendingFilePaths = new Map() const pendingTaskRefs = new Map() + const pendingPlanSnapshots = new Map() const autoCommit = options?.autoCommit ?? true function getState(sessionID: string): SessionState { @@ -25,12 +26,14 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) { ctx, pendingFilePaths, pendingTaskRefs, + pendingPlanSnapshots, isCallerOrchestrator: options?.isCallerOrchestrator, }), "tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths, pendingTaskRefs, + pendingPlanSnapshots, autoCommit, getState, isCallerOrchestrator: options?.isCallerOrchestrator, diff --git a/src/hooks/atlas/tool-execute-after-task-timers.test.ts b/src/hooks/atlas/tool-execute-after-task-timers.test.ts index 54b210233..6bda79239 100644 --- a/src/hooks/atlas/tool-execute-after-task-timers.test.ts +++ b/src/hooks/atlas/tool-execute-after-task-timers.test.ts @@ -78,7 +78,7 @@ describe("createToolExecuteAfterHandler task timers", () => { session: { get: async (input: SessionGetInput) => createSessionGetResult(parentSessionIDs?.[input.path.id]), }, - } as unknown as PluginInput["client"] + } as PluginInput["client"] if (parentSessionIDs) { spyOn(client.session, "get").mockImplementation((input) => Promise.resolve( @@ -88,6 +88,7 @@ describe("createToolExecuteAfterHandler task timers", () => { const pendingFilePaths = new Map() const pendingTaskRefs = new Map() + const pendingPlanSnapshots = new Map() const ctx = { client, project, @@ -98,11 +99,17 @@ describe("createToolExecuteAfterHandler task timers", () => { } satisfies PluginInput return { - beforeHandler: createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs }), + beforeHandler: createToolExecuteBeforeHandler({ + ctx, + pendingFilePaths, + pendingTaskRefs, + pendingPlanSnapshots, + }), afterHandler: createToolExecuteAfterHandler({ ctx, pendingFilePaths, pendingTaskRefs, + pendingPlanSnapshots, autoCommit: true, getState: () => ({ promptFailureCount: 0 }), }), @@ -219,4 +226,70 @@ describe("createToolExecuteAfterHandler task timers", () => { expect(taskSession?.status).toBe("completed") expect(typeof taskSession?.elapsed_ms).toBe("number") }) + + it("ends task timer when plan checkbox flips to checked via edit tool", async () => { + // given + const parentSessionID = "ses_parent_3" + const planPath = join(testDirectory, "task-timer-edit-plan.md") + writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. Implement auth flow\n", "utf-8") + writeBoulderState(testDirectory, { + schema_version: 2, + active_work_id: "work-1", + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [parentSessionID], + plan_name: "task-timer-edit-plan", + task_sessions: { + "todo:1": { + task_key: "todo:1", + task_label: "1", + task_title: "Implement auth flow", + session_id: "ses_child_3", + started_at: "2026-01-02T10:00:00Z", + status: "running", + updated_at: "2026-01-02T10:00:00Z", + }, + }, + works: { + "work-1": { + work_id: "work-1", + active_plan: planPath, + plan_name: "task-timer-edit-plan", + started_at: "2026-01-02T10:00:00Z", + session_ids: [parentSessionID], + status: "active", + task_sessions: {}, + }, + }, + }) + const { beforeHandler, afterHandler } = createHandlers() + + await beforeHandler( + { tool: "edit", sessionID: parentSessionID, callID: "call-task-timer-edit-1" }, + { args: { filePath: planPath, oldString: "- [ ] 1. Implement auth flow", newString: "- [x] 1. Implement auth flow" } }, + ) + + writeFileSync(planPath, "# Plan\n\n## TODOs\n- [x] 1. Implement auth flow\n", "utf-8") + + // when + await afterHandler( + { tool: "edit", sessionID: parentSessionID, callID: "call-task-timer-edit-1" }, + { + title: "Edit", + output: "Updated file", + metadata: { + filePath: planPath, + }, + }, + ) + + // then + const taskSession = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions?.["todo:1"] + expect(taskSession).toBeDefined() + expect(taskSession?.ended_at).toBeString() + expect(taskSession?.status).toBe("completed") + expect(typeof taskSession?.elapsed_ms).toBe("number") + expect((taskSession?.elapsed_ms ?? 0) > 0).toBe(true) + }) + }) diff --git a/src/hooks/atlas/tool-execute-after.ts b/src/hooks/atlas/tool-execute-after.ts index 4cef75ac1..46928cb81 100644 --- a/src/hooks/atlas/tool-execute-after.ts +++ b/src/hooks/atlas/tool-execute-after.ts @@ -11,6 +11,7 @@ import { upsertTaskSessionState, } from "../../features/boulder-state" import { existsSync, readFileSync } from "node:fs" +import { resolve } from "node:path" import { log } from "../../shared/logger" import { isCallerOrchestrator } from "../../shared/session-utils" import { syncBackgroundLaunchSessionTracking } from "./background-launch-session-tracking" @@ -59,15 +60,77 @@ function isTrackedTaskChecked(planPath: string, taskKey: string): boolean { } } +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 CHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[[xX]\]\s*(.+)$/ +const TODO_TASK_PATTERN = /^(\d+)\.\s+(.+)$/ +const FINAL_WAVE_TASK_PATTERN = /^(F\d+)\.\s+(.+)$/i + +function parseCheckedTopLevelTaskKeys(planContent: string): Set { + const checkedKeys = new Set() + const lines = planContent.split(/\r?\n/) + let section: "todo" | "final-wave" | "other" = "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" + continue + } + + if (section !== "todo" && section !== "final-wave") { + continue + } + + const checkedMatch = line.match(CHECKED_CHECKBOX_PATTERN) + if (!checkedMatch || checkedMatch[1].length > 0) { + continue + } + + const taskBody = checkedMatch[2].trim() + if (section === "todo") { + const taskMatch = taskBody.match(TODO_TASK_PATTERN) + if (taskMatch?.[1]) { + checkedKeys.add(`todo:${taskMatch[1]}`) + } + continue + } + + const taskMatch = taskBody.match(FINAL_WAVE_TASK_PATTERN) + if (taskMatch?.[1]) { + checkedKeys.add(`final-wave:${taskMatch[1].toLowerCase()}`) + } + } + + return checkedKeys +} + +function readCheckedTaskKeysFromPlan(planPath: string): Set { + if (!existsSync(planPath)) { + return new Set() + } + + try { + return parseCheckedTopLevelTaskKeys(readFileSync(planPath, "utf-8")) + } catch { + return new Set() + } +} + export function createToolExecuteAfterHandler(input: { ctx: PluginInput pendingFilePaths: Map pendingTaskRefs: Map + pendingPlanSnapshots?: Map autoCommit: boolean getState: (sessionID: string) => SessionState isCallerOrchestrator?: (sessionID: string | undefined) => Promise }): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput | undefined) => Promise { - const { ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState } = input + const { ctx, pendingFilePaths, pendingTaskRefs, pendingPlanSnapshots, autoCommit, getState } = input const resolveIsCallerOrchestrator = input.isCallerOrchestrator ?? ((sessionID) => isCallerOrchestrator(sessionID, ctx.client)) return async (toolInput, toolOutput): Promise => { // Guard against undefined output (e.g., from /review command - see issue #1035) @@ -81,12 +144,33 @@ export function createToolExecuteAfterHandler(input: { if (isWriteOrEditToolName(toolInput.tool)) { let filePath = toolInput.callID ? pendingFilePaths.get(toolInput.callID) : undefined + const planSnapshot = toolInput.callID && pendingPlanSnapshots + ? pendingPlanSnapshots.get(toolInput.callID) + : undefined if (toolInput.callID) { pendingFilePaths.delete(toolInput.callID) + pendingPlanSnapshots?.delete(toolInput.callID) } if (!filePath) { filePath = toolOutput.metadata?.filePath as string | undefined } + + if (filePath && toolInput.sessionID) { + const sessionWork = getWorkForSession(ctx.directory, toolInput.sessionID) + if (sessionWork) { + const planPath = resolveBoulderPlanPathForWork(ctx.directory, sessionWork) + if (resolve(filePath) === resolve(planPath) && planSnapshot !== undefined) { + const beforeCheckedKeys = parseCheckedTopLevelTaskKeys(planSnapshot) + const afterCheckedKeys = readCheckedTaskKeysFromPlan(planPath) + for (const taskKey of afterCheckedKeys) { + if (!beforeCheckedKeys.has(taskKey)) { + endTaskTimer(ctx.directory, sessionWork.work_id, taskKey) + } + } + } + } + } + if (filePath && !isSisyphusPath(filePath)) { toolOutput.output = (toolOutput.output || "") + DIRECT_WORK_REMINDER log(`[${HOOK_NAME}] Direct work reminder appended`, { diff --git a/src/hooks/atlas/tool-execute-before.ts b/src/hooks/atlas/tool-execute-before.ts index dd31f1c40..88e31fc13 100644 --- a/src/hooks/atlas/tool-execute-before.ts +++ b/src/hooks/atlas/tool-execute-before.ts @@ -2,7 +2,9 @@ import { log } from "../../shared/logger" import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" import { isCallerOrchestrator } from "../../shared/session-utils" import type { PluginInput } from "@opencode-ai/plugin" -import { readBoulderState, readCurrentTopLevelTask, resolveBoulderPlanPath } from "../../features/boulder-state" +import { existsSync, readFileSync } from "node:fs" +import { resolve } from "node:path" +import { getWorkForSession, readBoulderState, readCurrentTopLevelTask, resolveBoulderPlanPath, resolveBoulderPlanPathForWork } from "../../features/boulder-state" import { HOOK_NAME } from "./hook-name" import { ORCHESTRATOR_DELEGATION_REQUIRED, SINGLE_TASK_DIRECTIVE } from "./system-reminder-templates" import { isSisyphusPath } from "./sisyphus-path" @@ -13,12 +15,13 @@ export function createToolExecuteBeforeHandler(input: { ctx: PluginInput pendingFilePaths: Map pendingTaskRefs: Map + pendingPlanSnapshots?: Map isCallerOrchestrator?: (sessionID: string | undefined) => Promise }): ( toolInput: { tool: string; sessionID?: string; callID?: string }, toolOutput: { args: Record; message?: string } ) => Promise { - const { ctx, pendingFilePaths, pendingTaskRefs } = input + const { ctx, pendingFilePaths, pendingTaskRefs, pendingPlanSnapshots } = input const resolveIsCallerOrchestrator = input.isCallerOrchestrator ?? ((sessionID) => isCallerOrchestrator(sessionID, ctx.client)) function trackTask(callID: string, task: TrackedTopLevelTaskRef): void { @@ -38,6 +41,27 @@ export function createToolExecuteBeforeHandler(input: { // Store filePath for use in tool.execute.after if (toolInput.callID) { pendingFilePaths.set(toolInput.callID, filePath) + + const sessionID = toolInput.sessionID + const sessionWork = sessionID + ? getWorkForSession(ctx.directory, sessionID) + : null + const state = sessionWork ? null : readBoulderState(ctx.directory) + const planPath = sessionWork + ? resolveBoulderPlanPathForWork(ctx.directory, sessionWork) + : state + ? resolveBoulderPlanPath(ctx.directory, state) + : null + + if (planPath && resolve(filePath) === resolve(planPath) && pendingPlanSnapshots) { + try { + if (existsSync(planPath)) { + pendingPlanSnapshots.set(toolInput.callID, readFileSync(planPath, "utf-8")) + } + } catch { + pendingPlanSnapshots.delete(toolInput.callID) + } + } } const warning = ORCHESTRATOR_DELEGATION_REQUIRED.replace("$FILE_PATH", filePath) toolOutput.message = (toolOutput.message || "") + warning @@ -65,28 +89,28 @@ export function createToolExecuteBeforeHandler(input: { ? readCurrentTopLevelTask(resolveBoulderPlanPath(ctx.directory, boulderState)) : null if (currentTask) { - const task = { + const trackedTask = { key: currentTask.key, label: currentTask.label, title: currentTask.title, } const hasExistingClaim = [...pendingTaskRefs.values()].some((pendingTaskRef) => ( - pendingTaskRef.kind === "track" && pendingTaskRef.task.key === task.key + pendingTaskRef.kind === "track" && pendingTaskRef.task.key === trackedTask.key )) if (hasExistingClaim) { pendingTaskRefs.set(toolInput.callID, { kind: "skip", reason: "ambiguous_task_key", - task, + task: trackedTask, }) log(`[${HOOK_NAME}] Skipping task session persistence for ambiguous task key`, { sessionID: toolInput.sessionID, callID: toolInput.callID, - taskKey: task.key, + taskKey: trackedTask.key, }) } else { - trackTask(toolInput.callID, task) + trackTask(toolInput.callID, trackedTask) } } } diff --git a/src/hooks/atlas/write-edit-tool-policy.ts b/src/hooks/atlas/write-edit-tool-policy.ts index af75d2727..790f65351 100644 --- a/src/hooks/atlas/write-edit-tool-policy.ts +++ b/src/hooks/atlas/write-edit-tool-policy.ts @@ -1,4 +1,4 @@ -const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit"] +const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit", "hashline_edit"] export function isWriteOrEditToolName(toolName: string): boolean { return WRITE_EDIT_TOOLS.includes(toolName)