init claude-code
This commit is contained in:
@@ -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>)
|
||||
Reference in New Issue
Block a user