From 4ceb4d1ddebd00b2d9b5843005054e3c398e4876 Mon Sep 17 00:00:00 2001 From: kazz187 Date: Sat, 22 Aug 2026 13:03:07 +0900 Subject: [PATCH] refactor: resolve goconst findings via constants and parser unification golangci-lint 2.13.1 upgraded goconst to 1.11.0, which widened repeated-literal counting from file scope to package scope (221 findings, up from 21). These are legitimate detections rather than a lapsed config, so resolve them as a real refactor instead of suppressing them. - .golangci.yml: ignore-tests (test fixture duplication is desirable) and skip "true"/"false", the stringly-typed metadata booleans. Thresholds unchanged. - internal/claudemd: new package unifying the four duplicated frontmatter parsers (skill/agent x file/string). The string-based agent parser now also handles block scalars, list-form tools:, and indented keys. - internal/eventbus/metadata.go: event metadata keys as shared constants. These values are a wire contract with the frontend, so they are unchanged. - cmd/taskguild-agent/constants.go: tool names, interaction option values, hook decisions, settings.json keys, metadata keys and content block fields. - pkg/clog, internal/template, internal/project: log attribute keys, entity types and seeded workflow status names. goconst 221 -> 0; total lint findings 2126 -> 1873 with no linter above baseline. Every literal-to-constant substitution was verified to resolve back to the identical string. --- .golangci.yml | 9 + cmd/taskguild-agent/constants.go | 62 +++ cmd/taskguild-agent/directive.go | 22 +- cmd/taskguild-agent/interaction.go | 49 +- cmd/taskguild-agent/permission_cache.go | 2 +- cmd/taskguild-agent/prompt.go | 24 +- cmd/taskguild-agent/runner.go | 30 +- .../single_command_permission_cache.go | 5 +- cmd/taskguild-agent/sync_permissions.go | 16 +- cmd/taskguild-agent/tool_description.go | 12 +- cmd/taskguild-agent/toolhooks.go | 58 +- internal/agent/server.go | 231 +------- internal/agentmanager/agent_conflict.go | 115 +--- internal/agentmanager/agent_sync.go | 7 +- internal/agentmanager/git_handler.go | 11 +- internal/agentmanager/interaction_handler.go | 3 +- internal/agentmanager/script_conflict.go | 7 +- internal/agentmanager/script_handler.go | 13 +- internal/agentmanager/skill_conflict.go | 125 +---- internal/agentmanager/task_handler.go | 31 +- internal/agentmanager/task_log_handler.go | 3 +- internal/agentmanager/worktree_handler.go | 15 +- internal/chatnotifier/notifier.go | 4 +- internal/claudemd/claudemd.go | 296 ++++++++++ internal/claudemd/claudemd_test.go | 525 ++++++++++++++++++ internal/event/server.go | 2 +- internal/eventbus/bus.go | 2 + internal/eventbus/metadata.go | 29 + internal/interaction/server.go | 10 +- internal/orchestrator/orchestrator.go | 2 +- internal/project/seeder.go | 39 +- internal/schedule/server_test.go | 4 + internal/skill/server.go | 210 +------ internal/task/server.go | 30 +- internal/tasklog/description_logger.go | 2 +- internal/template/entity.go | 9 + .../repositoryimpl/yaml_repository.go | 6 +- internal/template/server.go | 34 +- pkg/clog/chi.go | 12 +- pkg/clog/connect.go | 22 +- pkg/clog/connect_text.go | 6 +- pkg/clog/context.go | 11 + pkg/clog/http_text.go | 2 +- 43 files changed, 1229 insertions(+), 878 deletions(-) create mode 100644 cmd/taskguild-agent/constants.go create mode 100644 internal/claudemd/claudemd.go create mode 100644 internal/claudemd/claudemd_test.go create mode 100644 internal/eventbus/metadata.go diff --git a/.golangci.yml b/.golangci.yml index 61f018e8..08bf83ec 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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 diff --git a/cmd/taskguild-agent/constants.go b/cmd/taskguild-agent/constants.go new file mode 100644 index 00000000..ae76ebf9 --- /dev/null +++ b/cmd/taskguild-agent/constants.go @@ -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" diff --git a/cmd/taskguild-agent/directive.go b/cmd/taskguild-agent/directive.go index a7804c11..3363a02d 100644 --- a/cmd/taskguild-agent/directive.go +++ b/cmd/taskguild-agent/directive.go @@ -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 "" @@ -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 @@ -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) @@ -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)) } diff --git a/cmd/taskguild-agent/interaction.go b/cmd/taskguild-agent/interaction.go index 4bcb2f18..8acaee9f 100644 --- a/cmd/taskguild-agent/interaction.go +++ b/cmd/taskguild-agent/interaction.go @@ -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" @@ -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 @@ -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) @@ -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) @@ -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) } @@ -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 } @@ -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) @@ -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) @@ -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"}, } } @@ -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) @@ -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: @@ -633,7 +634,7 @@ func handleAlwaysAllowCommand( ruleType := rule.Type if ruleType == "" { - ruleType = "command" + ruleType = scp.TypeCommand } _, err := client.AddSingleCommandPermission(ctx, connect.NewRequest(&v1.AddSingleCommandPermissionRequest{ diff --git a/cmd/taskguild-agent/permission_cache.go b/cmd/taskguild-agent/permission_cache.go index 8850be67..8d081776 100644 --- a/cmd/taskguild-agent/permission_cache.go +++ b/cmd/taskguild-agent/permission_cache.go @@ -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) } diff --git a/cmd/taskguild-agent/prompt.go b/cmd/taskguild-agent/prompt.go index 57fab94e..2ee0ee65 100644 --- a/cmd/taskguild-agent/prompt.go +++ b/cmd/taskguild-agent/prompt.go @@ -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, }) } } @@ -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], }) } @@ -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, }) } } diff --git a/cmd/taskguild-agent/runner.go b/cmd/taskguild-agent/runner.go index 938dc810..c3287524 100644 --- a/cmd/taskguild-agent/runner.go +++ b/cmd/taskguild-agent/runner.go @@ -248,7 +248,7 @@ func runTask( tl.Log(v1.TaskLogCategory_TASK_LOG_CATEGORY_TURN_START, v1.TaskLogLevel_TASK_LOG_LEVEL_INFO, fmt.Sprintf("Turn %d started", turn), - map[string]string{"turn": strconv.Itoa(turn), "claude_mode": turnMode}) + map[string]string{metaTurn: strconv.Itoa(turn), metaClaudeMode: turnMode}) logger.Info("starting Claude CLI", "turn", turn, "session_id", sessionID, "claude_mode", turnMode) logger.Debug("Claude SDK input", "turn", turn) @@ -301,11 +301,11 @@ func runTask( if err != nil { tl.Log(v1.TaskLogCategory_TASK_LOG_CATEGORY_TURN_END, v1.TaskLogLevel_TASK_LOG_LEVEL_ERROR, fmt.Sprintf("Turn %d error: %v", turn, err), - map[string]string{"turn": strconv.Itoa(turn), "claude_mode": endMode}) + map[string]string{metaTurn: strconv.Itoa(turn), metaClaudeMode: endMode}) } else { tl.Log(v1.TaskLogCategory_TASK_LOG_CATEGORY_TURN_END, v1.TaskLogLevel_TASK_LOG_LEVEL_INFO, fmt.Sprintf("Turn %d completed", turn), - map[string]string{"turn": strconv.Itoa(turn), "claude_mode": endMode}) + map[string]string{metaTurn: strconv.Itoa(turn), metaClaudeMode: endMode}) } // Save session ID for resume. @@ -450,8 +450,8 @@ func runTask( tl.Log(v1.TaskLogCategory_TASK_LOG_CATEGORY_DIRECTIVE, v1.TaskLogLevel_TASK_LOG_LEVEL_INFO, "Task description updated", map[string]string{ - "directive_type": "TASK_DESCRIPTION", - "turn": strconv.Itoa(turn), + metaDirectiveType: "TASK_DESCRIPTION", + metaTurn: strconv.Itoa(turn), }) // Emit a RESULT log so description updates appear in the chronological results timeline. descPreview := newDesc @@ -462,8 +462,8 @@ func runTask( tl.Log(v1.TaskLogCategory_TASK_LOG_CATEGORY_RESULT, v1.TaskLogLevel_TASK_LOG_LEVEL_INFO, descPreview, map[string]string{ - "full_text": newDesc, - "result_type": "description", + metaFullText: newDesc, + metaResultType: "description", }) } } @@ -478,9 +478,9 @@ func runTask( tl.Log(v1.TaskLogCategory_TASK_LOG_CATEGORY_DIRECTIVE, v1.TaskLogLevel_TASK_LOG_LEVEL_INFO, "Task created: "+d.Title, map[string]string{ - "directive_type": "CREATE_TASK", - "task_title": d.Title, - "turn": strconv.Itoa(turn), + metaDirectiveType: "CREATE_TASK", + "task_title": d.Title, + metaTurn: strconv.Itoa(turn), }) } } @@ -517,8 +517,8 @@ func runTask( tl.Log(v1.TaskLogCategory_TASK_LOG_CATEGORY_AGENT_OUTPUT, v1.TaskLogLevel_TASK_LOG_LEVEL_INFO, preview, map[string]string{ - "full_text": fullText, - "turn": strconv.Itoa(turn), + metaFullText: fullText, + metaTurn: strconv.Itoa(turn), }) } } @@ -532,9 +532,9 @@ func runTask( tl.Log(v1.TaskLogCategory_TASK_LOG_CATEGORY_DIRECTIVE, v1.TaskLogLevel_TASK_LOG_LEVEL_INFO, "Status transition: "+nextStatusID, map[string]string{ - "directive_type": "NEXT_STATUS", - "next_status": nextStatusID, - "turn": strconv.Itoa(turn), + metaDirectiveType: "NEXT_STATUS", + "next_status": nextStatusID, + metaTurn: strconv.Itoa(turn), }) // Validate the transition before reporting completion. diff --git a/cmd/taskguild-agent/single_command_permission_cache.go b/cmd/taskguild-agent/single_command_permission_cache.go index 28c487a0..de0c7ac3 100644 --- a/cmd/taskguild-agent/single_command_permission_cache.go +++ b/cmd/taskguild-agent/single_command_permission_cache.go @@ -10,6 +10,7 @@ import ( "connectrpc.com/connect" + scp "github.com/kazz187/taskguild/internal/singlecommandpermission" "github.com/kazz187/taskguild/pkg/shellparse" v1 "github.com/kazz187/taskguild/proto/gen/go/taskguild/v1" "github.com/kazz187/taskguild/proto/gen/go/taskguild/v1/taskguildv1connect" @@ -133,7 +134,7 @@ func (c *singleCommandPermissionCache) CheckCommand(command string) (matched boo defer c.mu.RUnlock() for _, p := range c.patterns { - if p.ptype != "command" { + if p.ptype != scp.TypeCommand { continue } @@ -151,7 +152,7 @@ func (c *singleCommandPermissionCache) CheckRedirect(path string) (matched bool, defer c.mu.RUnlock() for _, p := range c.patterns { - if p.ptype != "redirect" { + if p.ptype != scp.TypeRedirect { continue } diff --git a/cmd/taskguild-agent/sync_permissions.go b/cmd/taskguild-agent/sync_permissions.go index 36255174..aa6a5796 100644 --- a/cmd/taskguild-agent/sync_permissions.go +++ b/cmd/taskguild-agent/sync_permissions.go @@ -72,7 +72,7 @@ func readLocalPermissions(path string) (allow, ask, deny []string, raw map[strin return nil, nil, nil, raw } - permsRaw, ok := raw["permissions"] + permsRaw, ok := raw[settingsKeyPermissions] if !ok { return nil, nil, nil, raw } @@ -82,9 +82,9 @@ func readLocalPermissions(path string) (allow, ask, deny []string, raw map[strin return nil, nil, nil, raw } - allow = toStringSlice(permsMap["allow"]) - ask = toStringSlice(permsMap["ask"]) - deny = toStringSlice(permsMap["deny"]) + allow = toStringSlice(permsMap[settingsKeyAllow]) + ask = toStringSlice(permsMap[settingsKeyAsk]) + deny = toStringSlice(permsMap[settingsKeyDeny]) return allow, ask, deny, raw } @@ -121,10 +121,10 @@ func writeLocalPermissions(path string, raw map[string]any, merged *v1.Permissio } // Update only the permissions section. - raw["permissions"] = map[string]any{ - "allow": allowList, - "ask": askList, - "deny": denyList, + raw[settingsKeyPermissions] = map[string]any{ + settingsKeyAllow: allowList, + settingsKeyAsk: askList, + settingsKeyDeny: denyList, } data, err := json.MarshalIndent(raw, "", " ") diff --git a/cmd/taskguild-agent/tool_description.go b/cmd/taskguild-agent/tool_description.go index 1ca8ba90..d8f2fc60 100644 --- a/cmd/taskguild-agent/tool_description.go +++ b/cmd/taskguild-agent/tool_description.go @@ -96,7 +96,7 @@ func formatToolDescription(toolName string, input map[string]any) string { var sb strings.Builder switch toolName { - case "Bash": + case toolBash: sb.WriteString("**Tool:** `Bash`\n") if desc := str("description"); desc != "" { @@ -118,7 +118,7 @@ func formatToolDescription(toolName string, input map[string]any) string { fmt.Fprintf(&sb, "\n%s\n", fence) } - case "Edit": + case toolEdit: sb.WriteString("**Tool:** `Edit`\n") filePath := str("file_path") @@ -149,7 +149,7 @@ func formatToolDescription(toolName string, input map[string]any) string { sb.WriteString(fence + "\n") } - case "Write": + case toolWrite: sb.WriteString("**Tool:** `Write`\n") filePath := str("file_path") @@ -165,14 +165,14 @@ func formatToolDescription(toolName string, input map[string]any) string { fmt.Fprintf(&sb, "\n%s\n", fence) } - case "Read": + case toolRead: sb.WriteString("**Tool:** `Read`\n") if filePath := str("file_path"); filePath != "" { fmt.Fprintf(&sb, "**File:** `%s`\n", filePath) } - case "Glob": + case toolGlob: sb.WriteString("**Tool:** `Glob`\n") if pattern := str("pattern"); pattern != "" { @@ -183,7 +183,7 @@ func formatToolDescription(toolName string, input map[string]any) string { fmt.Fprintf(&sb, "**Path:** `%s`\n", path) } - case "Grep": + case toolGrep: sb.WriteString("**Tool:** `Grep`\n") if pattern := str("pattern"); pattern != "" { diff --git a/cmd/taskguild-agent/toolhooks.go b/cmd/taskguild-agent/toolhooks.go index 3c0b8371..44e14fb5 100644 --- a/cmd/taskguild-agent/toolhooks.go +++ b/cmd/taskguild-agent/toolhooks.go @@ -47,7 +47,7 @@ func buildToolUseHooks( // fork a new Claude session. This catches AI-side // loops (e.g. codex:rescue calling itself // recursively) and avoids runaway token consumption. - if input.ToolName == "Skill" && loopGuard != nil { + if input.ToolName == toolSkill && loopGuard != nil { skillName, _ := input.ToolInput["skill"].(string) if skillName != "" { block, reason := loopGuard.CheckAndRegister(input.ToolUseID, skillName, input.ToolInput) @@ -59,14 +59,14 @@ func buildToolUseHooks( "reason", reason) return claudeagent.HookOutput{ - Decision: "block", + Decision: hookDecisionBlock, Reason: reason, }, nil } } } - if input.ToolName != "ExitPlanMode" { + if input.ToolName != toolExitPlanMode { return claudeagent.HookOutput{}, nil } @@ -82,7 +82,7 @@ func buildToolUseHooks( func(input claudeagent.HookInput, toolUseID string, ctx claudeagent.HookContext) (claudeagent.HookOutput, error) { // Release the skill loop guard slot for this Skill // invocation now that it has finished. - if input.ToolName == "Skill" && loopGuard != nil { + if input.ToolName == toolSkill && loopGuard != nil { loopGuard.Release(input.ToolUseID) } @@ -94,7 +94,7 @@ func buildToolUseHooks( logToolUse(tl, taskID, input, false) // Track plan file writes. - if input.ToolName == "Write" || input.ToolName == "Edit" { + if input.ToolName == toolWrite || input.ToolName == toolEdit { if fp, ok := input.ToolInput["file_path"].(string); ok { if strings.Contains(fp, ".claude/plans/") { planFilePath = fp @@ -103,7 +103,7 @@ func buildToolUseHooks( } // Save plan result when ExitPlanMode is called. - if input.ToolName == "ExitPlanMode" && tl != nil { + if input.ToolName == toolExitPlanMode && tl != nil { var planContent string if input.ToolResponse != nil { @@ -152,7 +152,7 @@ func buildToolUseHooks( // invocation. Note: when the guard itself blocked the // invocation in PreToolUse, the slot was never // registered, so this Release is a no-op — safe. - if input.ToolName == "Skill" && loopGuard != nil { + if input.ToolName == toolSkill && loopGuard != nil { loopGuard.Release(input.ToolUseID) } @@ -223,7 +223,7 @@ func logToolUse(tl *taskLogger, taskID string, input claudeagent.HookInput, isFa // Add permission mode if available. if input.PermissionMode != "" { - metadata["claude_mode"] = input.PermissionMode + metadata[metaClaudeMode] = input.PermissionMode } slog.Info("tool_use", "task_id", taskID, "summary", summary, "failed", isFail, "claude_mode", input.PermissionMode) @@ -234,19 +234,19 @@ func logToolUse(tl *taskLogger, taskID string, input claudeagent.HookInput, isFa // formatToolSummary creates a human-readable one-line summary for a tool invocation. func formatToolSummary(toolName string, toolInput map[string]any) string { switch toolName { - case "Read": + case toolRead: if fp, ok := toolInput["file_path"].(string); ok { return "Read: " + fp } - case "Write": + case toolWrite: if fp, ok := toolInput["file_path"].(string); ok { return "Write: " + fp } - case "Edit": + case toolEdit: if fp, ok := toolInput["file_path"].(string); ok { return "Edit: " + fp } - case "Bash": + case toolBash: if cmd, ok := toolInput["command"].(string); ok { // Truncate long commands. if len(cmd) > 80 { @@ -255,11 +255,11 @@ func formatToolSummary(toolName string, toolInput map[string]any) string { return "Bash: " + cmd } - case "Glob": + case toolGlob: if pattern, ok := toolInput["pattern"].(string); ok { return "Glob: " + pattern } - case "Grep": + case toolGrep: if pattern, ok := toolInput["pattern"].(string); ok { path := "" if p, ok := toolInput["path"].(string); ok { @@ -268,29 +268,29 @@ func formatToolSummary(toolName string, toolInput map[string]any) string { return fmt.Sprintf("Grep: %q%s", pattern, path) } - case "WebSearch": + case toolWebSearch: if query, ok := toolInput["query"].(string); ok { return fmt.Sprintf("WebSearch: %q", query) } - case "WebFetch": + case toolWebFetch: if url, ok := toolInput["url"].(string); ok { return "WebFetch: " + url } - case "Agent": + case toolAgent: if desc, ok := toolInput["description"].(string); ok { return "Agent: " + desc } - case "TodoWrite": + case toolTodoWrite: return "TodoWrite" - case "NotebookEdit": + case toolNotebookEdit: if nbPath, ok := toolInput["notebook_path"].(string); ok { return "NotebookEdit: " + nbPath } - case "Skill": + case toolSkill: if skill, ok := toolInput["skill"].(string); ok { return "Skill /" + skill } - case "AskUserQuestion": + case toolAskUserQuestion: return "AskUserQuestion" } @@ -339,8 +339,8 @@ func handleExitPlanModeApproval( Title: "Plan review", Description: planContent, Options: []*v1.InteractionOption{ - {Label: "Approve", Value: "approve", Description: "Approve the plan and proceed"}, - {Label: "Reject", Value: "reject", Description: "Reject the plan with feedback"}, + {Label: "Approve", Value: optionApprove, Description: "Approve the plan and proceed"}, + {Label: "Reject", Value: optionReject, Description: "Reject the plan with feedback"}, }, })) if err != nil { @@ -358,7 +358,7 @@ func handleExitPlanModeApproval( select { case <-ctx.Done(): return claudeagent.HookOutput{ - Decision: "block", + Decision: hookDecisionBlock, Reason: "context canceled while waiting for plan approval", }, nil case inter := <-ch: @@ -366,7 +366,7 @@ func handleExitPlanModeApproval( logger.Info("plan approval expired, blocking ExitPlanMode") return claudeagent.HookOutput{ - Decision: "block", + Decision: hookDecisionBlock, Reason: "Plan approval expired. Please revise the plan and try again.", }, nil } @@ -374,18 +374,18 @@ func handleExitPlanModeApproval( responseStr := inter.GetResponse() logger.Info("plan approval response", "response", responseStr) - if responseStr == "approve" { + if responseStr == optionApprove { return claudeagent.HookOutput{}, nil } // Any other response is treated as rejection with feedback. feedback := responseStr - if feedback == "reject" { + if feedback == optionReject { feedback = "The user rejected the plan. Please ask the user for feedback and revise the plan." } return claudeagent.HookOutput{ - Decision: "block", + Decision: hookDecisionBlock, Reason: "Plan not approved. User feedback: " + feedback, }, nil @@ -400,7 +400,7 @@ func handleExitPlanModeApproval( } return claudeagent.HookOutput{ - Decision: "block", + Decision: hookDecisionBlock, Reason: "Plan not approved. User feedback: " + msg.GetTitle(), }, nil } diff --git a/internal/agent/server.go b/internal/agent/server.go index 473eaaff..913ad581 100644 --- a/internal/agent/server.go +++ b/internal/agent/server.go @@ -1,7 +1,6 @@ package agent import ( - "bufio" "context" "fmt" "os" @@ -13,6 +12,7 @@ import ( "github.com/oklog/ulid/v2" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/kazz187/taskguild/internal/claudemd" taskguildv1 "github.com/kazz187/taskguild/proto/gen/go/taskguild/v1" "github.com/kazz187/taskguild/proto/gen/go/taskguild/v1/taskguildv1connect" ) @@ -303,232 +303,21 @@ func (s *Server) SyncAgentsFromDir(ctx context.Context, req *connect.Request[tas }), nil } -// parsedAgent holds data extracted from a .claude/agents/*.md file. -type parsedAgent struct { - Name string - Description string - Prompt string - Tools []string - DisallowedTools []string - Model string - PermissionMode string - Skills []string - Memory string -} - -// parseAgentMDFile parses a Claude Code agent definition markdown file. -// Format: YAML frontmatter between --- delimiters, followed by the prompt body. -func parseAgentMDFile(filePath string) (*parsedAgent, error) { - f, err := os.Open(filePath) +// parseAgentMDFile reads a Claude Code agent definition markdown file and +// parses its YAML frontmatter. The file name (without extension) is used as the +// agent name when the frontmatter carries no name. +func parseAgentMDFile(filePath string) (*claudemd.Agent, error) { + data, err := os.ReadFile(filePath) if err != nil { return nil, err } - defer f.Close() - - scanner := bufio.NewScanner(f) - - // Detect frontmatter start. - hasFrontmatter := false - - var ( - frontmatterLines []string - bodyLines []string - ) - - inFrontmatter := false - frontmatterDone := false - - for scanner.Scan() { - line := scanner.Text() - if !hasFrontmatter && !frontmatterDone { - if strings.TrimSpace(line) == "---" { - hasFrontmatter = true - inFrontmatter = true - - continue - } - // No frontmatter, everything is body. - frontmatterDone = true - - bodyLines = append(bodyLines, line) - - continue - } - - if inFrontmatter { - if strings.TrimSpace(line) == "---" { - inFrontmatter = false - frontmatterDone = true - - continue - } - - frontmatterLines = append(frontmatterLines, line) - } else { - bodyLines = append(bodyLines, line) - } - } - - // Extract name from filename. - base := filepath.Base(filePath) - name := strings.TrimSuffix(base, ".md") - result := &parsedAgent{ - Name: name, + parsed := claudemd.ParseAgent(string(data)) + if parsed.Name == "" { + parsed.Name = strings.TrimSuffix(filepath.Base(filePath), ".md") } - // Parse frontmatter as simple key: value pairs. - // Also supports YAML list format ( - item) for list fields like skills, - // and YAML block scalar indicators (| and >) for multi-line string values. - var ( - currentListKey string - blockScalarKey string - blockScalarLines []string - blockIndent int - ) - - assignAgentBlockScalar := func(key string, lines []string) { - value := strings.TrimRight(strings.Join(lines, "\n"), "\n ") - - switch key { - case "name": - result.Name = value - case "description": - result.Description = value - case "model": - result.Model = value - case "permissionMode": - result.PermissionMode = value - case "memory": - result.Memory = value - } - } - - for _, line := range frontmatterLines { - trimmed := strings.TrimSpace(line) - - // If collecting a block scalar, check if this line continues it. - if blockScalarKey != "" { - if len(line) > 0 && (line[0] == ' ' || line[0] == '\t') { - // Indented line: part of the block scalar. - if len(blockScalarLines) == 0 { - blockIndent = len(line) - len(strings.TrimLeft(line, " \t")) - } - - stripped := line - if len(line) >= blockIndent { - stripped = line[blockIndent:] - } - - blockScalarLines = append(blockScalarLines, stripped) - - continue - } - - if trimmed == "" { - // Blank line within block scalar. - blockScalarLines = append(blockScalarLines, "") - continue - } - // Non-indented line: finalize block scalar and fall through. - assignAgentBlockScalar(blockScalarKey, blockScalarLines) - blockScalarKey = "" - blockScalarLines = nil - } - - // Check for YAML list item (e.g. " - skill-name"). - if strings.HasPrefix(trimmed, "- ") && currentListKey != "" { - item := strings.TrimSpace(strings.TrimPrefix(trimmed, "- ")) - if item != "" { - switch currentListKey { - case "skills": - result.Skills = append(result.Skills, item) - case "tools": - result.Tools = append(result.Tools, item) - case "disallowedTools": - result.DisallowedTools = append(result.DisallowedTools, item) - } - } - - continue - } - - if idx := strings.Index(line, ":"); idx > 0 { - key := strings.TrimSpace(line[:idx]) - value := strings.TrimSpace(line[idx+1:]) - currentListKey = "" // Reset list context. - - // Detect block scalar indicator. - if value == "|" || value == ">" { - blockScalarKey = key - blockScalarLines = nil - blockIndent = 0 - - continue - } - - switch key { - case "name": - result.Name = value - case "description": - result.Description = value - case "tools": - if value == "" { - currentListKey = "tools" - } else { - parts := strings.SplitSeq(value, ",") - for p := range parts { - p = strings.TrimSpace(p) - if p != "" { - result.Tools = append(result.Tools, p) - } - } - } - case "disallowedTools": - if value == "" { - currentListKey = "disallowedTools" - } else { - parts := strings.SplitSeq(value, ",") - for p := range parts { - p = strings.TrimSpace(p) - if p != "" { - result.DisallowedTools = append(result.DisallowedTools, p) - } - } - } - case "model": - result.Model = value - case "permissionMode": - result.PermissionMode = value - case "skills": - if value == "" { - currentListKey = "skills" - } else { - parts := strings.SplitSeq(value, ",") - for p := range parts { - p = strings.TrimSpace(p) - if p != "" { - result.Skills = append(result.Skills, p) - } - } - } - case "memory": - result.Memory = value - } - } - } - - // Finalize any trailing block scalar. - if blockScalarKey != "" { - assignAgentBlockScalar(blockScalarKey, blockScalarLines) - } - - // The body is the system prompt. - body := strings.Join(bodyLines, "\n") - body = strings.TrimSpace(body) - result.Prompt = body - - return result, nil + return parsed, nil } func toProto(a *Agent) *taskguildv1.AgentDefinition { diff --git a/internal/agentmanager/agent_conflict.go b/internal/agentmanager/agent_conflict.go index 636f3979..4f2f048b 100644 --- a/internal/agentmanager/agent_conflict.go +++ b/internal/agentmanager/agent_conflict.go @@ -2,16 +2,16 @@ package agentmanager import ( "context" - "fmt" "log/slog" "strconv" - "strings" "time" "connectrpc.com/connect" "github.com/oklog/ulid/v2" "github.com/kazz187/taskguild/internal/agent" + "github.com/kazz187/taskguild/internal/claudemd" + "github.com/kazz187/taskguild/internal/eventbus" "github.com/kazz187/taskguild/pkg/cerr" taskguildv1 "github.com/kazz187/taskguild/proto/gen/go/taskguild/v1" ) @@ -87,9 +87,9 @@ func (s *Server) ReportAgentComparison(ctx context.Context, req *connect.Request req.Msg.GetRequestId(), "", map[string]string{ - "project_id": proj.ID, - "request_id": req.Msg.GetRequestId(), - "diff_count": strconv.Itoa(len(req.Msg.GetDiffs())), + eventbus.MetaProjectID: proj.ID, + eventbus.MetaRequestID: req.Msg.GetRequestId(), + eventbus.MetaDiffCount: strconv.Itoa(len(req.Msg.GetDiffs())), }, ) @@ -162,10 +162,7 @@ func (s *Server) ResolveAgentConflict(ctx context.Context, req *connect.Request[ return nil, cerr.ExtractConnectError(ctx, err) } // Parse the agent content to extract fields. - parsed, parseErr := parseAgentMDContent(req.Msg.GetAgentContent()) - if parseErr != nil { - return nil, cerr.NewError(cerr.InvalidArgument, fmt.Sprintf("failed to parse agent content: %v", parseErr), nil).ConnectError() - } + parsed := claudemd.ParseAgent(req.Msg.GetAgentContent()) resultAgent.Description = parsed.Description resultAgent.Prompt = parsed.Prompt @@ -185,10 +182,7 @@ func (s *Server) ResolveAgentConflict(ctx context.Context, req *connect.Request[ } } else { // Agent-only agent — create new in DB. - parsed, parseErr := parseAgentMDContent(req.Msg.GetAgentContent()) - if parseErr != nil { - return nil, cerr.NewError(cerr.InvalidArgument, fmt.Sprintf("failed to parse agent content: %v", parseErr), nil).ConnectError() - } + parsed := claudemd.ParseAgent(req.Msg.GetAgentContent()) now := time.Now() @@ -265,98 +259,3 @@ func (s *Server) removeAgentDiff(projectID, agentID, filename string) { s.agentDiffCache[projectID] = filtered } - -// parseAgentMDContent parses a markdown agent definition (YAML frontmatter + prompt body) -// and returns the extracted fields. Used when resolving conflicts with AGENT choice. -func parseAgentMDContent(content string) (*parsedAgentMD, error) { - result := &parsedAgentMD{} - - lines := strings.Split(content, "\n") - if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { - // No frontmatter, treat entire content as prompt. - result.Prompt = content - return result, nil - } - - // Find closing ---. - closingIdx := -1 - - for i := 1; i < len(lines); i++ { - if strings.TrimSpace(lines[i]) == "---" { - closingIdx = i - break - } - } - - if closingIdx == -1 { - result.Prompt = content - return result, nil - } - - // Parse YAML frontmatter. - for i := 1; i < closingIdx; i++ { - line := lines[i] - if after, ok := strings.CutPrefix(line, "name:"); ok { - result.Name = strings.TrimSpace(after) - } else if after, ok := strings.CutPrefix(line, "description:"); ok { - result.Description = strings.TrimSpace(after) - } else if after, ok := strings.CutPrefix(line, "tools:"); ok { - toolsStr := strings.TrimSpace(after) - for t := range strings.SplitSeq(toolsStr, ",") { - t = strings.TrimSpace(t) - if t != "" { - result.Tools = append(result.Tools, t) - } - } - } else if after, ok := strings.CutPrefix(line, "disallowedTools:"); ok { - toolsStr := strings.TrimSpace(after) - for t := range strings.SplitSeq(toolsStr, ",") { - t = strings.TrimSpace(t) - if t != "" { - result.DisallowedTools = append(result.DisallowedTools, t) - } - } - } else if after, ok := strings.CutPrefix(line, "model:"); ok { - result.Model = strings.TrimSpace(after) - } else if after, ok := strings.CutPrefix(line, "permissionMode:"); ok { - result.PermissionMode = strings.TrimSpace(after) - } else if after, ok := strings.CutPrefix(line, "memory:"); ok { - result.Memory = strings.TrimSpace(after) - } else if strings.HasPrefix(line, "skills:") { - // YAML list follows on subsequent lines with " - " prefix. - for j := i + 1; j < closingIdx; j++ { - skillLine := strings.TrimSpace(lines[j]) - if after, ok := strings.CutPrefix(skillLine, "- "); ok { - result.Skills = append(result.Skills, after) - i = j // skip parsed lines - } else { - break - } - } - } - } - - // Extract prompt body (everything after closing ---). - if closingIdx+1 < len(lines) { - promptLines := lines[closingIdx+1:] - prompt := strings.Join(promptLines, "\n") - // Trim leading/trailing newlines but preserve internal formatting. - prompt = strings.TrimSpace(prompt) - result.Prompt = prompt - } - - return result, nil -} - -// parsedAgentMD holds data extracted from a markdown agent definition. -type parsedAgentMD struct { - Name string - Description string - Prompt string - Tools []string - DisallowedTools []string - Model string - PermissionMode string - Skills []string - Memory string -} diff --git a/internal/agentmanager/agent_sync.go b/internal/agentmanager/agent_sync.go index 22ab9010..3ef1a9b9 100644 --- a/internal/agentmanager/agent_sync.go +++ b/internal/agentmanager/agent_sync.go @@ -9,6 +9,7 @@ import ( "github.com/kazz187/taskguild/internal/agent" "github.com/kazz187/taskguild/internal/claudesettings" + "github.com/kazz187/taskguild/internal/eventbus" "github.com/kazz187/taskguild/internal/permission" "github.com/kazz187/taskguild/pkg/cerr" taskguildv1 "github.com/kazz187/taskguild/proto/gen/go/taskguild/v1" @@ -101,9 +102,9 @@ func (s *Server) ReportAgentStatus(ctx context.Context, req *connect.Request[tas req.Msg.GetTaskId(), "", map[string]string{ - "agent_manager_id": req.Msg.GetAgentManagerId(), - "agent_status": req.Msg.GetStatus().String(), - "message": req.Msg.GetMessage(), + eventbus.MetaAgentManagerID: req.Msg.GetAgentManagerId(), + eventbus.MetaAgentStatus: req.Msg.GetStatus().String(), + eventbus.MetaMessage: req.Msg.GetMessage(), }, ) diff --git a/internal/agentmanager/git_handler.go b/internal/agentmanager/git_handler.go index 45f724ab..7ab42423 100644 --- a/internal/agentmanager/git_handler.go +++ b/internal/agentmanager/git_handler.go @@ -8,6 +8,7 @@ import ( "connectrpc.com/connect" "github.com/oklog/ulid/v2" + "github.com/kazz187/taskguild/internal/eventbus" "github.com/kazz187/taskguild/pkg/cerr" taskguildv1 "github.com/kazz187/taskguild/proto/gen/go/taskguild/v1" ) @@ -63,11 +64,11 @@ func (s *Server) ReportGitPullMainResult(ctx context.Context, req *connect.Reque req.Msg.GetRequestId(), "", map[string]string{ - "project_id": proj.ID, - "request_id": req.Msg.GetRequestId(), - "success": strconv.FormatBool(req.Msg.GetSuccess()), - "output": req.Msg.GetOutput(), - "error_message": req.Msg.GetErrorMessage(), + eventbus.MetaProjectID: proj.ID, + eventbus.MetaRequestID: req.Msg.GetRequestId(), + eventbus.MetaSuccess: strconv.FormatBool(req.Msg.GetSuccess()), + eventbus.MetaOutput: req.Msg.GetOutput(), + eventbus.MetaErrorMessage: req.Msg.GetErrorMessage(), }, ) diff --git a/internal/agentmanager/interaction_handler.go b/internal/agentmanager/interaction_handler.go index b01f3108..47490443 100644 --- a/internal/agentmanager/interaction_handler.go +++ b/internal/agentmanager/interaction_handler.go @@ -9,6 +9,7 @@ import ( "connectrpc.com/connect" "github.com/oklog/ulid/v2" + "github.com/kazz187/taskguild/internal/eventbus" "github.com/kazz187/taskguild/internal/interaction" taskguildv1 "github.com/kazz187/taskguild/proto/gen/go/taskguild/v1" ) @@ -62,7 +63,7 @@ func (s *Server) CreateInteraction(ctx context.Context, req *connect.Request[tas taskguildv1.EventType_EVENT_TYPE_INTERACTION_CREATED, inter.ID, interaction.MarshalInteractionPayload(interProto), - map[string]string{"task_id": inter.TaskID, "agent_id": inter.AgentID}, + map[string]string{eventbus.MetaTaskID: inter.TaskID, eventbus.MetaAgentID: inter.AgentID}, ) return connect.NewResponse(&taskguildv1.CreateInteractionResponse{ diff --git a/internal/agentmanager/script_conflict.go b/internal/agentmanager/script_conflict.go index d7c9faaa..0c8c62c3 100644 --- a/internal/agentmanager/script_conflict.go +++ b/internal/agentmanager/script_conflict.go @@ -9,6 +9,7 @@ import ( "connectrpc.com/connect" "github.com/oklog/ulid/v2" + "github.com/kazz187/taskguild/internal/eventbus" "github.com/kazz187/taskguild/internal/script" "github.com/kazz187/taskguild/pkg/cerr" taskguildv1 "github.com/kazz187/taskguild/proto/gen/go/taskguild/v1" @@ -85,9 +86,9 @@ func (s *Server) ReportScriptComparison(ctx context.Context, req *connect.Reques req.Msg.GetRequestId(), "", map[string]string{ - "project_id": proj.ID, - "request_id": req.Msg.GetRequestId(), - "diff_count": strconv.Itoa(len(req.Msg.GetDiffs())), + eventbus.MetaProjectID: proj.ID, + eventbus.MetaRequestID: req.Msg.GetRequestId(), + eventbus.MetaDiffCount: strconv.Itoa(len(req.Msg.GetDiffs())), }, ) diff --git a/internal/agentmanager/script_handler.go b/internal/agentmanager/script_handler.go index eef2e811..2d186543 100644 --- a/internal/agentmanager/script_handler.go +++ b/internal/agentmanager/script_handler.go @@ -9,6 +9,7 @@ import ( "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/kazz187/taskguild/internal/eventbus" "github.com/kazz187/taskguild/internal/script" "github.com/kazz187/taskguild/pkg/cerr" taskguildv1 "github.com/kazz187/taskguild/proto/gen/go/taskguild/v1" @@ -74,12 +75,12 @@ func (s *Server) ReportScriptExecutionResult(ctx context.Context, req *connect.R req.Msg.GetRequestId(), "", map[string]string{ - "project_id": proj.ID, - "request_id": req.Msg.GetRequestId(), - "script_id": req.Msg.GetScriptId(), - "success": strconv.FormatBool(req.Msg.GetSuccess()), - "exit_code": strconv.Itoa(int(req.Msg.GetExitCode())), - "error_message": req.Msg.GetErrorMessage(), + eventbus.MetaProjectID: proj.ID, + eventbus.MetaRequestID: req.Msg.GetRequestId(), + eventbus.MetaScriptID: req.Msg.GetScriptId(), + eventbus.MetaSuccess: strconv.FormatBool(req.Msg.GetSuccess()), + eventbus.MetaExitCode: strconv.Itoa(int(req.Msg.GetExitCode())), + eventbus.MetaErrorMessage: req.Msg.GetErrorMessage(), }, ) diff --git a/internal/agentmanager/skill_conflict.go b/internal/agentmanager/skill_conflict.go index 48b5db74..6f9b4418 100644 --- a/internal/agentmanager/skill_conflict.go +++ b/internal/agentmanager/skill_conflict.go @@ -2,15 +2,15 @@ package agentmanager import ( "context" - "fmt" "log/slog" "strconv" - "strings" "time" "connectrpc.com/connect" "github.com/oklog/ulid/v2" + "github.com/kazz187/taskguild/internal/claudemd" + "github.com/kazz187/taskguild/internal/eventbus" "github.com/kazz187/taskguild/internal/skill" "github.com/kazz187/taskguild/pkg/cerr" taskguildv1 "github.com/kazz187/taskguild/proto/gen/go/taskguild/v1" @@ -87,9 +87,9 @@ func (s *Server) ReportSkillComparison(ctx context.Context, req *connect.Request req.Msg.GetRequestId(), "", map[string]string{ - "project_id": proj.ID, - "request_id": req.Msg.GetRequestId(), - "diff_count": strconv.Itoa(len(req.Msg.GetDiffs())), + eventbus.MetaProjectID: proj.ID, + eventbus.MetaRequestID: req.Msg.GetRequestId(), + eventbus.MetaDiffCount: strconv.Itoa(len(req.Msg.GetDiffs())), }, ) @@ -152,10 +152,7 @@ func (s *Server) ResolveSkillConflict(ctx context.Context, req *connect.Request[ case taskguildv1.SkillResolutionChoice_SKILL_RESOLUTION_CHOICE_AGENT: // Agent version wins. Update the DB with agent's content. - parsed, parseErr := parseSkillMDContent(req.Msg.GetAgentContent()) - if parseErr != nil { - return nil, cerr.NewError(cerr.InvalidArgument, fmt.Sprintf("failed to parse skill content: %v", parseErr), nil).ConnectError() - } + parsed := claudemd.ParseSkill(req.Msg.GetAgentContent()) if req.Msg.GetSkillId() != "" { // Update existing skill. @@ -259,113 +256,3 @@ func (s *Server) removeSkillDiff(projectID, skillID, filename string) { s.skillDiffCache[projectID] = filtered } - -// parseSkillMDContent parses a SKILL.md content string (YAML frontmatter + body) -// and returns the extracted fields. Used when resolving conflicts with AGENT choice. -func parseSkillMDContent(content string) (*parsedSkillMD, error) { - result := &parsedSkillMD{ - UserInvocable: true, // Default per skill spec. - } - - lines := strings.Split(content, "\n") - if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { - // No frontmatter, treat entire content as body. - result.Content = content - return result, nil - } - - // Find closing ---. - closingIdx := -1 - - for i := 1; i < len(lines); i++ { - if strings.TrimSpace(lines[i]) == "---" { - closingIdx = i - break - } - } - - if closingIdx == -1 { - result.Content = content - return result, nil - } - - // Parse YAML frontmatter. - var currentListKey string - - for i := 1; i < closingIdx; i++ { - line := lines[i] - trimmed := strings.TrimSpace(line) - - // Check for YAML list item. - if strings.HasPrefix(trimmed, "- ") && currentListKey != "" { - item := strings.TrimSpace(strings.TrimPrefix(trimmed, "- ")) - if item != "" { - switch currentListKey { - case "allowed-tools": - result.AllowedTools = append(result.AllowedTools, item) - } - } - - continue - } - - if idx := strings.Index(line, ":"); idx > 0 { - key := strings.TrimSpace(line[:idx]) - value := strings.TrimSpace(line[idx+1:]) - currentListKey = "" - - switch key { - case "name": - result.Name = value - case "description": - result.Description = value - case "disable-model-invocation": - result.DisableModelInvocation = strings.EqualFold(value, "true") - case "user-invocable": - result.UserInvocable = strings.EqualFold(value, "true") - case "allowed-tools": - if value == "" { - currentListKey = "allowed-tools" - } else { - for p := range strings.SplitSeq(value, ",") { - p = strings.TrimSpace(p) - if p != "" { - result.AllowedTools = append(result.AllowedTools, p) - } - } - } - case "model": - result.Model = value - case "context": - result.Context = value - case "agent": - result.Agent = value - case "argument-hint": - result.ArgumentHint = value - } - } - } - - // Extract body (everything after closing ---). - if closingIdx+1 < len(lines) { - bodyLines := lines[closingIdx+1:] - body := strings.Join(bodyLines, "\n") - result.Content = strings.TrimSpace(body) - } - - return result, nil -} - -// parsedSkillMD holds data extracted from a SKILL.md content string. -type parsedSkillMD struct { - Name string - Description string - Content string - DisableModelInvocation bool - UserInvocable bool - AllowedTools []string - Model string - Context string - Agent string - ArgumentHint string -} diff --git a/internal/agentmanager/task_handler.go b/internal/agentmanager/task_handler.go index 95630fc5..fbdc2027 100644 --- a/internal/agentmanager/task_handler.go +++ b/internal/agentmanager/task_handler.go @@ -14,6 +14,7 @@ import ( "connectrpc.com/connect" "github.com/oklog/ulid/v2" + "github.com/kazz187/taskguild/internal/eventbus" "github.com/kazz187/taskguild/internal/task" "github.com/kazz187/taskguild/internal/tasklog" "github.com/kazz187/taskguild/internal/version" @@ -185,8 +186,8 @@ func (s *Server) handleReleasedTask(ctx context.Context, agentManagerID string, t.ID, "", map[string]string{ - "task_id": t.ID, - "reason": "agent_released", + eventbus.MetaTaskID: t.ID, + eventbus.MetaReason: "agent_released", }, ) } @@ -223,9 +224,9 @@ func (s *Server) handleReleasedTask(ctx context.Context, agentManagerID string, t.ID, "", map[string]string{ - "project_id": t.ProjectID, - "workflow_id": t.WorkflowID, - "reason": "agent_released", + eventbus.MetaProjectID: t.ProjectID, + eventbus.MetaWorkflowID: t.WorkflowID, + eventbus.MetaReason: "agent_released", }, ) } @@ -366,8 +367,8 @@ func (s *Server) ReportTaskResult(ctx context.Context, req *connect.Request[task s.emitResultLog(ctx, t, req.Msg.GetSummary(), req.Msg.GetErrorMessage()) eventMeta := map[string]string{ - "project_id": t.ProjectID, - "workflow_id": t.WorkflowID, + eventbus.MetaProjectID: t.ProjectID, + eventbus.MetaWorkflowID: t.WorkflowID, } if req.Msg.GetErrorMessage() != "" { @@ -386,7 +387,7 @@ func (s *Server) ReportTaskResult(ctx context.Context, req *connect.Request[task return nil, err } - eventMeta["reason"] = "stopped_by_user" + eventMeta[eventbus.MetaReason] = "stopped_by_user" s.eventBus.PublishNew( taskguildv1.EventType_EVENT_TYPE_TASK_UPDATED, t.ID, "", eventMeta, @@ -428,8 +429,8 @@ func (s *Server) ReportTaskResult(ctx context.Context, req *connect.Request[task // Schedule delayed re-broadcast in a goroutine. go s.delayedRebroadcast(t.ID, t.ProjectID, t.WorkflowID, delay) - eventMeta["reason"] = "retry_scheduled" - eventMeta["retry_count"] = strconv.Itoa(retryCount) + eventMeta[eventbus.MetaReason] = "retry_scheduled" + eventMeta[eventbus.MetaRetryCount] = strconv.Itoa(retryCount) s.eventBus.PublishNew( taskguildv1.EventType_EVENT_TYPE_TASK_UPDATED, t.ID, "", eventMeta, @@ -593,7 +594,7 @@ func (s *Server) emitResultLog(ctx context.Context, t *task.Task, summary, errMs taskguildv1.EventType_EVENT_TYPE_TASK_LOG, l.ID, "", - map[string]string{"task_id": t.ID, "project_id": t.ProjectID}, + map[string]string{eventbus.MetaTaskID: t.ID, eventbus.MetaProjectID: t.ProjectID}, ) } @@ -997,10 +998,10 @@ func (s *Server) ClaimTask(ctx context.Context, req *connect.Request[taskguildv1 t.ID, "", map[string]string{ - "agent_manager_id": req.Msg.GetAgentManagerId(), - "agent_config_id": agentConfigID, - "project_id": t.ProjectID, - "workflow_id": t.WorkflowID, + eventbus.MetaAgentManagerID: req.Msg.GetAgentManagerId(), + eventbus.MetaAgentConfigID: agentConfigID, + eventbus.MetaProjectID: t.ProjectID, + eventbus.MetaWorkflowID: t.WorkflowID, }, ) diff --git a/internal/agentmanager/task_log_handler.go b/internal/agentmanager/task_log_handler.go index be0e892e..230c63d7 100644 --- a/internal/agentmanager/task_log_handler.go +++ b/internal/agentmanager/task_log_handler.go @@ -7,6 +7,7 @@ import ( "connectrpc.com/connect" "github.com/oklog/ulid/v2" + "github.com/kazz187/taskguild/internal/eventbus" "github.com/kazz187/taskguild/internal/tasklog" "github.com/kazz187/taskguild/pkg/cerr" taskguildv1 "github.com/kazz187/taskguild/proto/gen/go/taskguild/v1" @@ -39,7 +40,7 @@ func (s *Server) ReportTaskLog(ctx context.Context, req *connect.Request[taskgui return nil, err } - eventMeta := map[string]string{"task_id": req.Msg.GetTaskId(), "project_id": t.ProjectID} + eventMeta := map[string]string{eventbus.MetaTaskID: req.Msg.GetTaskId(), eventbus.MetaProjectID: t.ProjectID} s.eventBus.PublishNew( taskguildv1.EventType_EVENT_TYPE_TASK_LOG, diff --git a/internal/agentmanager/worktree_handler.go b/internal/agentmanager/worktree_handler.go index 40a616f4..de7bae91 100644 --- a/internal/agentmanager/worktree_handler.go +++ b/internal/agentmanager/worktree_handler.go @@ -8,6 +8,7 @@ import ( "connectrpc.com/connect" "github.com/oklog/ulid/v2" + "github.com/kazz187/taskguild/internal/eventbus" "github.com/kazz187/taskguild/pkg/cerr" taskguildv1 "github.com/kazz187/taskguild/proto/gen/go/taskguild/v1" ) @@ -68,8 +69,8 @@ func (s *Server) ReportWorktreeList(ctx context.Context, req *connect.Request[ta req.Msg.GetRequestId(), "", map[string]string{ - "project_id": proj.ID, - "request_id": req.Msg.GetRequestId(), + eventbus.MetaProjectID: proj.ID, + eventbus.MetaRequestID: req.Msg.GetRequestId(), }, ) @@ -170,11 +171,11 @@ func (s *Server) ReportWorktreeDeleteResult(ctx context.Context, req *connect.Re req.Msg.GetRequestId(), "", map[string]string{ - "project_id": proj.ID, - "request_id": req.Msg.GetRequestId(), - "worktree_name": req.Msg.GetWorktreeName(), - "success": strconv.FormatBool(req.Msg.GetSuccess()), - "error_message": req.Msg.GetErrorMessage(), + eventbus.MetaProjectID: proj.ID, + eventbus.MetaRequestID: req.Msg.GetRequestId(), + eventbus.MetaWorktreeName: req.Msg.GetWorktreeName(), + eventbus.MetaSuccess: strconv.FormatBool(req.Msg.GetSuccess()), + eventbus.MetaErrorMessage: req.Msg.GetErrorMessage(), }, ) diff --git a/internal/chatnotifier/notifier.go b/internal/chatnotifier/notifier.go index e569c9b7..be32b85a 100644 --- a/internal/chatnotifier/notifier.go +++ b/internal/chatnotifier/notifier.go @@ -60,7 +60,7 @@ func (n *Notifier) Start(ctx context.Context) { func (n *Notifier) handleTaskStatusChanged(ctx context.Context, event *taskguildv1.Event) { taskID := event.GetResourceId() - newStatusID := event.GetMetadata()["new_status_id"] + newStatusID := event.GetMetadata()[eventbus.MetaNewStatusID] t, err := n.taskRepo.Get(ctx, taskID) if err != nil { @@ -107,7 +107,7 @@ func (n *Notifier) handleTaskStatusChanged(ctx context.Context, event *taskguild taskguildv1.EventType_EVENT_TYPE_INTERACTION_CREATED, inter.ID, interaction.MarshalInteractionPayload(interProto), - map[string]string{"task_id": inter.TaskID, "project_id": t.ProjectID}, + map[string]string{eventbus.MetaTaskID: inter.TaskID, eventbus.MetaProjectID: t.ProjectID}, ) slog.Info("chat notifier: status change notification created", diff --git a/internal/claudemd/claudemd.go b/internal/claudemd/claudemd.go new file mode 100644 index 00000000..fde7ff6c --- /dev/null +++ b/internal/claudemd/claudemd.go @@ -0,0 +1,296 @@ +// Package claudemd parses the YAML frontmatter of Claude Code markdown +// definition files (.claude/skills/*/SKILL.md and .claude/agents/*.md). +// +// It is the string-based counterpart of internal/claudesettings, which handles +// .claude/settings.json. Callers that read from disk should read the whole file +// and hand the content to ParseSkill / ParseAgent. +package claudemd + +import "strings" + +// Delimiter is the YAML frontmatter fence used by Claude Code markdown files. +const Delimiter = "---" + +// YAML frontmatter keys used by .claude/skills/*/SKILL.md and .claude/agents/*.md. +const ( + keyName = "name" + keyDescription = "description" + keyModel = "model" + keyContext = "context" + keyAgent = "agent" + keyArgumentHint = "argument-hint" + keyAllowedTools = "allowed-tools" + keyDisableModelInvocation = "disable-model-invocation" + keyUserInvocable = "user-invocable" + keyTools = "tools" + keyDisallowedTools = "disallowedTools" + keySkills = "skills" + keyPermissionMode = "permissionMode" + keyMemory = "memory" +) + +// YAML syntax markers recognized inside the frontmatter block. +const ( + blockScalarLiteral = "|" + blockScalarFolded = ">" + listItemPrefix = "- " + boolTrue = "true" +) + +// Skill holds data extracted from a SKILL.md file. +type Skill struct { + Name string + Description string + Content string + Model string + Context string + Agent string + ArgumentHint string + AllowedTools []string + DisableModelInvocation bool + UserInvocable bool +} + +// Agent holds data extracted from a .claude/agents/*.md file. +type Agent struct { + Name string + Description string + Prompt string + Model string + PermissionMode string + Memory string + Tools []string + DisallowedTools []string + Skills []string +} + +// ParseSkill parses a SKILL.md document. Name is left empty when the frontmatter +// carries no name: the caller decides the fallback (usually the skill directory +// name). UserInvocable defaults to true per the skill spec. +func ParseSkill(content string) *Skill { + front, body := split(content) + result := &Skill{ + Content: body, + UserInvocable: true, + } + + walk(front, map[string]bool{keyAllowedTools: true}, + func(key, value string) { + switch key { + case keyName: + result.Name = value + case keyDescription: + result.Description = value + case keyDisableModelInvocation: + result.DisableModelInvocation = strings.EqualFold(value, boolTrue) + case keyUserInvocable: + result.UserInvocable = strings.EqualFold(value, boolTrue) + case keyModel: + result.Model = value + case keyContext: + result.Context = value + case keyAgent: + result.Agent = value + case keyArgumentHint: + result.ArgumentHint = value + } + }, + func(key, item string) { + if key == keyAllowedTools { + result.AllowedTools = append(result.AllowedTools, item) + } + }, + ) + + return result +} + +// ParseAgent parses a .claude/agents/*.md document. Name is left empty when the +// frontmatter carries no name: the caller decides the fallback (usually the file +// name without its extension). +func ParseAgent(content string) *Agent { + front, body := split(content) + result := &Agent{Prompt: body} + + listKeys := map[string]bool{ + keyTools: true, + keyDisallowedTools: true, + keySkills: true, + } + + walk(front, listKeys, + func(key, value string) { + switch key { + case keyName: + result.Name = value + case keyDescription: + result.Description = value + case keyModel: + result.Model = value + case keyPermissionMode: + result.PermissionMode = value + case keyMemory: + result.Memory = value + } + }, + func(key, item string) { + switch key { + case keyTools: + result.Tools = append(result.Tools, item) + case keyDisallowedTools: + result.DisallowedTools = append(result.DisallowedTools, item) + case keySkills: + result.Skills = append(result.Skills, item) + } + }, + ) + + return result +} + +// split separates the YAML frontmatter lines from the body. When the content has +// no frontmatter, or the opening fence is never closed, front is nil and body is +// the whole (trimmed) content. +func split(content string) ([]string, string) { + lines := strings.Split(content, "\n") + for i, line := range lines { + lines[i] = strings.TrimSuffix(line, "\r") + } + + whole := strings.TrimSpace(strings.Join(lines, "\n")) + + if strings.TrimSpace(lines[0]) != Delimiter { + return nil, whole + } + + for i := 1; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == Delimiter { + return lines[1:i], strings.TrimSpace(strings.Join(lines[i+1:], "\n")) + } + } + + return nil, whole +} + +// blockScalar accumulates the indented lines of a YAML block scalar (| or >). +type blockScalar struct { + key string + lines []string + indent int +} + +// start begins accumulating a block scalar for key. +func (b *blockScalar) start(key string) { + b.key = key + b.lines = nil + b.indent = 0 +} + +// consume reports whether line continues the open block scalar, appending it +// when it does. Indented lines are de-indented by the first line's indent. +func (b *blockScalar) consume(line, trimmed string) bool { + if b.key == "" { + return false + } + + if len(line) > 0 && (line[0] == ' ' || line[0] == '\t') { + if len(b.lines) == 0 { + b.indent = len(line) - len(strings.TrimLeft(line, " \t")) + } + + stripped := line + if len(line) >= b.indent { + stripped = line[b.indent:] + } + + b.lines = append(b.lines, stripped) + + return true + } + + if trimmed == "" { + b.lines = append(b.lines, "") + return true + } + + return false +} + +// flush emits the accumulated value and resets the accumulator. It is a no-op +// when no block scalar is open. +func (b *blockScalar) flush(scalar func(key, value string)) { + if b.key == "" { + return + } + + scalar(b.key, strings.TrimRight(strings.Join(b.lines, "\n"), "\n ")) + b.key = "" + b.lines = nil +} + +// emitList handles a list-valued key. An empty value opens a multi-line list and +// the key is returned so that following "- item" lines attach to it; otherwise +// the comma-separated inline items are emitted immediately. +func emitList(key, value string, list func(key, item string)) string { + if value == "" { + return key + } + + for p := range strings.SplitSeq(value, ",") { + if p = strings.TrimSpace(p); p != "" { + list(key, p) + } + } + + return "" +} + +// walk iterates frontmatter lines and dispatches each entry: +// - scalar(key, value) for "key: value" and for block scalars (| and >) +// - list(key, item) for " - item" continuations and for the comma-separated +// inline form of keys present in listKeys +func walk(lines []string, listKeys map[string]bool, scalar func(key, value string), list func(key, item string)) { + var ( + currentListKey string + block blockScalar + ) + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + if block.consume(line, trimmed) { + continue + } + // Any other line terminates an open block scalar, then is processed below. + block.flush(scalar) + + // YAML list item (e.g. " - Read") continuing the previous list key. + if item, ok := strings.CutPrefix(trimmed, listItemPrefix); ok && currentListKey != "" { + if item = strings.TrimSpace(item); item != "" { + list(currentListKey, item) + } + + continue + } + + idx := strings.Index(line, ":") + if idx <= 0 { + continue + } + + key := strings.TrimSpace(line[:idx]) + value := strings.TrimSpace(line[idx+1:]) + currentListKey = "" // Reset list context. + + switch { + case value == blockScalarLiteral || value == blockScalarFolded: + block.start(key) + case listKeys[key]: + currentListKey = emitList(key, value, list) + default: + scalar(key, value) + } + } + + block.flush(scalar) +} diff --git a/internal/claudemd/claudemd_test.go b/internal/claudemd/claudemd_test.go new file mode 100644 index 00000000..0217106b --- /dev/null +++ b/internal/claudemd/claudemd_test.go @@ -0,0 +1,525 @@ +package claudemd_test + +import ( + "reflect" + "testing" + + "github.com/kazz187/taskguild/internal/claudemd" +) + +func TestParseSkill_BlockScalarLiteral(t *testing.T) { + t.Parallel() + + content := `--- +name: gopls-explorer +description: | + Use gopls (Go Language Server) CLI commands. + Prefer gopls over grep/glob for Go code. +--- + +Skill body here. +` + + got := claudemd.ParseSkill(content) + + want := "Use gopls (Go Language Server) CLI commands.\nPrefer gopls over grep/glob for Go code." + if got.Description != want { + t.Errorf("description mismatch\ngot: %q\nwant: %q", got.Description, want) + } + + if got.Name != "gopls-explorer" { + t.Errorf("name = %q, want %q", got.Name, "gopls-explorer") + } + + if got.Content != "Skill body here." { + t.Errorf("content = %q, want %q", got.Content, "Skill body here.") + } +} + +func TestParseSkill_BlockScalarFollowedByOtherFields(t *testing.T) { + t.Parallel() + + content := `--- +name: my-skill +description: | + Line one. + Line two. +model: sonnet +argument-hint: +--- + +Body. +` + + got := claudemd.ParseSkill(content) + + if got.Description != "Line one.\nLine two." { + t.Errorf("description = %q", got.Description) + } + + if got.Model != "sonnet" { + t.Errorf("model = %q, want %q", got.Model, "sonnet") + } + + if got.ArgumentHint != "" { + t.Errorf("argumentHint = %q, want %q", got.ArgumentHint, "") + } +} + +func TestParseSkill_BlockScalarAsLastField(t *testing.T) { + t.Parallel() + + content := `--- +name: last-block +description: | + Only line. +--- +Body. +` + + got := claudemd.ParseSkill(content) + + if got.Description != "Only line." { + t.Errorf("description = %q, want %q", got.Description, "Only line.") + } +} + +func TestParseSkill_EmptyBlockScalar(t *testing.T) { + t.Parallel() + + content := `--- +name: empty-block +description: | +model: opus +--- +Body. +` + + got := claudemd.ParseSkill(content) + + if got.Description != "" { + t.Errorf("description = %q, want empty", got.Description) + } + + if got.Model != "opus" { + t.Errorf("model = %q, want %q", got.Model, "opus") + } +} + +func TestParseSkill_BlockScalarWithBlankLines(t *testing.T) { + t.Parallel() + + content := `--- +name: blank-lines +description: | + First paragraph. + + Second paragraph. +--- +Body. +` + + got := claudemd.ParseSkill(content) + + want := "First paragraph.\n\nSecond paragraph." + if got.Description != want { + t.Errorf("description mismatch\ngot: %q\nwant: %q", got.Description, want) + } +} + +func TestParseSkill_FoldedBlockScalar(t *testing.T) { + t.Parallel() + + content := `--- +name: folded +description: > + Folded line one. + Folded line two. +--- +Body. +` + + got := claudemd.ParseSkill(content) + + want := "Folded line one.\nFolded line two." + if got.Description != want { + t.Errorf("description mismatch\ngot: %q\nwant: %q", got.Description, want) + } +} + +func TestParseSkill_BlockScalarThenList(t *testing.T) { + t.Parallel() + + content := `--- +name: block-then-list +description: | + Multi + line. +allowed-tools: + - Read + - Write + - Bash +--- +Body. +` + + got := claudemd.ParseSkill(content) + + if got.Description != "Multi\nline." { + t.Errorf("description = %q", got.Description) + } + + if want := []string{"Read", "Write", "Bash"}; !reflect.DeepEqual(got.AllowedTools, want) { + t.Errorf("allowedTools = %v, want %v", got.AllowedTools, want) + } +} + +func TestParseSkill_AllowedToolsInline(t *testing.T) { + t.Parallel() + + content := `--- +name: inline-tools +allowed-tools: Read, Write , Bash +--- +Body. +` + + got := claudemd.ParseSkill(content) + + if want := []string{"Read", "Write", "Bash"}; !reflect.DeepEqual(got.AllowedTools, want) { + t.Errorf("allowedTools = %v, want %v", got.AllowedTools, want) + } +} + +func TestParseSkill_SingleLineDescription(t *testing.T) { + t.Parallel() + + content := `--- +name: single-line +description: A one line description. +disable-model-invocation: true +user-invocable: false +context: repo +agent: reviewer +--- + +Body text. +` + + got := claudemd.ParseSkill(content) + + if got.Description != "A one line description." { + t.Errorf("description = %q", got.Description) + } + + if !got.DisableModelInvocation { + t.Error("disableModelInvocation = false, want true") + } + + if got.UserInvocable { + t.Error("userInvocable = true, want false") + } + + if got.Context != "repo" { + t.Errorf("context = %q, want %q", got.Context, "repo") + } + + if got.Agent != "reviewer" { + t.Errorf("agent = %q, want %q", got.Agent, "reviewer") + } +} + +func TestParseSkill_UserInvocableDefaultsTrue(t *testing.T) { + t.Parallel() + + got := claudemd.ParseSkill("---\nname: defaults\n---\nBody.\n") + + if !got.UserInvocable { + t.Error("userInvocable = false, want true (skill spec default)") + } + + if got.DisableModelInvocation { + t.Error("disableModelInvocation = true, want false") + } +} + +func TestParseSkill_EmptyNameStaysEmpty(t *testing.T) { + t.Parallel() + + got := claudemd.ParseSkill("---\nname:\ndescription: d\n---\nBody.\n") + + if got.Name != "" { + t.Errorf("name = %q, want empty (caller supplies the fallback)", got.Name) + } +} + +func TestParseSkill_NoFrontmatter(t *testing.T) { + t.Parallel() + + content := "# Heading\n\nJust a body, no frontmatter.\n" + + got := claudemd.ParseSkill(content) + + if got.Content != "# Heading\n\nJust a body, no frontmatter." { + t.Errorf("content = %q", got.Content) + } + + if got.Name != "" || got.Description != "" { + t.Errorf("expected no frontmatter fields, got name=%q description=%q", got.Name, got.Description) + } + + if !got.UserInvocable { + t.Error("userInvocable = false, want true") + } +} + +func TestParseSkill_UnclosedFrontmatter(t *testing.T) { + t.Parallel() + + content := "---\nname: broken\ndescription: no closing fence\n" + + got := claudemd.ParseSkill(content) + + if got.Name != "" { + t.Errorf("name = %q, want empty (malformed frontmatter is treated as body)", got.Name) + } + + if got.Content != "---\nname: broken\ndescription: no closing fence" { + t.Errorf("content = %q", got.Content) + } +} + +func TestParseSkill_Empty(t *testing.T) { + t.Parallel() + + got := claudemd.ParseSkill("") + + if got.Content != "" || got.Name != "" { + t.Errorf("got %+v, want zero-valued skill", got) + } +} + +func TestParseAgent_BlockScalarLiteral(t *testing.T) { + t.Parallel() + + content := `--- +name: gopls-agent +description: | + Use gopls for precise Go code exploration. + Prefer gopls over grep/glob for Go code. +--- + +Agent prompt here. +` + + got := claudemd.ParseAgent(content) + + want := "Use gopls for precise Go code exploration.\nPrefer gopls over grep/glob for Go code." + if got.Description != want { + t.Errorf("description mismatch\ngot: %q\nwant: %q", got.Description, want) + } + + if got.Name != "gopls-agent" { + t.Errorf("name = %q, want %q", got.Name, "gopls-agent") + } + + if got.Prompt != "Agent prompt here." { + t.Errorf("prompt = %q, want %q", got.Prompt, "Agent prompt here.") + } +} + +func TestParseAgent_BlockScalarFollowedByOtherFields(t *testing.T) { + t.Parallel() + + content := `--- +name: my-agent +description: | + Line one. + Line two. +model: sonnet +permissionMode: acceptEdits +memory: project +--- + +Prompt. +` + + got := claudemd.ParseAgent(content) + + if got.Description != "Line one.\nLine two." { + t.Errorf("description = %q", got.Description) + } + + if got.Model != "sonnet" { + t.Errorf("model = %q, want %q", got.Model, "sonnet") + } + + if got.PermissionMode != "acceptEdits" { + t.Errorf("permissionMode = %q, want %q", got.PermissionMode, "acceptEdits") + } + + if got.Memory != "project" { + t.Errorf("memory = %q, want %q", got.Memory, "project") + } +} + +func TestParseAgent_BlockScalarAsLastField(t *testing.T) { + t.Parallel() + + got := claudemd.ParseAgent("---\nname: last\ndescription: |\n Only line.\n---\nPrompt.\n") + + if got.Description != "Only line." { + t.Errorf("description = %q, want %q", got.Description, "Only line.") + } +} + +func TestParseAgent_EmptyBlockScalar(t *testing.T) { + t.Parallel() + + got := claudemd.ParseAgent("---\nname: empty\ndescription: |\nmodel: opus\n---\nPrompt.\n") + + if got.Description != "" { + t.Errorf("description = %q, want empty", got.Description) + } + + if got.Model != "opus" { + t.Errorf("model = %q, want %q", got.Model, "opus") + } +} + +func TestParseAgent_BlockScalarThenList(t *testing.T) { + t.Parallel() + + content := `--- +name: block-then-list +description: | + Multi + line. +skills: + - alpha + - beta +--- +Prompt. +` + + got := claudemd.ParseAgent(content) + + if got.Description != "Multi\nline." { + t.Errorf("description = %q", got.Description) + } + + if want := []string{"alpha", "beta"}; !reflect.DeepEqual(got.Skills, want) { + t.Errorf("skills = %v, want %v", got.Skills, want) + } +} + +func TestParseAgent_SingleLineDescription(t *testing.T) { + t.Parallel() + + got := claudemd.ParseAgent("---\nname: single\ndescription: A one line description.\n---\n\nPrompt body.\n") + + if got.Description != "A one line description." { + t.Errorf("description = %q", got.Description) + } + + if got.Prompt != "Prompt body." { + t.Errorf("prompt = %q", got.Prompt) + } +} + +func TestParseAgent_ToolsInlineAndList(t *testing.T) { + t.Parallel() + + content := `--- +name: tooled +tools: Read, Write , Bash +disallowedTools: + - WebFetch + - WebSearch +skills: alpha,beta +--- +Prompt. +` + + got := claudemd.ParseAgent(content) + + if want := []string{"Read", "Write", "Bash"}; !reflect.DeepEqual(got.Tools, want) { + t.Errorf("tools = %v, want %v", got.Tools, want) + } + + if want := []string{"WebFetch", "WebSearch"}; !reflect.DeepEqual(got.DisallowedTools, want) { + t.Errorf("disallowedTools = %v, want %v", got.DisallowedTools, want) + } + + if want := []string{"alpha", "beta"}; !reflect.DeepEqual(got.Skills, want) { + t.Errorf("skills = %v, want %v", got.Skills, want) + } +} + +func TestParseAgent_IndentedKeys(t *testing.T) { + t.Parallel() + + got := claudemd.ParseAgent("---\n name: indented\n model: opus\n---\nPrompt.\n") + + if got.Name != "indented" { + t.Errorf("name = %q, want %q", got.Name, "indented") + } + + if got.Model != "opus" { + t.Errorf("model = %q, want %q", got.Model, "opus") + } +} + +func TestParseAgent_NoFrontmatter(t *testing.T) { + t.Parallel() + + got := claudemd.ParseAgent("Just a prompt, no frontmatter.\n") + + if got.Prompt != "Just a prompt, no frontmatter." { + t.Errorf("prompt = %q", got.Prompt) + } + + if got.Name != "" { + t.Errorf("name = %q, want empty", got.Name) + } +} + +func TestParseAgent_UnclosedFrontmatter(t *testing.T) { + t.Parallel() + + content := "---\nname: broken\nmodel: opus\n" + + got := claudemd.ParseAgent(content) + + if got.Name != "" { + t.Errorf("name = %q, want empty (malformed frontmatter is treated as prompt)", got.Name) + } + + if got.Prompt != "---\nname: broken\nmodel: opus" { + t.Errorf("prompt = %q", got.Prompt) + } +} + +func TestParseAgent_EmptyNameStaysEmpty(t *testing.T) { + t.Parallel() + + got := claudemd.ParseAgent("---\nname:\nmodel: opus\n---\nPrompt.\n") + + if got.Name != "" { + t.Errorf("name = %q, want empty (caller supplies the fallback)", got.Name) + } +} + +func TestSplit_CRLF(t *testing.T) { + t.Parallel() + + got := claudemd.ParseSkill("---\r\nname: crlf\r\n---\r\nBody.\r\n") + + if got.Name != "crlf" { + t.Errorf("name = %q, want %q", got.Name, "crlf") + } + + if got.Content != "Body." { + t.Errorf("content = %q, want %q", got.Content, "Body.") + } +} diff --git a/internal/event/server.go b/internal/event/server.go index 69c75344..5a1466a1 100644 --- a/internal/event/server.go +++ b/internal/event/server.go @@ -57,7 +57,7 @@ func (s *Server) SubscribeEvents(ctx context.Context, req *connect.Request[taskg } // Filter by project_id if specified. if projectID != "" { - if eventProjectID, ok := event.GetMetadata()["project_id"]; ok && eventProjectID != projectID { + if eventProjectID, ok := event.GetMetadata()[eventbus.MetaProjectID]; ok && eventProjectID != projectID { continue } } diff --git a/internal/eventbus/bus.go b/internal/eventbus/bus.go index 3cbf6877..c89ee830 100644 --- a/internal/eventbus/bus.go +++ b/internal/eventbus/bus.go @@ -1,3 +1,5 @@ +// Package eventbus provides an in-process publish/subscribe bus for taskguild +// domain events delivered to streaming API subscribers. package eventbus import ( diff --git a/internal/eventbus/metadata.go b/internal/eventbus/metadata.go new file mode 100644 index 00000000..857a7f30 --- /dev/null +++ b/internal/eventbus/metadata.go @@ -0,0 +1,29 @@ +package eventbus + +// Event metadata keys carried in taskguildv1.Event.Metadata. +// +// These values are part of the wire contract shared with the frontend +// (frontend/src/lib/event-stream.ts, components/organisms/WorktreeList.tsx) and +// with server-side readers (internal/event, internal/orchestrator, +// internal/interaction, internal/chatnotifier). Do not rename the values. +const ( + MetaProjectID = "project_id" + MetaTaskID = "task_id" + MetaAgentID = "agent_id" + MetaWorkflowID = "workflow_id" + MetaRequestID = "request_id" + MetaScriptID = "script_id" + MetaAgentManagerID = "agent_manager_id" + MetaAgentConfigID = "agent_config_id" + MetaAgentStatus = "agent_status" + MetaWorktreeName = "worktree_name" + MetaNewStatusID = "new_status_id" + MetaReason = "reason" + MetaRetryCount = "retry_count" + MetaSuccess = "success" + MetaOutput = "output" + MetaErrorMessage = "error_message" + MetaExitCode = "exit_code" + MetaDiffCount = "diff_count" + MetaMessage = "message" +) diff --git a/internal/interaction/server.go b/internal/interaction/server.go index 0de0f959..ef211fb9 100644 --- a/internal/interaction/server.go +++ b/internal/interaction/server.go @@ -115,7 +115,7 @@ func (s *Server) RespondToInteraction(ctx context.Context, req *connect.Request[ taskguildv1.EventType_EVENT_TYPE_INTERACTION_RESPONDED, inter.ID, MarshalInteractionPayload(interProto), - map[string]string{"task_id": inter.TaskID, "agent_id": inter.AgentID}, + map[string]string{eventbus.MetaTaskID: inter.TaskID, eventbus.MetaAgentID: inter.AgentID}, ) return connect.NewResponse(&taskguildv1.RespondToInteractionResponse{ @@ -157,7 +157,7 @@ func (s *Server) RespondToInteractionByToken(ctx context.Context, req *connect.R taskguildv1.EventType_EVENT_TYPE_INTERACTION_RESPONDED, inter.ID, MarshalInteractionPayload(interProto), - map[string]string{"task_id": inter.TaskID, "agent_id": inter.AgentID}, + map[string]string{eventbus.MetaTaskID: inter.TaskID, eventbus.MetaAgentID: inter.AgentID}, ) return connect.NewResponse(&taskguildv1.RespondToInteractionByTokenResponse{ @@ -188,7 +188,7 @@ func (s *Server) ExpireInteraction(ctx context.Context, req *connect.Request[tas taskguildv1.EventType_EVENT_TYPE_INTERACTION_RESPONDED, inter.ID, MarshalInteractionPayload(interProto), - map[string]string{"task_id": inter.TaskID, "agent_id": inter.AgentID}, + map[string]string{eventbus.MetaTaskID: inter.TaskID, eventbus.MetaAgentID: inter.AgentID}, ) return connect.NewResponse(&taskguildv1.ExpireInteractionResponse{ @@ -231,7 +231,7 @@ func (s *Server) SendMessage(ctx context.Context, req *connect.Request[taskguild taskguildv1.EventType_EVENT_TYPE_INTERACTION_CREATED, inter.ID, MarshalInteractionPayload(interProto), - map[string]string{"task_id": inter.TaskID, "project_id": t.ProjectID}, + map[string]string{eventbus.MetaTaskID: inter.TaskID, eventbus.MetaProjectID: t.ProjectID}, ) return connect.NewResponse(&taskguildv1.SendMessageResponse{ @@ -260,7 +260,7 @@ func (s *Server) SubscribeInteractions(ctx context.Context, req *connect.Request } // Filter by task_id if specified. if taskID != "" { - if eventTaskID, ok := event.GetMetadata()["task_id"]; ok && eventTaskID != taskID { + if eventTaskID, ok := event.GetMetadata()[eventbus.MetaTaskID]; ok && eventTaskID != taskID { continue } } diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 554a4d50..51795f21 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -147,7 +147,7 @@ func (o *Orchestrator) handleTaskEvent(ctx context.Context, event *taskguildv1.E // manual status change or resume. func (o *Orchestrator) handleInteractionCreated(ctx context.Context, event *taskguildv1.Event) { // The event's ResourceId is the interaction ID; task_id is in metadata. - taskID := event.GetMetadata()["task_id"] + taskID := event.GetMetadata()[eventbus.MetaTaskID] if taskID == "" { return } diff --git a/internal/project/seeder.go b/internal/project/seeder.go index d75746e4..60f079b2 100644 --- a/internal/project/seeder.go +++ b/internal/project/seeder.go @@ -105,6 +105,19 @@ func buildDefaultSkillDefinitions() []*skill.Skill { } } +// Default workflow status names created by Seed. Workflow statuses are +// user-editable, so these are seed defaults rather than a closed enum. +const ( + statusDraft = "Draft" + statusPlan = "Plan" + statusDevelop = "Develop" + statusReview = "Review" + statusClosed = "Closed" +) + +// modelOpus is the default model for the seeded Plan / Develop / Review statuses. +const modelOpus = "opus" + // Seed creates the default development workflow with role skills, guard // skills, and hook skills for a newly created project. func (s *Seeder) Seed(ctx context.Context, projectID string) error { @@ -143,27 +156,27 @@ func (s *Seeder) Seed(ctx context.Context, projectID string) error { Name: "development", Statuses: []workflow.Status{ { - Name: "Draft", + Name: statusDraft, Order: 0, IsInitial: true, - TransitionsTo: []string{"Plan", "Develop"}, + TransitionsTo: []string{statusPlan, statusDevelop}, EnableSkillHarness: true, }, { - Name: "Plan", + Name: statusPlan, Order: 1, - TransitionsTo: []string{"Develop"}, + TransitionsTo: []string{statusDevelop}, PermissionMode: "plan", - Model: "opus", + Model: modelOpus, Effort: "high", SkillIDs: []string{architectSkill.ID}, EnableSkillHarness: true, }, { - Name: "Develop", + Name: statusDevelop, Order: 2, - TransitionsTo: []string{"Review"}, - InheritSessionFrom: "Plan", + TransitionsTo: []string{statusReview}, + InheritSessionFrom: statusPlan, Hooks: []workflow.StatusHook{ { ID: ulid.Make().String(), @@ -184,23 +197,23 @@ func (s *Seeder) Seed(ctx context.Context, projectID string) error { }, }, PermissionMode: "acceptEdits", - Model: "opus", + Model: modelOpus, Effort: "max", SkillIDs: []string{softwareEngineerSkill.ID}, EnableSkillHarness: true, }, { - Name: "Review", + Name: statusReview, Order: 3, - TransitionsTo: []string{"Closed"}, + TransitionsTo: []string{statusClosed}, PermissionMode: "acceptEdits", - Model: "opus", + Model: modelOpus, Effort: "high", SkillIDs: []string{seniorEngineerSkill.ID}, EnableSkillHarness: true, }, { - Name: "Closed", + Name: statusClosed, Order: 4, IsTerminal: true, TransitionsTo: []string{}, diff --git a/internal/schedule/server_test.go b/internal/schedule/server_test.go index b24ee7bf..9ac52dc8 100644 --- a/internal/schedule/server_test.go +++ b/internal/schedule/server_test.go @@ -107,6 +107,7 @@ func (r *memWorkflowRepo) Get(_ context.Context, id string) (*workflow.Workflow, return &c, nil } + func (r *memWorkflowRepo) List(_ context.Context, _ string, _, _ int) ([]*workflow.Workflow, int, error) { if r.wf == nil { return nil, 0, nil @@ -128,13 +129,16 @@ func (s *stubScheduler) Add(sc *schedule.Schedule) error { s.added = append(s.added, sc.ID) return nil } + func (s *stubScheduler) Update(sc *schedule.Schedule) error { s.updated = append(s.updated, sc.ID) return nil } + func (s *stubScheduler) Remove(id string) { s.removed = append(s.removed, id) } + func (s *stubScheduler) NextRun(_ string, _ time.Time) time.Time { return time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) } diff --git a/internal/skill/server.go b/internal/skill/server.go index 7ea2bf1e..d139ba9d 100644 --- a/internal/skill/server.go +++ b/internal/skill/server.go @@ -1,18 +1,17 @@ package skill import ( - "bufio" "context" "fmt" "os" "path/filepath" - "strings" "time" "connectrpc.com/connect" "github.com/oklog/ulid/v2" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/kazz187/taskguild/internal/claudemd" taskguildv1 "github.com/kazz187/taskguild/proto/gen/go/taskguild/v1" "github.com/kazz187/taskguild/proto/gen/go/taskguild/v1/taskguildv1connect" ) @@ -300,210 +299,21 @@ func (s *Server) SyncSkillsFromDir(ctx context.Context, req *connect.Request[tas }), nil } -// parsedSkill holds data extracted from a .claude/skills/*/SKILL.md file. -type parsedSkill struct { - Name string - Description string - Content string - DisableModelInvocation bool - UserInvocable bool - AllowedTools []string - Model string - Context string - Agent string - ArgumentHint string -} - -// parseSkillMDFile parses a Claude Code skill definition markdown file. -// Format: YAML frontmatter between --- delimiters, followed by the content body. -func parseSkillMDFile(filePath string, dirName string) (*parsedSkill, error) { - f, err := os.Open(filePath) +// parseSkillMDFile reads a Claude Code skill definition markdown file and +// parses its YAML frontmatter. dirName is used as the skill name when the +// frontmatter carries no name. +func parseSkillMDFile(filePath string, dirName string) (*claudemd.Skill, error) { + data, err := os.ReadFile(filePath) if err != nil { return nil, err } - defer f.Close() - - scanner := bufio.NewScanner(f) - - // Detect frontmatter start. - hasFrontmatter := false - - var ( - frontmatterLines []string - bodyLines []string - ) - - inFrontmatter := false - frontmatterDone := false - - for scanner.Scan() { - line := scanner.Text() - if !hasFrontmatter && !frontmatterDone { - if strings.TrimSpace(line) == "---" { - hasFrontmatter = true - inFrontmatter = true - - continue - } - // No frontmatter, everything is body. - frontmatterDone = true - - bodyLines = append(bodyLines, line) - - continue - } - - if inFrontmatter { - if strings.TrimSpace(line) == "---" { - inFrontmatter = false - frontmatterDone = true - - continue - } - - frontmatterLines = append(frontmatterLines, line) - } else { - bodyLines = append(bodyLines, line) - } - } - - result := &parsedSkill{ - Name: dirName, - UserInvocable: true, // Default is true per skill spec. - } - - // Parse frontmatter as simple key: value pairs. - // Also supports YAML list format ( - item) for list fields like allowed-tools, - // and YAML block scalar indicators (| and >) for multi-line string values. - var ( - currentListKey string - blockScalarKey string - blockScalarLines []string - blockIndent int - ) - - assignSkillBlockScalar := func(key string, lines []string) { - value := strings.TrimRight(strings.Join(lines, "\n"), "\n ") - - switch key { - case "name": - result.Name = value - case "description": - result.Description = value - case "model": - result.Model = value - case "context": - result.Context = value - case "agent": - result.Agent = value - case "argument-hint": - result.ArgumentHint = value - } - } - - for _, line := range frontmatterLines { - trimmed := strings.TrimSpace(line) - // If collecting a block scalar, check if this line continues it. - if blockScalarKey != "" { - if len(line) > 0 && (line[0] == ' ' || line[0] == '\t') { - // Indented line: part of the block scalar. - if len(blockScalarLines) == 0 { - blockIndent = len(line) - len(strings.TrimLeft(line, " \t")) - } - - stripped := line - if len(line) >= blockIndent { - stripped = line[blockIndent:] - } - - blockScalarLines = append(blockScalarLines, stripped) - - continue - } - - if trimmed == "" { - // Blank line within block scalar. - blockScalarLines = append(blockScalarLines, "") - continue - } - // Non-indented line: finalize block scalar and fall through. - assignSkillBlockScalar(blockScalarKey, blockScalarLines) - blockScalarKey = "" - blockScalarLines = nil - } - - // Check for YAML list item (e.g. " - Read"). - if strings.HasPrefix(trimmed, "- ") && currentListKey != "" { - item := strings.TrimSpace(strings.TrimPrefix(trimmed, "- ")) - if item != "" { - switch currentListKey { - case "allowed-tools": - result.AllowedTools = append(result.AllowedTools, item) - } - } - - continue - } - - if idx := strings.Index(line, ":"); idx > 0 { - key := strings.TrimSpace(line[:idx]) - value := strings.TrimSpace(line[idx+1:]) - currentListKey = "" // Reset list context. - - // Detect block scalar indicator. - if value == "|" || value == ">" { - blockScalarKey = key - blockScalarLines = nil - blockIndent = 0 - - continue - } - - switch key { - case "name": - result.Name = value - case "description": - result.Description = value - case "disable-model-invocation": - result.DisableModelInvocation = strings.EqualFold(value, "true") - case "user-invocable": - result.UserInvocable = strings.EqualFold(value, "true") - case "allowed-tools": - if value == "" { - currentListKey = "allowed-tools" - } else { - parts := strings.SplitSeq(value, ",") - for p := range parts { - p = strings.TrimSpace(p) - if p != "" { - result.AllowedTools = append(result.AllowedTools, p) - } - } - } - case "model": - result.Model = value - case "context": - result.Context = value - case "agent": - result.Agent = value - case "argument-hint": - result.ArgumentHint = value - } - } + parsed := claudemd.ParseSkill(string(data)) + if parsed.Name == "" { + parsed.Name = dirName } - // Finalize any trailing block scalar. - if blockScalarKey != "" { - assignSkillBlockScalar(blockScalarKey, blockScalarLines) - } - - // The body is the skill content. - body := strings.Join(bodyLines, "\n") - body = strings.TrimSpace(body) - result.Content = body - - return result, nil + return parsed, nil } func toProto(s *Skill) *taskguildv1.SkillDefinition { diff --git a/internal/task/server.go b/internal/task/server.go index a6db92ad..246cd9df 100644 --- a/internal/task/server.go +++ b/internal/task/server.go @@ -158,7 +158,7 @@ func (s *Server) CreateTaskInternal(ctx context.Context, in CreateTaskInput) (*T taskguildv1.EventType_EVENT_TYPE_TASK_CREATED, t.ID, "", - map[string]string{"project_id": t.ProjectID, "workflow_id": t.WorkflowID}, + map[string]string{eventbus.MetaProjectID: t.ProjectID, eventbus.MetaWorkflowID: t.WorkflowID}, ) return t, nil @@ -276,7 +276,7 @@ func (s *Server) UpdateTask(ctx context.Context, req *connect.Request[taskguildv taskguildv1.EventType_EVENT_TYPE_TASK_UPDATED, t.ID, "", - map[string]string{"project_id": t.ProjectID, "workflow_id": t.WorkflowID}, + map[string]string{eventbus.MetaProjectID: t.ProjectID, eventbus.MetaWorkflowID: t.WorkflowID}, ) return connect.NewResponse(&taskguildv1.UpdateTaskResponse{ @@ -309,7 +309,7 @@ func (s *Server) DeleteTask(ctx context.Context, req *connect.Request[taskguildv taskguildv1.EventType_EVENT_TYPE_TASK_DELETED, req.Msg.GetId(), "", - map[string]string{"project_id": t.ProjectID, "workflow_id": t.WorkflowID}, + map[string]string{eventbus.MetaProjectID: t.ProjectID, eventbus.MetaWorkflowID: t.WorkflowID}, ) return connect.NewResponse(&taskguildv1.DeleteTaskResponse{}), nil @@ -416,9 +416,9 @@ func (s *Server) UpdateTaskStatus(ctx context.Context, req *connect.Request[task t.ID, "", map[string]string{ - "project_id": t.ProjectID, - "workflow_id": t.WorkflowID, - "new_status_id": req.Msg.GetStatusId(), + eventbus.MetaProjectID: t.ProjectID, + eventbus.MetaWorkflowID: t.WorkflowID, + eventbus.MetaNewStatusID: req.Msg.GetStatusId(), }, ) @@ -474,9 +474,9 @@ func (s *Server) StopTask(ctx context.Context, req *connect.Request[taskguildv1. t.ID, "", map[string]string{ - "project_id": t.ProjectID, - "workflow_id": t.WorkflowID, - "reason": "stopped_by_user", + eventbus.MetaProjectID: t.ProjectID, + eventbus.MetaWorkflowID: t.WorkflowID, + eventbus.MetaReason: "stopped_by_user", }, ) @@ -535,9 +535,9 @@ func (s *Server) ResumeTask(ctx context.Context, req *connect.Request[taskguildv t.ID, "", map[string]string{ - "project_id": t.ProjectID, - "workflow_id": t.WorkflowID, - "reason": "resumed_by_user", + eventbus.MetaProjectID: t.ProjectID, + eventbus.MetaWorkflowID: t.WorkflowID, + eventbus.MetaReason: "resumed_by_user", }, ) @@ -568,7 +568,7 @@ func (s *Server) ArchiveTask(ctx context.Context, req *connect.Request[taskguild taskguildv1.EventType_EVENT_TYPE_TASK_ARCHIVED, t.ID, "", - map[string]string{"project_id": t.ProjectID, "workflow_id": t.WorkflowID}, + map[string]string{eventbus.MetaProjectID: t.ProjectID, eventbus.MetaWorkflowID: t.WorkflowID}, ) return connect.NewResponse(&taskguildv1.ArchiveTaskResponse{ @@ -633,7 +633,7 @@ func (s *Server) ArchiveTerminalTasks(ctx context.Context, req *connect.Request[ taskguildv1.EventType_EVENT_TYPE_TASK_ARCHIVED, t.ID, "", - map[string]string{"project_id": t.ProjectID, "workflow_id": t.WorkflowID}, + map[string]string{eventbus.MetaProjectID: t.ProjectID, eventbus.MetaWorkflowID: t.WorkflowID}, ) } @@ -665,7 +665,7 @@ func (s *Server) UnarchiveTask(ctx context.Context, req *connect.Request[taskgui taskguildv1.EventType_EVENT_TYPE_TASK_UNARCHIVED, t.ID, "", - map[string]string{"project_id": t.ProjectID, "workflow_id": t.WorkflowID}, + map[string]string{eventbus.MetaProjectID: t.ProjectID, eventbus.MetaWorkflowID: t.WorkflowID}, ) return connect.NewResponse(&taskguildv1.UnarchiveTaskResponse{ diff --git a/internal/tasklog/description_logger.go b/internal/tasklog/description_logger.go index 15351f19..6e061613 100644 --- a/internal/tasklog/description_logger.go +++ b/internal/tasklog/description_logger.go @@ -50,7 +50,7 @@ func (a *DescriptionLoggerAdapter) LogDescriptionChange(ctx context.Context, pro taskguildv1.EventType_EVENT_TYPE_TASK_LOG, l.ID, "", - map[string]string{"task_id": taskID, "project_id": projectID}, + map[string]string{eventbus.MetaTaskID: taskID, eventbus.MetaProjectID: projectID}, ) return nil diff --git a/internal/template/entity.go b/internal/template/entity.go index 2b49bb83..5b2f7157 100644 --- a/internal/template/entity.go +++ b/internal/template/entity.go @@ -2,6 +2,15 @@ package template import "time" +// Template entity types. Mirrored on the wire (proto template.proto) and in the +// frontend (frontend/src/components/organisms/TemplateListTypes.ts), and stored +// verbatim in the on-disk YAML. Do not rename the values. +const ( + EntityTypeAgent = "agent" + EntityTypeSkill = "skill" + EntityTypeScript = "script" +) + // Template represents a reusable snapshot of an Agent, Skill, or Script configuration. // Templates are global (not project-scoped) and can be used across any project. type Template struct { diff --git a/internal/template/repositoryimpl/yaml_repository.go b/internal/template/repositoryimpl/yaml_repository.go index 4e811b50..28b27697 100644 --- a/internal/template/repositoryimpl/yaml_repository.go +++ b/internal/template/repositoryimpl/yaml_repository.go @@ -137,15 +137,15 @@ func (r *YAMLRepository) Delete(ctx context.Context, id string) error { // configName extracts the entity name stored inside a template's config. func configName(t *template.Template) string { switch t.EntityType { - case "agent": + case template.EntityTypeAgent: if t.AgentConfig != nil { return t.AgentConfig.Name } - case "skill": + case template.EntityTypeSkill: if t.SkillConfig != nil { return t.SkillConfig.Name } - case "script": + case template.EntityTypeScript: if t.ScriptConfig != nil { return t.ScriptConfig.Name } diff --git a/internal/template/server.go b/internal/template/server.go index b2b62624..6cb216eb 100644 --- a/internal/template/server.go +++ b/internal/template/server.go @@ -48,19 +48,19 @@ func (s *Server) CreateTemplate(ctx context.Context, req *connect.Request[taskgu } switch req.Msg.GetEntityType() { - case "agent": + case EntityTypeAgent: if req.Msg.GetAgentConfig() == nil { return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("agent_config is required for entity_type=agent")) } t.AgentConfig = agentConfigFromProto(req.Msg.GetAgentConfig()) - case "skill": + case EntityTypeSkill: if req.Msg.GetSkillConfig() == nil { return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("skill_config is required for entity_type=skill")) } t.SkillConfig = skillConfigFromProto(req.Msg.GetSkillConfig()) - case "script": + case EntityTypeScript: if req.Msg.GetScriptConfig() == nil { return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("script_config is required for entity_type=script")) } @@ -140,15 +140,15 @@ func (s *Server) UpdateTemplate(ctx context.Context, req *connect.Request[taskgu } switch t.EntityType { - case "agent": + case EntityTypeAgent: if req.Msg.GetAgentConfig() != nil { t.AgentConfig = agentConfigFromProto(req.Msg.GetAgentConfig()) } - case "skill": + case EntityTypeSkill: if req.Msg.GetSkillConfig() != nil { t.SkillConfig = skillConfigFromProto(req.Msg.GetSkillConfig()) } - case "script": + case EntityTypeScript: if req.Msg.GetScriptConfig() != nil { t.ScriptConfig = scriptConfigFromProto(req.Msg.GetScriptConfig()) } @@ -185,7 +185,7 @@ func (s *Server) SaveAsTemplate(ctx context.Context, req *connect.Request[taskgu ) switch req.Msg.GetEntityType() { - case "agent": + case EntityTypeAgent: a, err := s.agentRepo.Get(ctx, req.Msg.GetEntityId()) if err != nil { return nil, err @@ -205,7 +205,7 @@ func (s *Server) SaveAsTemplate(ctx context.Context, req *connect.Request[taskgu ID: ulid.Make().String(), Name: templateName, Description: templateDesc, - EntityType: "agent", + EntityType: EntityTypeAgent, AgentConfig: &AgentConfig{ Name: a.Name, Description: a.Description, @@ -233,7 +233,7 @@ func (s *Server) SaveAsTemplate(ctx context.Context, req *connect.Request[taskgu ID: ulid.Make().String(), Name: sk.Name, Description: sk.Description, - EntityType: "skill", + EntityType: EntityTypeSkill, SkillConfig: &SkillConfig{ Name: sk.Name, Description: sk.Description, @@ -257,7 +257,7 @@ func (s *Server) SaveAsTemplate(ctx context.Context, req *connect.Request[taskgu } } - case "skill": + case EntityTypeSkill: sk, err := s.skillRepo.Get(ctx, req.Msg.GetEntityId()) if err != nil { return nil, err @@ -277,7 +277,7 @@ func (s *Server) SaveAsTemplate(ctx context.Context, req *connect.Request[taskgu ID: ulid.Make().String(), Name: templateName, Description: templateDesc, - EntityType: "skill", + EntityType: EntityTypeSkill, SkillConfig: &SkillConfig{ Name: sk.Name, Description: sk.Description, @@ -294,7 +294,7 @@ func (s *Server) SaveAsTemplate(ctx context.Context, req *connect.Request[taskgu UpdatedAt: now, } - case "script": + case EntityTypeScript: sc, err := s.scriptRepo.Get(ctx, req.Msg.GetEntityId()) if err != nil { return nil, err @@ -314,7 +314,7 @@ func (s *Server) SaveAsTemplate(ctx context.Context, req *connect.Request[taskgu ID: ulid.Make().String(), Name: templateName, Description: templateDesc, - EntityType: "script", + EntityType: EntityTypeScript, ScriptConfig: &ScriptConfig{ Name: sc.Name, Description: sc.Description, @@ -363,7 +363,7 @@ func (s *Server) CreateFromTemplate(ctx context.Context, req *connect.Request[ta ) switch tmpl.EntityType { - case "agent": + case EntityTypeAgent: cfg := tmpl.AgentConfig if req.Msg.GetAgentConfig() != nil { cfg = agentConfigFromProto(req.Msg.GetAgentConfig()) @@ -408,7 +408,7 @@ func (s *Server) CreateFromTemplate(ctx context.Context, req *connect.Request[ta } // Find the skill template by config name. - skillTmpl, err := s.repo.FindByConfigName(ctx, "skill", skillName) + skillTmpl, err := s.repo.FindByConfigName(ctx, EntityTypeSkill, skillName) if err != nil { warnings = append(warnings, fmt.Sprintf("Skill '%s' template not found", skillName)) continue @@ -447,7 +447,7 @@ func (s *Server) CreateFromTemplate(ctx context.Context, req *connect.Request[ta } } - case "skill": + case EntityTypeSkill: cfg := tmpl.SkillConfig if req.Msg.GetSkillConfig() != nil { cfg = skillConfigFromProto(req.Msg.GetSkillConfig()) @@ -482,7 +482,7 @@ func (s *Server) CreateFromTemplate(ctx context.Context, req *connect.Request[ta createdEntityID = sk.ID - case "script": + case EntityTypeScript: cfg := tmpl.ScriptConfig if req.Msg.GetScriptConfig() != nil { cfg = scriptConfigFromProto(req.Msg.GetScriptConfig()) diff --git a/pkg/clog/chi.go b/pkg/clog/chi.go index facc6f75..0c33ec09 100644 --- a/pkg/clog/chi.go +++ b/pkg/clog/chi.go @@ -40,9 +40,9 @@ func SlogChiMiddleware(opts ...ChiOption) func(http.Handler) http.Handler { ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) ctx := ContextWithSlog(r.Context()) AddAttributes(ctx, map[string]any{ - "method": r.Method, - "procedure": r.URL.Path, - "proto": r.Proto, + MethodAttributeKey: r.Method, + ProcedureAttributeKey: r.URL.Path, + ProtoAttributeKey: r.Proto, }) next.ServeHTTP(ww, r.WithContext(ctx)) @@ -51,9 +51,9 @@ func SlogChiMiddleware(opts ...ChiOption) func(http.Handler) http.Handler { } AddAttributes(ctx, map[string]any{ - "status": ww.Status(), - "bytes_written": ww.BytesWritten(), - "duration": time.Since(startTime), + StatusAttributeKey: ww.Status(), + BytesWrittenAttributeKey: ww.BytesWritten(), + DurationAttributeKey: time.Since(startTime), }) msg := http.StatusText(ww.Status()) diff --git a/pkg/clog/connect.go b/pkg/clog/connect.go index a5c67aca..3b07fa47 100644 --- a/pkg/clog/connect.go +++ b/pkg/clog/connect.go @@ -46,10 +46,10 @@ func NewSlogConnectUnaryInterceptor(opts ...ConnectOption) connect.UnaryIntercep newCtx := ContextWithSlog(ctx) AddAttributes(newCtx, map[string]any{ - "method": req.HTTPMethod(), - "procedure": req.Spec().Procedure, - "stream_type": req.Spec().StreamType.String(), - "idempotency_level": req.Spec().IdempotencyLevel.String(), + MethodAttributeKey: req.HTTPMethod(), + ProcedureAttributeKey: req.Spec().Procedure, + StreamTypeAttributeKey: req.Spec().StreamType.String(), + IdempotencyLevelAttributeKey: req.Spec().IdempotencyLevel.String(), }) resp, err := next(newCtx, req) @@ -69,8 +69,8 @@ func NewSlogConnectUnaryInterceptor(opts ...ConnectOption) connect.UnaryIntercep } AddAttributes(newCtx, map[string]any{ - "code": codeStr, - "duration": time.Since(startTime), + CodeAttributeKey: codeStr, + DurationAttributeKey: time.Since(startTime), }) if cerr == nil { @@ -103,9 +103,9 @@ func (s *slogConnectInterceptor) WrapStreamingHandler(next connect.StreamingHand newCtx := ContextWithSlog(ctx) AddAttributes(newCtx, map[string]any{ - "procedure": conn.Spec().Procedure, - "stream_type": conn.Spec().StreamType.String(), - "idempotency_level": conn.Spec().IdempotencyLevel.String(), + ProcedureAttributeKey: conn.Spec().Procedure, + StreamTypeAttributeKey: conn.Spec().StreamType.String(), + IdempotencyLevelAttributeKey: conn.Spec().IdempotencyLevel.String(), }) slog.InfoContext(newCtx, "Connected") @@ -128,8 +128,8 @@ func (s *slogConnectInterceptor) WrapStreamingHandler(next connect.StreamingHand } AddAttributes(newCtx, map[string]any{ - "code": codeStr, - "duration": time.Since(startTime), + CodeAttributeKey: codeStr, + DurationAttributeKey: time.Since(startTime), }) if cerr == nil { diff --git a/pkg/clog/connect_text.go b/pkg/clog/connect_text.go index 16fa4a1b..72d267eb 100644 --- a/pkg/clog/connect_text.go +++ b/pkg/clog/connect_text.go @@ -111,7 +111,7 @@ func (h *ConnectTextHandler) Handle(ctx context.Context, record slog.Record) err return true }) - for _, key := range []string{"method", "stream_type", "procedure"} { + for _, key := range []string{MethodAttributeKey, StreamTypeAttributeKey, ProcedureAttributeKey} { err := printColumn(c, kv, key) if err != nil { return err @@ -123,8 +123,8 @@ func (h *ConnectTextHandler) Handle(ctx context.Context, record slog.Record) err return fmt.Errorf("can't write quote: %w", err) } - if v, ok := kv["code"]; ok { - delete(kv, "code") + if v, ok := kv[CodeAttributeKey]; ok { + delete(kv, CodeAttributeKey) if _, err := c.Printf("[%s] ", v); err != nil { return fmt.Errorf("can't write code: %w", err) diff --git a/pkg/clog/context.go b/pkg/clog/context.go index a020e14d..39eef762 100644 --- a/pkg/clog/context.go +++ b/pkg/clog/context.go @@ -84,6 +84,17 @@ func mergeMaps(dst, src map[string]any) { const ( ErrorAttributeKey = "error.message" StackAttributeKey = "error.stack" + + MethodAttributeKey = "method" + ProcedureAttributeKey = "procedure" + ProtoAttributeKey = "proto" + PathAttributeKey = "path" + StatusAttributeKey = "status" + CodeAttributeKey = "code" + DurationAttributeKey = "duration" + StreamTypeAttributeKey = "stream_type" + IdempotencyLevelAttributeKey = "idempotency_level" + BytesWrittenAttributeKey = "bytes_written" ) func AddError(ctx context.Context, err error) { diff --git a/pkg/clog/http_text.go b/pkg/clog/http_text.go index 6fc4e1d4..d6f5f45a 100644 --- a/pkg/clog/http_text.go +++ b/pkg/clog/http_text.go @@ -128,7 +128,7 @@ func (h *HTTPTextHandler) Handle(ctx context.Context, record slog.Record) error return true }) - for _, key := range []string{"proto", "method", "path", "status"} { + for _, key := range []string{ProtoAttributeKey, MethodAttributeKey, PathAttributeKey, StatusAttributeKey} { err := printColumn(c, kv, key) if err != nil { return err