Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ linters:
gocognit:
min-complexity: 25

goconst:
# goconst 1.11 はパッケージ単位でカウントするため、テストの fixture 文字列が
# 大量に発砲する。テストのリテラル重複は可読性上むしろ望ましいので除外する。
# なお exclusions.rules では代替できない(カウントがパッケージ横断のため)。
ignore-tests: true
# boolean を文字列で持つ metadata (map[string]string) の比較値。定数化する価値がない。
ignore-string-values:
- '^(?:true|false)$'

dupl:
threshold: 100

Expand Down
62 changes: 62 additions & 0 deletions cmd/taskguild-agent/constants.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package main

// Claude Code tool names.
const (
toolBash = "Bash"
toolRead = "Read"
toolWrite = "Write"
toolEdit = "Edit"
toolGlob = "Glob"
toolGrep = "Grep"
toolWebSearch = "WebSearch"
toolWebFetch = "WebFetch"
toolNotebookEdit = "NotebookEdit"
toolTodoWrite = "TodoWrite"
toolAgent = "Agent"
toolSkill = "Skill"
toolAskUserQuestion = "AskUserQuestion"
toolExitPlanMode = "ExitPlanMode"
toolEnterPlanMode = "EnterPlanMode"
)

// Interaction option values exchanged with the frontend.
const (
optionAllow = "allow"
optionDeny = "deny"
optionAlwaysAllowCommand = "always_allow_command"
optionApprove = "approve"
optionReject = "reject"
)

// hookDecisionBlock is the Claude Code hook decision that stops a tool call
// (claudeagent.HookOutput.Decision).
const hookDecisionBlock = "block"

// .claude/settings.json permissions section keys.
const (
settingsKeyPermissions = "permissions"
settingsKeyAllow = "allow"
settingsKeyAsk = "ask"
settingsKeyDeny = "deny"
)

// Task / task-log metadata keys.
const (
metaClaudeMode = "claude_mode"
metaTurn = "turn"
metaDirectiveType = "directive_type"
metaFullText = "full_text"
metaResultType = "result_type"
)

// Anthropic content block field names and type values.
const (
blockFieldType = "type"
blockFieldText = "text"
blockTypeText = "text"
blockTypeImage = "image"
)

// msgContextCanceled is the PermissionResultDeny message used when the context
// is canceled while waiting for a user decision.
const msgContextCanceled = "context canceled"
22 changes: 9 additions & 13 deletions cmd/taskguild-agent/directive.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,15 +265,16 @@ func stripNextStatus(resultText string) string {
return strings.TrimSpace(strings.Join(filtered, "\n"))
}

// TASK_DESCRIPTION block markers emitted by the agent in its result text.
const (
startMarker = "TASK_DESCRIPTION_START"
endMarker = "TASK_DESCRIPTION_END"
)

// parseTaskDescription extracts a task description update from the result text.
// The description is enclosed between TASK_DESCRIPTION_START and TASK_DESCRIPTION_END markers.
// Returns the extracted description (trimmed) or empty string if no markers found.
func parseTaskDescription(resultText string) string {
const (
startMarker = "TASK_DESCRIPTION_START"
endMarker = "TASK_DESCRIPTION_END"
)

startIdx := strings.Index(resultText, startMarker)
if startIdx == -1 {
return ""
Expand All @@ -292,11 +293,6 @@ func parseTaskDescription(resultText string) string {
// stripTaskDescription removes the TASK_DESCRIPTION block from the result text
// so it doesn't clutter the reported summary.
func stripTaskDescription(resultText string) string {
const (
startMarker = "TASK_DESCRIPTION_START"
endMarker = "TASK_DESCRIPTION_END"
)

startIdx := strings.Index(resultText, startMarker)
if startIdx == -1 {
return resultText
Expand Down Expand Up @@ -527,7 +523,7 @@ func saveClaudeMode(ctx context.Context, taskClient taskguildv1connect.TaskServi

_, err := taskClient.UpdateTask(ctx, connect.NewRequest(&v1.UpdateTaskRequest{
Id: taskID,
Metadata: map[string]string{"claude_mode": mode},
Metadata: map[string]string{metaClaudeMode: mode},
}))
if err != nil {
logger.Error("failed to save claude_mode", "error", err)
Expand Down Expand Up @@ -560,8 +556,8 @@ func savePlanResult(ctx context.Context, taskID, content string, tl *taskLogger)
tl.Log(v1.TaskLogCategory_TASK_LOG_CATEGORY_RESULT, v1.TaskLogLevel_TASK_LOG_LEVEL_INFO,
preview,
map[string]string{
"full_text": content,
"result_type": "plan",
metaFullText: content,
metaResultType: "plan",
})
logger.Info("plan_result saved as log", "content_length", len(content))
}
Expand Down
49 changes: 25 additions & 24 deletions cmd/taskguild-agent/interaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"connectrpc.com/connect"

claudeagent "github.com/kazz187/claude-agent-sdk-go"
scp "github.com/kazz187/taskguild/internal/singlecommandpermission"
"github.com/kazz187/taskguild/pkg/clog"
"github.com/kazz187/taskguild/pkg/shellparse"
v1 "github.com/kazz187/taskguild/proto/gen/go/taskguild/v1"
Expand Down Expand Up @@ -263,18 +264,18 @@ func waitForUserResponse(

// readOnlyTools are always auto-allowed regardless of permission mode.
var readOnlyTools = map[string]bool{
"Read": true,
"Glob": true,
"Grep": true,
"WebSearch": true,
"WebFetch": true,
toolRead: true,
toolGlob: true,
toolGrep: true,
toolWebSearch: true,
toolWebFetch: true,
}

// editTools are auto-allowed in acceptEdits and bypassPermissions modes.
var editTools = map[string]bool{
"Edit": true,
"Write": true,
"NotebookEdit": true,
toolEdit: true,
toolWrite: true,
toolNotebookEdit: true,
}

// handleAskUserQuestion processes the AskUserQuestion tool by presenting each question
Expand Down Expand Up @@ -378,7 +379,7 @@ func handleAskUserQuestion(
select {
case <-ctx.Done():
waiter.Unregister(interactionID)
return claudeagent.PermissionResultDeny{Message: "context canceled"}, nil
return claudeagent.PermissionResultDeny{Message: msgContextCanceled}, nil
case inter := <-ch:
waiter.Unregister(interactionID)

Expand Down Expand Up @@ -410,7 +411,7 @@ func handleAskUserQuestion(
select {
case <-ctx.Done():
waiter.Unregister(followID)
return claudeagent.PermissionResultDeny{Message: "context canceled"}, nil
return claudeagent.PermissionResultDeny{Message: msgContextCanceled}, nil
case inter := <-followCh:
waiter.Unregister(followID)

Expand Down Expand Up @@ -458,7 +459,7 @@ func handlePermissionRequest(
// required to populate UpdatedInput["answers"]. Auto-allowing here would
// return empty answers and break the tool's contract — the agent would see
// `answers: {}` and have no way to know what the user wanted.
if toolName == "AskUserQuestion" {
if toolName == toolAskUserQuestion {
return handleAskUserQuestion(ctx, client, taskID, agentID, input, waiter)
}

Expand All @@ -484,7 +485,7 @@ func handlePermissionRequest(

// Plan mode tools: ExitPlanMode approval is handled by PreToolUse hook,
// EnterPlanMode is a safe mode switch — both skip permission requests.
if toolName == "ExitPlanMode" || toolName == "EnterPlanMode" {
if toolName == toolExitPlanMode || toolName == toolEnterPlanMode {
logger.Debug("auto-allowing plan mode tool", "tool", toolName)
return claudeagent.PermissionResultAllow{}, nil
}
Expand All @@ -493,7 +494,7 @@ func handlePermissionRequest(
// status's execution skills or a skill registered as a hook for the
// current status. These are skills TaskGuild itself has wired up for the
// task, so there is no user decision needed.
if toolName == "Skill" && len(statusSkills) > 0 {
if toolName == toolSkill && len(statusSkills) > 0 {
if skillRaw, ok := input["skill"]; ok {
if skillName, ok := skillRaw.(string); ok && statusSkills[skillName] {
logger.Debug("auto-allowing Skill tool (configured for status)", "skill", skillName)
Expand All @@ -513,7 +514,7 @@ func handlePermissionRequest(
// Single-command permission check for Bash tool.
var bashMeta *bashPermissionMetadata

if toolName == "Bash" && scpCache != nil {
if toolName == toolBash && scpCache != nil {
if cmdRaw, ok := input["command"]; ok {
if cmdStr, ok := cmdRaw.(string); ok && cmdStr != "" {
parsed := shellparse.Parse(cmdStr)
Expand All @@ -533,16 +534,16 @@ func handlePermissionRequest(

// Build interaction options based on tool type.
var options []*v1.InteractionOption
if toolName == "Bash" {
if toolName == toolBash {
options = []*v1.InteractionOption{
{Label: "Allow", Value: "allow", Description: "Allow this tool use"},
{Label: "Always Allow Command", Value: "always_allow_command", Description: "Allow and create rules for individual commands"},
{Label: "Deny", Value: "deny", Description: "Deny this tool use"},
{Label: "Allow", Value: optionAllow, Description: "Allow this tool use"},
{Label: "Always Allow Command", Value: optionAlwaysAllowCommand, Description: "Allow and create rules for individual commands"},
{Label: "Deny", Value: optionDeny, Description: "Deny this tool use"},
}
} else {
options = []*v1.InteractionOption{
{Label: "Allow", Value: "allow", Description: "Allow this tool use"},
{Label: "Deny", Value: "deny", Description: "Deny this tool use"},
{Label: "Allow", Value: optionAllow, Description: "Allow this tool use"},
{Label: "Deny", Value: optionDeny, Description: "Deny this tool use"},
}
}

Expand Down Expand Up @@ -576,7 +577,7 @@ func handlePermissionRequest(

select {
case <-ctx.Done():
return claudeagent.PermissionResultDeny{Message: "context canceled"}, nil
return claudeagent.PermissionResultDeny{Message: msgContextCanceled}, nil
case inter := <-ch:
if inter.GetStatus() == v1.InteractionStatus_INTERACTION_STATUS_EXPIRED {
logger.Info("permission request expired", "tool", toolName)
Expand All @@ -587,12 +588,12 @@ func handlePermissionRequest(

// Try to parse the response as JSON (always_allow_command from frontend).
var aacResp alwaysAllowCommandResponse
if json.Unmarshal([]byte(responseStr), &aacResp) == nil && aacResp.Action == "always_allow_command" {
if json.Unmarshal([]byte(responseStr), &aacResp) == nil && aacResp.Action == optionAlwaysAllowCommand {
return handleAlwaysAllowCommand(ctx, client, scpCache, aacResp.Rules, toolName, logger)
}

switch responseStr {
case "allow":
case optionAllow:
logger.Info("permission granted", "tool", toolName)
return claudeagent.PermissionResultAllow{}, nil
default:
Expand Down Expand Up @@ -633,7 +634,7 @@ func handleAlwaysAllowCommand(

ruleType := rule.Type
if ruleType == "" {
ruleType = "command"
ruleType = scp.TypeCommand
}

_, err := client.AddSingleCommandPermission(ctx, connect.NewRequest(&v1.AddSingleCommandPermissionRequest{
Expand Down
2 changes: 1 addition & 1 deletion cmd/taskguild-agent/permission_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ func matchPermissionRule(rule string, toolName string, input map[string]any) boo
}

// For Bash tools, match the command input against the pattern.
if toolName == "Bash" {
if toolName == toolBash {
cmd, _ := input["command"].(string)
return matchGlob(rPattern, cmd)
}
Expand Down
24 changes: 12 additions & 12 deletions cmd/taskguild-agent/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,8 @@ func buildUserPromptWithImages(ctx context.Context, metadata map[string]string,
textBefore := textPrompt[lastEnd:fullStart]
if strings.TrimSpace(textBefore) != "" {
blocks = append(blocks, map[string]any{
"type": "text",
"text": textBefore,
blockFieldType: blockTypeText,
blockFieldText: textBefore,
})
}
}
Expand All @@ -136,25 +136,25 @@ func buildUserPromptWithImages(ctx context.Context, metadata map[string]string,
}))
if err == nil {
blocks = append(blocks, map[string]any{
"type": "image",
blockFieldType: blockTypeImage,
"source": map[string]any{
"type": "base64",
"media_type": imgResp.Msg.GetImage().GetMediaType(),
"data": base64.StdEncoding.EncodeToString(imgResp.Msg.GetData()),
blockFieldType: "base64",
"media_type": imgResp.Msg.GetImage().GetMediaType(),
"data": base64.StdEncoding.EncodeToString(imgResp.Msg.GetData()),
},
})
} else {
// Keep the reference as text if fetch fails.
blocks = append(blocks, map[string]any{
"type": "text",
"text": textPrompt[fullStart:fullEnd],
blockFieldType: blockTypeText,
blockFieldText: textPrompt[fullStart:fullEnd],
})
}
} else {
// Image not found — keep the reference as text.
blocks = append(blocks, map[string]any{
"type": "text",
"text": textPrompt[fullStart:fullEnd],
blockFieldType: blockTypeText,
blockFieldText: textPrompt[fullStart:fullEnd],
})
}

Expand All @@ -166,8 +166,8 @@ func buildUserPromptWithImages(ctx context.Context, metadata map[string]string,
remaining := textPrompt[lastEnd:]
if strings.TrimSpace(remaining) != "" {
blocks = append(blocks, map[string]any{
"type": "text",
"text": remaining,
blockFieldType: blockTypeText,
blockFieldText: remaining,
})
}
}
Expand Down
Loading