ae05e76ddf
When no worktree is specified in boulder, stop injecting 'Worktree Setup Required' instructions. When worktree IS present, inject emphatic instructions ensuring the agent and all subagents operate exclusively within the worktree directory.
273 lines
9.0 KiB
TypeScript
273 lines
9.0 KiB
TypeScript
import { statSync } from "node:fs"
|
|
import type { PluginInput } from "@opencode-ai/plugin"
|
|
import {
|
|
readBoulderState,
|
|
writeBoulderState,
|
|
appendSessionId,
|
|
findPrometheusPlans,
|
|
getPlanProgress,
|
|
createBoulderState,
|
|
getPlanName,
|
|
clearBoulderState,
|
|
} from "../../features/boulder-state"
|
|
import { log } from "../../shared/logger"
|
|
import { updateSessionAgent } from "../../features/claude-code-session-state"
|
|
import { detectWorktreePath } from "./worktree-detector"
|
|
import { parseUserRequest } from "./parse-user-request"
|
|
|
|
export const HOOK_NAME = "start-work" as const
|
|
|
|
interface StartWorkHookInput {
|
|
sessionID: string
|
|
messageID?: string
|
|
}
|
|
|
|
interface StartWorkHookOutput {
|
|
parts: Array<{ type: string; text?: string }>
|
|
}
|
|
|
|
function findPlanByName(plans: string[], requestedName: string): string | null {
|
|
const lowerName = requestedName.toLowerCase()
|
|
const exactMatch = plans.find((p) => getPlanName(p).toLowerCase() === lowerName)
|
|
if (exactMatch) return exactMatch
|
|
const partialMatch = plans.find((p) => getPlanName(p).toLowerCase().includes(lowerName))
|
|
return partialMatch || null
|
|
}
|
|
|
|
function createWorktreeActiveBlock(worktreePath: string): string {
|
|
return `
|
|
## Worktree Active
|
|
|
|
**Worktree**: \`${worktreePath}\`
|
|
|
|
**CRITICAL — DO NOT FORGET**: You are working inside a git worktree. ALL operations MUST be performed exclusively within this worktree directory.
|
|
- Every file read, write, edit, and git operation MUST target paths under: \`${worktreePath}\`
|
|
- When delegating tasks to subagents, you MUST include the worktree path in your delegation prompt so they also operate exclusively within the worktree
|
|
- NEVER operate on the main repository directory — always use the worktree path above`
|
|
}
|
|
|
|
function resolveWorktreeContext(
|
|
explicitWorktreePath: string | null,
|
|
): { worktreePath: string | undefined; block: string } {
|
|
if (explicitWorktreePath === null) {
|
|
return { worktreePath: undefined, block: "" }
|
|
}
|
|
|
|
const validatedPath = detectWorktreePath(explicitWorktreePath)
|
|
if (validatedPath) {
|
|
return { worktreePath: validatedPath, block: createWorktreeActiveBlock(validatedPath) }
|
|
}
|
|
|
|
return {
|
|
worktreePath: undefined,
|
|
block: `\n**Worktree** (needs setup): \`git worktree add ${explicitWorktreePath} <branch>\`, then add \`"worktree_path"\` to boulder.json`,
|
|
}
|
|
}
|
|
|
|
export function createStartWorkHook(ctx: PluginInput) {
|
|
return {
|
|
"chat.message": async (input: StartWorkHookInput, output: StartWorkHookOutput): Promise<void> => {
|
|
const parts = output.parts
|
|
const promptText =
|
|
parts
|
|
?.filter((p) => p.type === "text" && p.text)
|
|
.map((p) => p.text)
|
|
.join("\n")
|
|
.trim() || ""
|
|
|
|
if (!promptText.includes("<session-context>")) return
|
|
|
|
log(`[${HOOK_NAME}] Processing start-work command`, { sessionID: input.sessionID })
|
|
updateSessionAgent(input.sessionID, "atlas")
|
|
|
|
const existingState = readBoulderState(ctx.directory)
|
|
const sessionId = input.sessionID
|
|
const timestamp = new Date().toISOString()
|
|
|
|
const { planName: explicitPlanName, explicitWorktreePath } = parseUserRequest(promptText)
|
|
const { worktreePath, block: worktreeBlock } = resolveWorktreeContext(explicitWorktreePath)
|
|
|
|
let contextInfo = ""
|
|
|
|
if (explicitPlanName) {
|
|
log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { sessionID: input.sessionID })
|
|
|
|
const allPlans = findPrometheusPlans(ctx.directory)
|
|
const matchedPlan = findPlanByName(allPlans, explicitPlanName)
|
|
|
|
if (matchedPlan) {
|
|
const progress = getPlanProgress(matchedPlan)
|
|
|
|
if (progress.isComplete) {
|
|
contextInfo = `
|
|
## Plan Already Complete
|
|
|
|
The requested plan "${getPlanName(matchedPlan)}" has been completed.
|
|
All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
|
|
} else {
|
|
if (existingState) clearBoulderState(ctx.directory)
|
|
const newState = createBoulderState(matchedPlan, sessionId, "atlas", worktreePath)
|
|
writeBoulderState(ctx.directory, newState)
|
|
|
|
contextInfo = `
|
|
## Auto-Selected Plan
|
|
|
|
**Plan**: ${getPlanName(matchedPlan)}
|
|
**Path**: ${matchedPlan}
|
|
**Progress**: ${progress.completed}/${progress.total} tasks
|
|
**Session ID**: ${sessionId}
|
|
**Started**: ${timestamp}
|
|
${worktreeBlock}
|
|
|
|
boulder.json has been created. Read the plan and begin execution.`
|
|
}
|
|
} else {
|
|
const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete)
|
|
if (incompletePlans.length > 0) {
|
|
const planList = incompletePlans
|
|
.map((p, i) => {
|
|
const prog = getPlanProgress(p)
|
|
return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}`
|
|
})
|
|
.join("\n")
|
|
|
|
contextInfo = `
|
|
## Plan Not Found
|
|
|
|
Could not find a plan matching "${explicitPlanName}".
|
|
|
|
Available incomplete plans:
|
|
${planList}
|
|
|
|
Ask the user which plan to work on.`
|
|
} else {
|
|
contextInfo = `
|
|
## Plan Not Found
|
|
|
|
Could not find a plan matching "${explicitPlanName}".
|
|
No incomplete plans available. Create a new plan with: /plan "your task"`
|
|
}
|
|
}
|
|
} else if (existingState) {
|
|
const progress = getPlanProgress(existingState.active_plan)
|
|
|
|
if (!progress.isComplete) {
|
|
const effectiveWorktree = worktreePath ?? existingState.worktree_path
|
|
|
|
if (worktreePath !== undefined) {
|
|
const updatedSessions = existingState.session_ids.includes(sessionId)
|
|
? existingState.session_ids
|
|
: [...existingState.session_ids, sessionId]
|
|
writeBoulderState(ctx.directory, {
|
|
...existingState,
|
|
worktree_path: worktreePath,
|
|
session_ids: updatedSessions,
|
|
})
|
|
} else {
|
|
appendSessionId(ctx.directory, sessionId)
|
|
}
|
|
|
|
const worktreeDisplay = effectiveWorktree ? createWorktreeActiveBlock(effectiveWorktree) : worktreeBlock
|
|
|
|
contextInfo = `
|
|
## Active Work Session Found
|
|
|
|
**Status**: RESUMING existing work
|
|
**Plan**: ${existingState.plan_name}
|
|
**Path**: ${existingState.active_plan}
|
|
**Progress**: ${progress.completed}/${progress.total} tasks completed
|
|
**Sessions**: ${existingState.session_ids.length + 1} (current session appended)
|
|
**Started**: ${existingState.started_at}
|
|
${worktreeDisplay}
|
|
|
|
The current session (${sessionId}) has been added to session_ids.
|
|
Read the plan file and continue from the first unchecked task.`
|
|
} else {
|
|
contextInfo = `
|
|
## Previous Work Complete
|
|
|
|
The previous plan (${existingState.plan_name}) has been completed.
|
|
Looking for new plans...`
|
|
}
|
|
}
|
|
|
|
if (
|
|
(!existingState && !explicitPlanName) ||
|
|
(existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)
|
|
) {
|
|
const plans = findPrometheusPlans(ctx.directory)
|
|
const incompletePlans = plans.filter((p) => !getPlanProgress(p).isComplete)
|
|
|
|
if (plans.length === 0) {
|
|
contextInfo += `
|
|
## No Plans Found
|
|
|
|
No Prometheus plan files found at .sisyphus/plans/
|
|
Use Prometheus to create a work plan first: /plan "your task"`
|
|
} else if (incompletePlans.length === 0) {
|
|
contextInfo += `
|
|
|
|
## All Plans Complete
|
|
|
|
All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your task"`
|
|
} else if (incompletePlans.length === 1) {
|
|
const planPath = incompletePlans[0]
|
|
const progress = getPlanProgress(planPath)
|
|
const newState = createBoulderState(planPath, sessionId, "atlas", worktreePath)
|
|
writeBoulderState(ctx.directory, newState)
|
|
|
|
contextInfo += `
|
|
|
|
## Auto-Selected Plan
|
|
|
|
**Plan**: ${getPlanName(planPath)}
|
|
**Path**: ${planPath}
|
|
**Progress**: ${progress.completed}/${progress.total} tasks
|
|
**Session ID**: ${sessionId}
|
|
**Started**: ${timestamp}
|
|
${worktreeBlock}
|
|
|
|
boulder.json has been created. Read the plan and begin execution.`
|
|
} else {
|
|
const planList = incompletePlans
|
|
.map((p, i) => {
|
|
const progress = getPlanProgress(p)
|
|
const modified = new Date(statSync(p).mtimeMs).toISOString()
|
|
return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}`
|
|
})
|
|
.join("\n")
|
|
|
|
contextInfo += `
|
|
|
|
<system-reminder>
|
|
## Multiple Plans Found
|
|
|
|
Current Time: ${timestamp}
|
|
Session ID: ${sessionId}
|
|
|
|
${planList}
|
|
|
|
Ask the user which plan to work on. Present the options above and wait for their response.
|
|
${worktreeBlock}
|
|
</system-reminder>`
|
|
}
|
|
}
|
|
|
|
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
|
|
if (idx >= 0 && output.parts[idx].text) {
|
|
output.parts[idx].text = output.parts[idx].text
|
|
.replace(/\$SESSION_ID/g, sessionId)
|
|
.replace(/\$TIMESTAMP/g, timestamp)
|
|
|
|
output.parts[idx].text += `\n\n---\n${contextInfo}`
|
|
}
|
|
|
|
log(`[${HOOK_NAME}] Context injected`, {
|
|
sessionID: input.sessionID,
|
|
hasExistingState: !!existingState,
|
|
worktreePath,
|
|
})
|
|
},
|
|
}
|
|
}
|