feat(hooks/atlas): end task timer when plan checkbox flips to checked via edit

This commit is contained in:
YeonGyu-Kim
2026-05-11 14:27:03 +09:00
parent b8c25b3b75
commit e3cddb3650
5 changed files with 195 additions and 11 deletions
+3
View File
@@ -8,6 +8,7 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) {
const sessions = new Map<string, SessionState>()
const pendingFilePaths = new Map<string, string>()
const pendingTaskRefs = new Map<string, PendingTaskRef>()
const pendingPlanSnapshots = new Map<string, string>()
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,
@@ -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<string, string>()
const pendingTaskRefs = new Map()
const pendingPlanSnapshots = new Map<string, string>()
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)
})
})
+85 -1
View File
@@ -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<string> {
const checkedKeys = new Set<string>()
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<string> {
if (!existsSync(planPath)) {
return new Set<string>()
}
try {
return parseCheckedTopLevelTaskKeys(readFileSync(planPath, "utf-8"))
} catch {
return new Set<string>()
}
}
export function createToolExecuteAfterHandler(input: {
ctx: PluginInput
pendingFilePaths: Map<string, string>
pendingTaskRefs: Map<string, PendingTaskRef>
pendingPlanSnapshots?: Map<string, string>
autoCommit: boolean
getState: (sessionID: string) => SessionState
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput | undefined) => Promise<void> {
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<void> => {
// 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`, {
+31 -7
View File
@@ -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<string, string>
pendingTaskRefs: Map<string, PendingTaskRef>
pendingPlanSnapshots?: Map<string, string>
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
}): (
toolInput: { tool: string; sessionID?: string; callID?: string },
toolOutput: { args: Record<string, unknown>; message?: string }
) => Promise<void> {
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)
}
}
}
+1 -1
View File
@@ -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)