chore: initialize recovered claude workspace
This commit is contained in:
406
src/tools/TaskUpdateTool/TaskUpdateTool.ts
Normal file
406
src/tools/TaskUpdateTool/TaskUpdateTool.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { z } from 'zod/v4'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
||||
import { buildTool, type ToolDef } from '../../Tool.js'
|
||||
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'
|
||||
import {
|
||||
executeTaskCompletedHooks,
|
||||
getTaskCompletedHookMessage,
|
||||
} from '../../utils/hooks.js'
|
||||
import { lazySchema } from '../../utils/lazySchema.js'
|
||||
import {
|
||||
blockTask,
|
||||
deleteTask,
|
||||
getTask,
|
||||
getTaskListId,
|
||||
isTodoV2Enabled,
|
||||
listTasks,
|
||||
type TaskStatus,
|
||||
TaskStatusSchema,
|
||||
updateTask,
|
||||
} from '../../utils/tasks.js'
|
||||
import {
|
||||
getAgentId,
|
||||
getAgentName,
|
||||
getTeammateColor,
|
||||
getTeamName,
|
||||
} from '../../utils/teammate.js'
|
||||
import { writeToMailbox } from '../../utils/teammateMailbox.js'
|
||||
import { VERIFICATION_AGENT_TYPE } from '../AgentTool/constants.js'
|
||||
import { TASK_UPDATE_TOOL_NAME } from './constants.js'
|
||||
import { DESCRIPTION, PROMPT } from './prompt.js'
|
||||
|
||||
const inputSchema = lazySchema(() => {
|
||||
// Extended status schema that includes 'deleted' as a special action
|
||||
const TaskUpdateStatusSchema = TaskStatusSchema().or(z.literal('deleted'))
|
||||
|
||||
return z.strictObject({
|
||||
taskId: z.string().describe('The ID of the task to update'),
|
||||
subject: z.string().optional().describe('New subject for the task'),
|
||||
description: z.string().optional().describe('New description for the task'),
|
||||
activeForm: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Present continuous form shown in spinner when in_progress (e.g., "Running tests")',
|
||||
),
|
||||
status: TaskUpdateStatusSchema.optional().describe(
|
||||
'New status for the task',
|
||||
),
|
||||
addBlocks: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Task IDs that this task blocks'),
|
||||
addBlockedBy: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Task IDs that block this task'),
|
||||
owner: z.string().optional().describe('New owner for the task'),
|
||||
metadata: z
|
||||
.record(z.string(), z.unknown())
|
||||
.optional()
|
||||
.describe(
|
||||
'Metadata keys to merge into the task. Set a key to null to delete it.',
|
||||
),
|
||||
})
|
||||
})
|
||||
type InputSchema = ReturnType<typeof inputSchema>
|
||||
|
||||
const outputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
success: z.boolean(),
|
||||
taskId: z.string(),
|
||||
updatedFields: z.array(z.string()),
|
||||
error: z.string().optional(),
|
||||
statusChange: z
|
||||
.object({
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
verificationNudgeNeeded: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
type OutputSchema = ReturnType<typeof outputSchema>
|
||||
|
||||
export type Output = z.infer<OutputSchema>
|
||||
|
||||
export const TaskUpdateTool = buildTool({
|
||||
name: TASK_UPDATE_TOOL_NAME,
|
||||
searchHint: 'update a task',
|
||||
maxResultSizeChars: 100_000,
|
||||
async description() {
|
||||
return DESCRIPTION
|
||||
},
|
||||
async prompt() {
|
||||
return PROMPT
|
||||
},
|
||||
get inputSchema(): InputSchema {
|
||||
return inputSchema()
|
||||
},
|
||||
get outputSchema(): OutputSchema {
|
||||
return outputSchema()
|
||||
},
|
||||
userFacingName() {
|
||||
return 'TaskUpdate'
|
||||
},
|
||||
shouldDefer: true,
|
||||
isEnabled() {
|
||||
return isTodoV2Enabled()
|
||||
},
|
||||
isConcurrencySafe() {
|
||||
return true
|
||||
},
|
||||
toAutoClassifierInput(input) {
|
||||
const parts = [input.taskId]
|
||||
if (input.status) parts.push(input.status)
|
||||
if (input.subject) parts.push(input.subject)
|
||||
return parts.join(' ')
|
||||
},
|
||||
renderToolUseMessage() {
|
||||
return null
|
||||
},
|
||||
async call(
|
||||
{
|
||||
taskId,
|
||||
subject,
|
||||
description,
|
||||
activeForm,
|
||||
status,
|
||||
owner,
|
||||
addBlocks,
|
||||
addBlockedBy,
|
||||
metadata,
|
||||
},
|
||||
context,
|
||||
) {
|
||||
const taskListId = getTaskListId()
|
||||
|
||||
// Auto-expand task list when updating tasks
|
||||
context.setAppState(prev => {
|
||||
if (prev.expandedView === 'tasks') return prev
|
||||
return { ...prev, expandedView: 'tasks' as const }
|
||||
})
|
||||
|
||||
// Check if task exists
|
||||
const existingTask = await getTask(taskListId, taskId)
|
||||
if (!existingTask) {
|
||||
return {
|
||||
data: {
|
||||
success: false,
|
||||
taskId,
|
||||
updatedFields: [],
|
||||
error: 'Task not found',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const updatedFields: string[] = []
|
||||
|
||||
// Update basic fields if provided and different from current value
|
||||
const updates: {
|
||||
subject?: string
|
||||
description?: string
|
||||
activeForm?: string
|
||||
status?: TaskStatus
|
||||
owner?: string
|
||||
metadata?: Record<string, unknown>
|
||||
} = {}
|
||||
if (subject !== undefined && subject !== existingTask.subject) {
|
||||
updates.subject = subject
|
||||
updatedFields.push('subject')
|
||||
}
|
||||
if (description !== undefined && description !== existingTask.description) {
|
||||
updates.description = description
|
||||
updatedFields.push('description')
|
||||
}
|
||||
if (activeForm !== undefined && activeForm !== existingTask.activeForm) {
|
||||
updates.activeForm = activeForm
|
||||
updatedFields.push('activeForm')
|
||||
}
|
||||
if (owner !== undefined && owner !== existingTask.owner) {
|
||||
updates.owner = owner
|
||||
updatedFields.push('owner')
|
||||
}
|
||||
// Auto-set owner when a teammate marks a task as in_progress without
|
||||
// explicitly providing an owner. This ensures the task list can match
|
||||
// todo items to teammates for showing activity status.
|
||||
if (
|
||||
isAgentSwarmsEnabled() &&
|
||||
status === 'in_progress' &&
|
||||
owner === undefined &&
|
||||
!existingTask.owner
|
||||
) {
|
||||
const agentName = getAgentName()
|
||||
if (agentName) {
|
||||
updates.owner = agentName
|
||||
updatedFields.push('owner')
|
||||
}
|
||||
}
|
||||
if (metadata !== undefined) {
|
||||
const merged = { ...(existingTask.metadata ?? {}) }
|
||||
for (const [key, value] of Object.entries(metadata)) {
|
||||
if (value === null) {
|
||||
delete merged[key]
|
||||
} else {
|
||||
merged[key] = value
|
||||
}
|
||||
}
|
||||
updates.metadata = merged
|
||||
updatedFields.push('metadata')
|
||||
}
|
||||
if (status !== undefined) {
|
||||
// Handle deletion - delete the task file and return early
|
||||
if (status === 'deleted') {
|
||||
const deleted = await deleteTask(taskListId, taskId)
|
||||
return {
|
||||
data: {
|
||||
success: deleted,
|
||||
taskId,
|
||||
updatedFields: deleted ? ['deleted'] : [],
|
||||
error: deleted ? undefined : 'Failed to delete task',
|
||||
statusChange: deleted
|
||||
? { from: existingTask.status, to: 'deleted' }
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// For regular status updates, validate and apply if different
|
||||
if (status !== existingTask.status) {
|
||||
// Run TaskCompleted hooks when marking a task as completed
|
||||
if (status === 'completed') {
|
||||
const blockingErrors: string[] = []
|
||||
|
||||
const generator = executeTaskCompletedHooks(
|
||||
taskId,
|
||||
existingTask.subject,
|
||||
existingTask.description,
|
||||
getAgentName(),
|
||||
getTeamName(),
|
||||
undefined,
|
||||
context?.abortController?.signal,
|
||||
undefined,
|
||||
context,
|
||||
)
|
||||
|
||||
for await (const result of generator) {
|
||||
if (result.blockingError) {
|
||||
blockingErrors.push(
|
||||
getTaskCompletedHookMessage(result.blockingError),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (blockingErrors.length > 0) {
|
||||
return {
|
||||
data: {
|
||||
success: false,
|
||||
taskId,
|
||||
updatedFields: [],
|
||||
error: blockingErrors.join('\n'),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updates.status = status
|
||||
updatedFields.push('status')
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await updateTask(taskListId, taskId, updates)
|
||||
}
|
||||
|
||||
// Notify new owner via mailbox when ownership changes
|
||||
if (updates.owner && isAgentSwarmsEnabled()) {
|
||||
const senderName = getAgentName() || 'team-lead'
|
||||
const senderColor = getTeammateColor()
|
||||
const assignmentMessage = JSON.stringify({
|
||||
type: 'task_assignment',
|
||||
taskId,
|
||||
subject: existingTask.subject,
|
||||
description: existingTask.description,
|
||||
assignedBy: senderName,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
await writeToMailbox(
|
||||
updates.owner,
|
||||
{
|
||||
from: senderName,
|
||||
text: assignmentMessage,
|
||||
timestamp: new Date().toISOString(),
|
||||
color: senderColor,
|
||||
},
|
||||
taskListId,
|
||||
)
|
||||
}
|
||||
|
||||
// Add blocks if provided and not already present
|
||||
if (addBlocks && addBlocks.length > 0) {
|
||||
const newBlocks = addBlocks.filter(
|
||||
id => !existingTask.blocks.includes(id),
|
||||
)
|
||||
for (const blockId of newBlocks) {
|
||||
await blockTask(taskListId, taskId, blockId)
|
||||
}
|
||||
if (newBlocks.length > 0) {
|
||||
updatedFields.push('blocks')
|
||||
}
|
||||
}
|
||||
|
||||
// Add blockedBy if provided and not already present (reverse: the blocker blocks this task)
|
||||
if (addBlockedBy && addBlockedBy.length > 0) {
|
||||
const newBlockedBy = addBlockedBy.filter(
|
||||
id => !existingTask.blockedBy.includes(id),
|
||||
)
|
||||
for (const blockerId of newBlockedBy) {
|
||||
await blockTask(taskListId, blockerId, taskId)
|
||||
}
|
||||
if (newBlockedBy.length > 0) {
|
||||
updatedFields.push('blockedBy')
|
||||
}
|
||||
}
|
||||
|
||||
// Structural verification nudge: if the main-thread agent just closed
|
||||
// out a 3+ task list and none of those tasks was a verification step,
|
||||
// append a reminder to the tool result. Fires at the loop-exit moment
|
||||
// where skips happen ("when the last task closed, the loop exited").
|
||||
// Mirrors the TodoWriteTool nudge for V1 sessions; this covers V2
|
||||
// (interactive CLI). TaskUpdateToolOutput is @internal so this field
|
||||
// does not touch the public SDK surface.
|
||||
let verificationNudgeNeeded = false
|
||||
if (
|
||||
feature('VERIFICATION_AGENT') &&
|
||||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_hive_evidence', false) &&
|
||||
!context.agentId &&
|
||||
updates.status === 'completed'
|
||||
) {
|
||||
const allTasks = await listTasks(taskListId)
|
||||
const allDone = allTasks.every(t => t.status === 'completed')
|
||||
if (
|
||||
allDone &&
|
||||
allTasks.length >= 3 &&
|
||||
!allTasks.some(t => /verif/i.test(t.subject))
|
||||
) {
|
||||
verificationNudgeNeeded = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
success: true,
|
||||
taskId,
|
||||
updatedFields,
|
||||
statusChange:
|
||||
updates.status !== undefined
|
||||
? { from: existingTask.status, to: updates.status }
|
||||
: undefined,
|
||||
verificationNudgeNeeded,
|
||||
},
|
||||
}
|
||||
},
|
||||
mapToolResultToToolResultBlockParam(content, toolUseID) {
|
||||
const {
|
||||
success,
|
||||
taskId,
|
||||
updatedFields,
|
||||
error,
|
||||
statusChange,
|
||||
verificationNudgeNeeded,
|
||||
} = content as Output
|
||||
if (!success) {
|
||||
// Return as non-error so it doesn't trigger sibling tool cancellation
|
||||
// in StreamingToolExecutor. "Task not found" is a benign condition
|
||||
// (e.g., task list already cleaned up) that the model can handle.
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result',
|
||||
content: error || `Task #${taskId} not found`,
|
||||
}
|
||||
}
|
||||
|
||||
let resultContent = `Updated task #${taskId} ${updatedFields.join(', ')}`
|
||||
|
||||
// Add reminder for teammates when they complete a task (supports in-process teammates)
|
||||
if (
|
||||
statusChange?.to === 'completed' &&
|
||||
getAgentId() &&
|
||||
isAgentSwarmsEnabled()
|
||||
) {
|
||||
resultContent +=
|
||||
'\n\nTask completed. Call TaskList now to find your next available task or see if your work unblocked others.'
|
||||
}
|
||||
|
||||
if (verificationNudgeNeeded) {
|
||||
resultContent += `\n\nNOTE: You just closed out 3+ tasks and none of them was a verification step. Before writing your final summary, spawn the verification agent (subagent_type="${VERIFICATION_AGENT_TYPE}"). You cannot self-assign PARTIAL by listing caveats in your summary — only the verifier issues a verdict.`
|
||||
}
|
||||
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result',
|
||||
content: resultContent,
|
||||
}
|
||||
},
|
||||
} satisfies ToolDef<InputSchema, Output>)
|
||||
1
src/tools/TaskUpdateTool/constants.ts
Normal file
1
src/tools/TaskUpdateTool/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const TASK_UPDATE_TOOL_NAME = 'TaskUpdate'
|
||||
77
src/tools/TaskUpdateTool/prompt.ts
Normal file
77
src/tools/TaskUpdateTool/prompt.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export const DESCRIPTION = 'Update a task in the task list'
|
||||
|
||||
export const PROMPT = `Use this tool to update a task in the task list.
|
||||
|
||||
## When to Use This Tool
|
||||
|
||||
**Mark tasks as resolved:**
|
||||
- When you have completed the work described in a task
|
||||
- When a task is no longer needed or has been superseded
|
||||
- IMPORTANT: Always mark your assigned tasks as resolved when you finish them
|
||||
- After resolving, call TaskList to find your next task
|
||||
|
||||
- ONLY mark a task as completed when you have FULLY accomplished it
|
||||
- If you encounter errors, blockers, or cannot finish, keep the task as in_progress
|
||||
- When blocked, create a new task describing what needs to be resolved
|
||||
- Never mark a task as completed if:
|
||||
- Tests are failing
|
||||
- Implementation is partial
|
||||
- You encountered unresolved errors
|
||||
- You couldn't find necessary files or dependencies
|
||||
|
||||
**Delete tasks:**
|
||||
- When a task is no longer relevant or was created in error
|
||||
- Setting status to \`deleted\` permanently removes the task
|
||||
|
||||
**Update task details:**
|
||||
- When requirements change or become clearer
|
||||
- When establishing dependencies between tasks
|
||||
|
||||
## Fields You Can Update
|
||||
|
||||
- **status**: The task status (see Status Workflow below)
|
||||
- **subject**: Change the task title (imperative form, e.g., "Run tests")
|
||||
- **description**: Change the task description
|
||||
- **activeForm**: Present continuous form shown in spinner when in_progress (e.g., "Running tests")
|
||||
- **owner**: Change the task owner (agent name)
|
||||
- **metadata**: Merge metadata keys into the task (set a key to null to delete it)
|
||||
- **addBlocks**: Mark tasks that cannot start until this one completes
|
||||
- **addBlockedBy**: Mark tasks that must complete before this one can start
|
||||
|
||||
## Status Workflow
|
||||
|
||||
Status progresses: \`pending\` → \`in_progress\` → \`completed\`
|
||||
|
||||
Use \`deleted\` to permanently remove a task.
|
||||
|
||||
## Staleness
|
||||
|
||||
Make sure to read a task's latest state using \`TaskGet\` before updating it.
|
||||
|
||||
## Examples
|
||||
|
||||
Mark task as in progress when starting work:
|
||||
\`\`\`json
|
||||
{"taskId": "1", "status": "in_progress"}
|
||||
\`\`\`
|
||||
|
||||
Mark task as completed after finishing work:
|
||||
\`\`\`json
|
||||
{"taskId": "1", "status": "completed"}
|
||||
\`\`\`
|
||||
|
||||
Delete a task:
|
||||
\`\`\`json
|
||||
{"taskId": "1", "status": "deleted"}
|
||||
\`\`\`
|
||||
|
||||
Claim a task by setting owner:
|
||||
\`\`\`json
|
||||
{"taskId": "1", "owner": "my-name"}
|
||||
\`\`\`
|
||||
|
||||
Set up task dependencies:
|
||||
\`\`\`json
|
||||
{"taskId": "2", "addBlockedBy": ["1"]}
|
||||
\`\`\`
|
||||
`
|
||||
Reference in New Issue
Block a user