fix(hooks/atlas): capture plan snapshot for .sisyphus paths

Oracle review of PR #3943 surfaced that endTaskTimer never fires for real Prometheus plans because their canonical path is .sisyphus/plans/ and the snapshot capture was nested inside the !isSisyphusPath branch intended for direct-work warning suppression. Move the snapshot/path tracking out of the warning gate so all plan-file edits are snapshotted regardless of .sisyphus prefix. Keep the warning branch isSisyphus-gated so Atlas does not yell at legitimate plan edits.

Regression test now uses a real .sisyphus/plans/ path and fails against HEAD before the fix.
This commit is contained in:
YeonGyu-Kim
2026-05-11 14:48:49 +09:00
parent cf5fe757df
commit 29c42485a8
2 changed files with 28 additions and 23 deletions
@@ -230,7 +230,9 @@ describe("createToolExecuteAfterHandler task timers", () => {
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")
const planDirectory = join(testDirectory, ".sisyphus", "plans")
mkdirSync(planDirectory, { recursive: true })
const planPath = join(planDirectory, "task-timer-edit-plan.md")
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. Implement auth flow\n", "utf-8")
writeBoulderState(testDirectory, {
schema_version: 2,
+25 -22
View File
@@ -80,32 +80,35 @@ export function createToolExecuteBeforeHandler(input: {
// Warn-only policy: Atlas guides orchestrators toward delegation but doesn't block, allowing flexibility for urgent fixes
if (isWriteOrEditToolName(toolInput.tool)) {
const filePath = (toolOutput.args.filePath ?? toolOutput.args.path ?? toolOutput.args.file) as string | undefined
if (filePath && !isSisyphusPath(filePath)) {
// Store filePath for use in tool.execute.after
if (toolInput.callID) {
pendingFilePaths.set(toolInput.callID, filePath)
if (!filePath || !toolInput.callID) {
return
}
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
// Store filePath for use in tool.execute.after
pendingFilePaths.set(toolInput.callID, filePath)
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 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)
}
}
if (!isSisyphusPath(filePath)) {
const warning = ORCHESTRATOR_DELEGATION_REQUIRED.replace("$FILE_PATH", filePath)
toolOutput.message = (toolOutput.message || "") + warning
log(`[${HOOK_NAME}] Injected delegation warning for direct file modification`, {