diff --git a/internal/tool/schema_batch_test.go b/internal/tool/schema_batch_test.go index 5b92f98a..939bb334 100644 --- a/internal/tool/schema_batch_test.go +++ b/internal/tool/schema_batch_test.go @@ -1470,3 +1470,442 @@ func TestSchemaPropertyExtensions(t *testing.T) { t.Fatalf("oneOf = %v", om["oneOf"]) } } + +func TestSpecifySchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecifyTool{} + props := schemaProps(t, SpecifyTool{}.Parameters()) + if props["spec"].(map[string]interface{})["type"] != "string" { + t.Fatal("spec type wrong") + } + req, _ := SpecifyTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "spec" { + t.Fatalf("required = %v, want [spec]", SpecifyTool{}.Parameters()["required"]) + } +} + +func TestPlanSchemaProvider(t *testing.T) { + var _ SchemaProvider = PlanTool{} + props := schemaProps(t, PlanTool{}.Parameters()) + if props["plan"].(map[string]interface{})["type"] != "string" { + t.Fatal("plan type wrong") + } + req, _ := PlanTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "plan" { + t.Fatalf("required = %v, want [plan]", PlanTool{}.Parameters()["required"]) + } +} + +func TestTasksSchemaProvider(t *testing.T) { + var _ SchemaProvider = TasksTool{} + props := schemaProps(t, TasksTool{}.Parameters()) + if props["tasks"].(map[string]interface{})["type"] != "string" { + t.Fatal("tasks type wrong") + } + req, _ := TasksTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "tasks" { + t.Fatalf("required = %v, want [tasks]", TasksTool{}.Parameters()["required"]) + } +} + +func TestApproveImplementationSchemaProvider(t *testing.T) { + var _ SchemaProvider = ApproveImplementationTool{} + props := schemaProps(t, ApproveImplementationTool{}.Parameters()) + if len(props) != 0 { + t.Fatalf("properties = %v, want empty", props) + } +} + +func TestSpecStatusSchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecStatusTool{} + props := schemaProps(t, SpecStatusTool{}.Parameters()) + if props["slug"].(map[string]interface{})["type"] != "string" { + t.Fatal("slug type wrong") + } +} + +func TestSpecEditSchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecEditTool{} + props := schemaProps(t, SpecEditTool{}.Parameters()) + enum, ok := props["artifact"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 4 || enum[0] != "spec.md" || enum[3] != "specs.md" { + t.Fatalf("artifact enum = %v, want 4 options", props["artifact"]) + } + req, _ := SpecEditTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "artifact" { + t.Fatalf("required = %v, want [artifact]", SpecEditTool{}.Parameters()["required"]) + } +} + +func TestSpecListSchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecListTool{} + props := schemaProps(t, SpecListTool{}.Parameters()) + if len(props) != 0 { + t.Fatalf("properties = %v, want empty", props) + } +} + +func TestSpecResetSchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecResetTool{} + props := schemaProps(t, SpecResetTool{}.Parameters()) + if props["slug"].(map[string]interface{})["type"] != "string" { + t.Fatal("slug type wrong") + } + if props["delete"].(map[string]interface{})["type"] != "boolean" { + t.Fatal("delete type wrong") + } +} + +func TestSpecConfigSchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecConfigTool{} + props := schemaProps(t, SpecConfigTool{}.Parameters()) + enum, ok := props["action"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 3 || enum[0] != "get" || enum[2] != "list" { + t.Fatalf("action enum = %v, want 3 options", props["action"]) + } + req, _ := SpecConfigTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "action" { + t.Fatalf("required = %v, want [action]", SpecConfigTool{}.Parameters()["required"]) + } +} + +func TestProposalSchemaProvider(t *testing.T) { + var _ SchemaProvider = ProposalTool{} + props := schemaProps(t, ProposalTool{}.Parameters()) + if props["title"].(map[string]interface{})["type"] != "string" { + t.Fatal("title type wrong") + } + if props["proposal"].(map[string]interface{})["type"] != "string" { + t.Fatal("proposal type wrong") + } + req, _ := ProposalTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "proposal" { + t.Fatalf("required = %v, want [proposal]", ProposalTool{}.Parameters()["required"]) + } +} + +func TestDesignSchemaProvider(t *testing.T) { + var _ SchemaProvider = DesignTool{} + props := schemaProps(t, DesignTool{}.Parameters()) + if props["design"].(map[string]interface{})["type"] != "string" { + t.Fatal("design type wrong") + } + req, _ := DesignTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "design" { + t.Fatalf("required = %v, want [design]", DesignTool{}.Parameters()["required"]) + } +} + +func TestSpecAdaptiveSchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecAdaptiveTool{} + props := schemaProps(t, SpecAdaptiveTool{}.Parameters()) + if props["task_id"].(map[string]interface{})["type"] != "string" { + t.Fatal("task_id type wrong") + } + if props["estimated_effort"].(map[string]interface{})["type"] != "integer" { + t.Fatal("estimated_effort type wrong") + } + if props["actual_effort"].(map[string]interface{})["type"] != "integer" { + t.Fatal("actual_effort type wrong") + } + if props["unplanned_deps"].(map[string]interface{})["type"] != "integer" { + t.Fatal("unplanned_deps type wrong") + } + if props["super_score"].(map[string]interface{})["type"] != "number" { + t.Fatal("super_score type wrong") + } + req, _ := SpecAdaptiveTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "task_id" { + t.Fatalf("required = %v, want [task_id]", SpecAdaptiveTool{}.Parameters()["required"]) + } +} + +func TestSpecAdrSchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecAdrTool{} + props := schemaProps(t, SpecAdrTool{}.Parameters()) + enum, ok := props["action"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 3 || enum[0] != "create" || enum[2] != "link" { + t.Fatalf("action enum = %v, want 3 options", props["action"]) + } + if props["title"].(map[string]interface{})["type"] != "string" { + t.Fatal("title type wrong") + } + if props["req_id"].(map[string]interface{})["type"] != "string" { + t.Fatal("req_id type wrong") + } +} + +func TestAnalyzeSchemaProvider(t *testing.T) { + var _ SchemaProvider = AnalyzeTool{} + props := schemaProps(t, AnalyzeTool{}.Parameters()) + if len(props) != 0 { + t.Fatalf("properties = %v, want empty", props) + } +} + +func TestSpecBddSchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecBddTool{} + props := schemaProps(t, SpecBddTool{}.Parameters()) + enum, ok := props["action"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 3 || enum[0] != "generate" || enum[2] != "export" { + t.Fatalf("action enum = %v, want 3 options", props["action"]) + } + enum2, ok2 := props["format"].(map[string]interface{})["enum"].([]interface{}) + if !ok2 || len(enum2) != 3 || enum2[0] != "gherkin" || enum2[2] != "pytest-bdd" { + t.Fatalf("format enum = %v, want 3 options", props["format"]) + } +} + +func TestSpecBlastSchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecBlastTool{} + props := schemaProps(t, SpecBlastTool{}.Parameters()) + if props["target_file"].(map[string]interface{})["type"] != "string" { + t.Fatal("target_file type wrong") + } +} + +func TestChecklistSchemaProvider(t *testing.T) { + var _ SchemaProvider = ChecklistTool{} + props := schemaProps(t, ChecklistTool{}.Parameters()) + if props["include_references"].(map[string]interface{})["type"] != "boolean" { + t.Fatal("include_references type wrong") + } + enum, ok := props["artifact"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 2 || enum[0] != "spec.md" || enum[1] != "tasks.md" { + t.Fatalf("artifact enum = %v, want 2 options", props["artifact"]) + } +} + +func TestConstitutionSchemaProvider(t *testing.T) { + var _ SchemaProvider = ConstitutionTool{} + props := schemaProps(t, ConstitutionTool{}.Parameters()) + enum, ok := props["action"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 4 || enum[0] != "get" || enum[3] != "validate" { + t.Fatalf("action enum = %v, want 4 options", props["action"]) + } + if props["rules"].(map[string]interface{})["type"] != "string" { + t.Fatal("rules type wrong") + } + req, _ := ConstitutionTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "action" { + t.Fatalf("required = %v, want [action]", ConstitutionTool{}.Parameters()["required"]) + } +} + +func TestConvergeSchemaProvider(t *testing.T) { + var _ SchemaProvider = ConvergeTool{} + props := schemaProps(t, ConvergeTool{}.Parameters()) + if props["append_tasks"].(map[string]interface{})["type"] != "boolean" { + t.Fatal("append_tasks type wrong") + } +} + +func TestSpecDriftSchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecDriftTool{} + props := schemaProps(t, SpecDriftTool{}.Parameters()) + if props["scan_dir"].(map[string]interface{})["type"] != "string" { + t.Fatal("scan_dir type wrong") + } +} + +func TestSpecGroundSchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecGroundTool{} + props := schemaProps(t, SpecGroundTool{}.Parameters()) + enum, ok := props["stage"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 5 || enum[0] != "specify" || enum[4] != "implement" { + t.Fatalf("stage enum = %v, want 5 options", props["stage"]) + } + if props["query"].(map[string]interface{})["type"] != "string" { + t.Fatal("query type wrong") + } + req, _ := SpecGroundTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "stage" { + t.Fatalf("required = %v, want [stage]", SpecGroundTool{}.Parameters()["required"]) + } +} + +func TestSpecLinksSchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecLinksTool{} + props := schemaProps(t, SpecLinksTool{}.Parameters()) + enum, ok := props["action"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 2 || enum[0] != "check" || enum[1] != "add" { + t.Fatalf("action enum = %v, want 2 options", props["action"]) + } +} + +func TestSpecMasterSchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecMasterTool{} + props := schemaProps(t, SpecMasterTool{}.Parameters()) + enum, ok := props["action"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 3 || enum[0] != "read" || enum[2] != "resume" { + t.Fatalf("action enum = %v, want 3 options", props["action"]) + } + req, _ := SpecMasterTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "action" { + t.Fatalf("required = %v, want [action]", SpecMasterTool{}.Parameters()["required"]) + } +} + +func TestSpecParallelSchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecParallelTool{} + props := schemaProps(t, SpecParallelTool{}.Parameters()) + if props["dry_run"].(map[string]interface{})["type"] != "boolean" { + t.Fatal("dry_run type wrong") + } + if props["max_parallel"].(map[string]interface{})["type"] != "integer" { + t.Fatal("max_parallel type wrong") + } +} + +func TestSpecPlanVariationsSchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecPlanVariationsTool{} + props := schemaProps(t, SpecPlanVariationsTool{}.Parameters()) + if props["count"].(map[string]interface{})["type"] != "integer" { + t.Fatal("count type wrong") + } + if props["selected"].(map[string]interface{})["type"] != "integer" { + t.Fatal("selected type wrong") + } +} + +func TestSpecProgressSchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecProgressTool{} + props := schemaProps(t, SpecProgressTool{}.Parameters()) + if props["auto_update"].(map[string]interface{})["type"] != "boolean" { + t.Fatal("auto_update type wrong") + } + if props["scan_dir"].(map[string]interface{})["type"] != "string" { + t.Fatal("scan_dir type wrong") + } +} + +func TestSpecPropertiesSchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecPropertiesTool{} + props := schemaProps(t, SpecPropertiesTool{}.Parameters()) + enum, ok := props["action"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 4 || enum[0] != "define" || enum[3] != "coverage" { + t.Fatalf("action enum = %v, want 4 options", props["action"]) + } + if props["property"].(map[string]interface{})["type"] != "string" { + t.Fatal("property type wrong") + } + if props["req_id"].(map[string]interface{})["type"] != "string" { + t.Fatal("req_id type wrong") + } +} + +func TestSpecProvenanceSchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecProvenanceTool{} + props := schemaProps(t, SpecProvenanceTool{}.Parameters()) + enum, ok := props["action"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 3 || enum[0] != "record" || enum[2] != "audit" { + t.Fatalf("action enum = %v, want 3 options", props["action"]) + } + if props["file"].(map[string]interface{})["type"] != "string" { + t.Fatal("file type wrong") + } + if props["model"].(map[string]interface{})["type"] != "string" { + t.Fatal("model type wrong") + } +} + +func TestSpecReviewSchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecReviewTool{} + props := schemaProps(t, SpecReviewTool{}.Parameters()) + enum, ok := props["scope"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 3 || enum[0] != "spec" || enum[2] != "full" { + t.Fatalf("scope enum = %v, want 3 options", props["scope"]) + } +} + +func TestSpecScaleSchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecScaleTool{} + props := schemaProps(t, SpecScaleTool{}.Parameters()) + if props["scan_dir"].(map[string]interface{})["type"] != "string" { + t.Fatal("scan_dir type wrong") + } +} + +func TestSpecSuperSchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecSuperTool{} + props := schemaProps(t, SpecSuperTool{}.Parameters()) + if props["scan_dir"].(map[string]interface{})["type"] != "string" { + t.Fatal("scan_dir type wrong") + } + if props["language"].(map[string]interface{})["type"] != "string" { + t.Fatal("language type wrong") + } +} + +func TestTasksToIssuesSchemaProvider(t *testing.T) { + var _ SchemaProvider = TasksToIssuesTool{} + props := schemaProps(t, TasksToIssuesTool{}.Parameters()) + if props["dry_run"].(map[string]interface{})["type"] != "boolean" { + t.Fatal("dry_run type wrong") + } + if props["labels"].(map[string]interface{})["type"] != "string" { + t.Fatal("labels type wrong") + } +} + +func TestSpecTestGenSchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecTestGenTool{} + props := schemaProps(t, SpecTestGenTool{}.Parameters()) + if props["scan_dir"].(map[string]interface{})["type"] != "string" { + t.Fatal("scan_dir type wrong") + } + enum, ok := props["language"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 3 || enum[0] != "go" || enum[2] != "py" { + t.Fatalf("language enum = %v, want 3 options", props["language"]) + } +} + +func TestSpecTraceSchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecTraceTool{} + props := schemaProps(t, SpecTraceTool{}.Parameters()) + enum, ok := props["action"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 4 || enum[0] != "matrix" || enum[3] != "gaps" { + t.Fatalf("action enum = %v, want 4 options", props["action"]) + } + if props["scan_dir"].(map[string]interface{})["type"] != "string" { + t.Fatal("scan_dir type wrong") + } +} + +func TestSpecVersionSchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecVersionTool{} + props := schemaProps(t, SpecVersionTool{}.Parameters()) + if props["message"].(map[string]interface{})["type"] != "string" { + t.Fatal("message type wrong") + } + if props["dry_run"].(map[string]interface{})["type"] != "boolean" { + t.Fatal("dry_run type wrong") + } + if props["include_specs"].(map[string]interface{})["type"] != "boolean" { + t.Fatal("include_specs type wrong") + } +} + +func TestClarifySchemaProvider(t *testing.T) { + var _ SchemaProvider = ClarifyTool{} + props := schemaProps(t, ClarifyTool{}.Parameters()) + enum, ok := props["artifact"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 3 || enum[0] != "spec.md" || enum[2] != "tasks.md" { + t.Fatalf("artifact enum = %v, want 3 options", props["artifact"]) + } +} + +func TestSpecClarifySchemaProvider(t *testing.T) { + var _ SchemaProvider = SpecClarifyTool{} + props := schemaProps(t, SpecClarifyTool{}.Parameters()) + enum, ok := props["phase"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 5 || enum[0] != "proposal" || enum[4] != "tasks" { + t.Fatalf("phase enum = %v, want 5 options", props["phase"]) + } + if props["auto_resolve"].(map[string]interface{})["type"] != "boolean" { + t.Fatal("auto_resolve type wrong") + } + req, _ := SpecClarifyTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "phase" { + t.Fatalf("required = %v, want [phase]", SpecClarifyTool{}.Parameters()["required"]) + } +} diff --git a/internal/tool/spec.go b/internal/tool/spec.go index 1b235fdf..79e34c7b 100644 --- a/internal/tool/spec.go +++ b/internal/tool/spec.go @@ -104,28 +104,42 @@ func writeSpecArtifactInDir(dir, filename, content string) (string, error) { // Proposal; can run in parallel with Design. type SpecifyTool struct{} -func (SpecifyTool) Name() string { return "Specify" } +func (SpecifyTool) Name() string { return "Specify" } + +// SpecifyInput is the typed input for SpecifyTool. +type SpecifyInput struct { + Title string `json:"title"` + Spec string `json:"spec"` +} + func (SpecifyTool) Aliases() []string { return []string{"specify"} } func (SpecifyTool) Description() string { return "Write spec.md describing requirements and constraints. Call after Proposal, can run in parallel with Design. Write/Edit/Bash stay blocked until ApproveImplementation." } -func (SpecifyTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "spec": map[string]interface{}{"type": "string", "description": "The spec content: problem statement, requirements, constraints"}, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecifyTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "spec": {Type: "string", Description: "The spec content: problem statement, requirements, constraints"}, }, - "required": []string{"spec"}, + + Required: []string{"spec"}, } } +func (SpecifyTool) Parameters() map[string]interface{} { + return specifySchema.ToJSONSchema() +} + +// specifySchema is the single source of truth for Specify's input schema. +var specifySchema = SpecifyTool{}.Schema() + func (SpecifyTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Title string `json:"title"` - Spec string `json:"spec"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[SpecifyInput]("Specify", input) + if err != nil { return "", err } if strings.TrimSpace(p.Spec) == "" { @@ -156,27 +170,41 @@ func (SpecifyTool) Execute(ctx context.Context, input json.RawMessage) (string, // Specify and Design to be complete. type PlanTool struct{} -func (PlanTool) Name() string { return "Plan" } +func (PlanTool) Name() string { return "Plan" } + +// PlanInput is the typed input for PlanTool. +type PlanInput struct { + Plan string `json:"plan"` +} + func (PlanTool) Aliases() []string { return []string{"plan"} } func (PlanTool) Description() string { return "Write plan.md describing the implementation approach. Call after both Specify and Design are complete." } -func (PlanTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "plan": map[string]interface{}{"type": "string", "description": "The technical approach: architecture, files to change, key decisions"}, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (PlanTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "plan": {Type: "string", Description: "The technical approach: architecture, files to change, key decisions"}, }, - "required": []string{"plan"}, + + Required: []string{"plan"}, } } +func (PlanTool) Parameters() map[string]interface{} { + return planSchema.ToJSONSchema() +} + +// planSchema is the single source of truth for Plan's input schema. +var planSchema = PlanTool{}.Schema() + func (PlanTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Plan string `json:"plan"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[PlanInput]("Plan", input) + if err != nil { return "", err } if strings.TrimSpace(p.Plan) == "" { @@ -192,27 +220,41 @@ func (PlanTool) Execute(ctx context.Context, input json.RawMessage) (string, err // TasksTool writes tasks.md — the implementation breakdown for an active spec. type TasksTool struct{} -func (TasksTool) Name() string { return "Tasks" } +func (TasksTool) Name() string { return "Tasks" } + +// TasksInput is the typed input for TasksTool. +type TasksInput struct { + Tasks string `json:"tasks"` +} + func (TasksTool) Aliases() []string { return []string{"tasks"} } func (TasksTool) Description() string { return "Write tasks.md breaking the plan into concrete implementation steps. Each task should reference REQ-XXX.Y.Z IDs from the spec. Call after Plan." } -func (TasksTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "tasks": map[string]interface{}{"type": "string", "description": "The task breakdown, as a list"}, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (TasksTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "tasks": {Type: "string", Description: "The task breakdown, as a list"}, }, - "required": []string{"tasks"}, + + Required: []string{"tasks"}, } } +func (TasksTool) Parameters() map[string]interface{} { + return tasksSchema.ToJSONSchema() +} + +// tasksSchema is the single source of truth for Tasks's input schema. +var tasksSchema = TasksTool{}.Schema() + func (TasksTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Tasks string `json:"tasks"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[TasksInput]("Tasks", input) + if err != nil { return "", err } if strings.TrimSpace(p.Tasks) == "" { @@ -237,10 +279,22 @@ func (ApproveImplementationTool) Description() string { return "Ask the user to approve moving from spec to implementation. Only after approval will Write/Edit/Bash be permitted. Call this once spec.md, plan.md, and tasks.md are all written." } +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (ApproveImplementationTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{}, + } +} + func (ApproveImplementationTool) Parameters() map[string]interface{} { - return map[string]interface{}{"type": "object", "properties": map[string]interface{}{}} + return approveImplementationSchema.ToJSONSchema() } +// approveImplementationSchema is the single source of truth for ApproveImplementation's input schema. +var approveImplementationSchema = ApproveImplementationTool{}.Schema() + func (ApproveImplementationTool) Execute(_ context.Context, _ json.RawMessage) (string, error) { return "Approved. You may now implement the plan and make changes.", nil } @@ -248,27 +302,44 @@ func (ApproveImplementationTool) Execute(_ context.Context, _ json.RawMessage) ( // SpecStatusTool reports the current spec stage and the status of all spec artifacts. type SpecStatusTool struct{} -func (SpecStatusTool) Name() string { return "SpecStatus" } +func (SpecStatusTool) Name() string { return "SpecStatus" } + +// SpecStatusInput is the typed input for SpecStatusTool. +type SpecStatusInput struct { + Slug string `json:"slug"` +} + func (SpecStatusTool) Aliases() []string { return []string{"spec_status"} } func (SpecStatusTool) Description() string { return "Show the current spec stage and validation status of spec artifacts (spec.md, plan.md, tasks.md). Use this to check progress in the spec workflow." } -func (SpecStatusTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "slug": map[string]interface{}{"type": "string", "description": "Optional: check a specific spec slug instead of the active one"}, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecStatusTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "slug": {Type: "string", Description: "Optional: check a specific spec slug instead of the active one"}, }, } } +func (SpecStatusTool) Parameters() map[string]interface{} { + return specStatusSchema.ToJSONSchema() +} + +// specStatusSchema is the single source of truth for SpecStatus's input schema. +var specStatusSchema = SpecStatusTool{}.Schema() + func (SpecStatusTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Slug string `json:"slug"` - } - if input != nil { - _ = json.Unmarshal(input, &p) + var p SpecStatusInput + if len(input) > 0 && string(input) != "null" { + decoded, err := DecodeInput[SpecStatusInput]("SpecStatus", input) + if err != nil { + return "", err + } + p = decoded } var slug string @@ -450,41 +521,45 @@ func countUncheckedTasks(content string) int { // SpecEditTool modifies the active spec by applying a delta spec or replacing artifact content. type SpecEditTool struct{} -func (SpecEditTool) Name() string { return "SpecEdit" } +func (SpecEditTool) Name() string { return "SpecEdit" } + +// SpecEditInput is the typed input for SpecEditTool. +type SpecEditInput struct { + Artifact string `json:"artifact"` + Delta string `json:"delta"` + Content string `json:"content"` +} + func (SpecEditTool) Aliases() []string { return []string{"spec_edit"} } func (SpecEditTool) Description() string { return "Edit the active spec: apply a delta (ADDED/MODIFIED/REMOVED/RENAMED requirements) to spec.md, or replace an artifact entirely with new content. Call this to refine requirements without starting over." } -func (SpecEditTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "artifact": map[string]interface{}{ - "type": "string", - "description": "Which file to edit: spec.md, plan.md, tasks.md, or specs.md", - "enum": []string{"spec.md", "plan.md", "tasks.md", "specs.md"}, - }, - "delta": map[string]interface{}{ - "type": "string", - "description": "Delta spec content with ## ADDED/MODIFIED/REMOVED/RENAMED Requirements sections. Applies structured changes to the artifact.", - }, - "content": map[string]interface{}{ - "type": "string", - "description": "Full replacement content for the artifact (replaces the entire file). Use this instead of delta for wholesale changes.", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecEditTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "artifact": {Type: "string", Enum: []interface{}{"spec.md", "plan.md", "tasks.md", "specs.md"}, Description: "Which file to edit: spec.md, plan.md, tasks.md, or specs.md"}, + "delta": {Type: "string", Description: "Delta spec content with ## ADDED/MODIFIED/REMOVED/RENAMED Requirements sections. Applies structured changes to the artifact."}, + "content": {Type: "string", Description: "Full replacement content for the artifact (replaces the entire file). Use this instead of delta for wholesale changes."}, }, - "required": []string{"artifact"}, + + Required: []string{"artifact"}, } } +func (SpecEditTool) Parameters() map[string]interface{} { + return specEditSchema.ToJSONSchema() +} + +// specEditSchema is the single source of truth for SpecEdit's input schema. +var specEditSchema = SpecEditTool{}.Schema() + func (SpecEditTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Artifact string `json:"artifact"` - Delta string `json:"delta"` - Content string `json:"content"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[SpecEditInput]("SpecEdit", input) + if err != nil { return "", err } @@ -564,19 +639,35 @@ func (SpecEditTool) Execute(ctx context.Context, input json.RawMessage) (string, // SpecListTool lists all spec workflows with their current stage. type SpecListTool struct{} -func (SpecListTool) Name() string { return "SpecList" } +func (SpecListTool) Name() string { return "SpecList" } + +// SpecResetInput is the typed input for SpecResetTool. +type SpecResetInput struct { + Slug string `json:"slug"` + Delete bool `json:"delete"` +} + func (SpecListTool) Aliases() []string { return []string{"spec_list"} } func (SpecListTool) Description() string { return "List all spec workflows in .rho/specs/ with their stage and title. Useful for finding existing specs to resume." } -func (SpecListTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{}, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecListTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{}, } } +func (SpecListTool) Parameters() map[string]interface{} { + return specListSchema.ToJSONSchema() +} + +// specListSchema is the single source of truth for SpecList's input schema. +var specListSchema = SpecListTool{}.Schema() + func (SpecListTool) Execute(_ context.Context, _ json.RawMessage) (string, error) { metas, err := spec.ListSpecs() if err != nil { @@ -620,23 +711,33 @@ func (SpecResetTool) Description() string { return "Reset/delete the active spec workflow. Clears the spec stage so Write/Edit/Bash follow the trust tier again. Optionally delete the spec artifacts entirely." } -func (SpecResetTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "slug": map[string]interface{}{"type": "string", "description": "Optional: target a specific slug instead of the active one"}, - "delete": map[string]interface{}{"type": "boolean", "description": "If true, delete the spec artifacts from disk"}, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecResetTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "slug": {Type: "string", Description: "Optional: target a specific slug instead of the active one"}, + "delete": {Type: "boolean", Description: "If true, delete the spec artifacts from disk"}, }, } } +func (SpecResetTool) Parameters() map[string]interface{} { + return specResetSchema.ToJSONSchema() +} + +// specResetSchema is the single source of truth for SpecReset's input schema. +var specResetSchema = SpecResetTool{}.Schema() + func (SpecResetTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Slug string `json:"slug"` - Delete bool `json:"delete"` - } - if input != nil { - _ = json.Unmarshal(input, &p) + var p SpecResetInput + if len(input) > 0 && string(input) != "null" { + decoded, err := DecodeInput[SpecResetInput]("SpecReset", input) + if err != nil { + return "", err + } + p = decoded } var slug string @@ -683,41 +784,45 @@ func specsDir() (string, error) { // SpecConfigTool allows the agent to read and update spec configuration. type SpecConfigTool struct{} -func (SpecConfigTool) Name() string { return "SpecConfig" } +func (SpecConfigTool) Name() string { return "SpecConfig" } + +// SpecConfigInput is the typed input for SpecConfigTool. +type SpecConfigInput struct { + Action string `json:"action"` + Field string `json:"field"` + Value string `json:"value"` +} + func (SpecConfigTool) Aliases() []string { return []string{"spec_config"} } func (SpecConfigTool) Description() string { return "Read or update spec configuration (language, framework, methodology, architecture). Call this to check preferences before writing specs, or to update config as needed." } -func (SpecConfigTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "action": map[string]interface{}{ - "type": "string", - "description": "'get' to read config, 'set' to update, 'list' to show available fields", - "enum": []string{"get", "set", "list"}, - }, - "field": map[string]interface{}{ - "type": "string", - "description": "The config field to update (required for 'set'). One of: language, framework, methodology, architecture, repo_structure, custom_prompt", - }, - "value": map[string]interface{}{ - "type": "string", - "description": "The new value for the field. Use 'ai' to let the AI decide.", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecConfigTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "action": {Type: "string", Enum: []interface{}{"get", "set", "list"}, Description: "'get' to read config, 'set' to update, 'list' to show available fields"}, + "field": {Type: "string", Description: "The config field to update (required for 'set'). One of: language, framework, methodology, architecture, repo_structure, custom_prompt"}, + "value": {Type: "string", Description: "The new value for the field. Use 'ai' to let the AI decide."}, }, - "required": []string{"action"}, + + Required: []string{"action"}, } } +func (SpecConfigTool) Parameters() map[string]interface{} { + return specConfigSchema.ToJSONSchema() +} + +// specConfigSchema is the single source of truth for SpecConfig's input schema. +var specConfigSchema = SpecConfigTool{}.Schema() + func (SpecConfigTool) Execute(_ context.Context, input json.RawMessage) (string, error) { - var p struct { - Action string `json:"action"` - Field string `json:"field"` - Value string `json:"value"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[SpecConfigInput]("SpecConfig", input) + if err != nil { return "", err } @@ -793,29 +898,43 @@ func firstLine(s string) string { // document that establishes the problem and goals before any technical work. type ProposalTool struct{} -func (ProposalTool) Name() string { return "Proposal" } +func (ProposalTool) Name() string { return "Proposal" } + +// ProposalInput is the typed input for ProposalTool. +type ProposalInput struct { + Title string `json:"title"` + Proposal string `json:"proposal"` +} + func (ProposalTool) Aliases() []string { return []string{"proposal"} } func (ProposalTool) Description() string { return "Write proposal.md outlining WHY this change is needed. Call this first to start a spec-driven workflow." } -func (ProposalTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "title": map[string]interface{}{"type": "string", "description": "Short title for this spec, used to name its directory"}, - "proposal": map[string]interface{}{"type": "string", "description": "The proposal content: problem statement, goals, out of scope, success criteria"}, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (ProposalTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "title": {Type: "string", Description: "Short title for this spec, used to name its directory"}, + "proposal": {Type: "string", Description: "The proposal content: problem statement, goals, out of scope, success criteria"}, }, - "required": []string{"proposal"}, + + Required: []string{"proposal"}, } } +func (ProposalTool) Parameters() map[string]interface{} { + return proposalSchema.ToJSONSchema() +} + +// proposalSchema is the single source of truth for Proposal's input schema. +var proposalSchema = ProposalTool{}.Schema() + func (ProposalTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Title string `json:"title"` - Proposal string `json:"proposal"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[ProposalInput]("Proposal", input) + if err != nil { return "", err } if strings.TrimSpace(p.Proposal) == "" { @@ -843,27 +962,41 @@ func (ProposalTool) Execute(ctx context.Context, input json.RawMessage) (string, // Can run in parallel with Specify after Proposal completes. type DesignTool struct{} -func (DesignTool) Name() string { return "Design" } +func (DesignTool) Name() string { return "Design" } + +// DesignInput is the typed input for DesignTool. +type DesignInput struct { + Design string `json:"design"` +} + func (DesignTool) Aliases() []string { return []string{"design"} } func (DesignTool) Description() string { return "Write design.md describing the technical approach. Call after Proposal, can run in parallel with Specify." } -func (DesignTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "design": map[string]interface{}{"type": "string", "description": "The technical design: architecture, data flow, key decisions, components, interfaces"}, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (DesignTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "design": {Type: "string", Description: "The technical design: architecture, data flow, key decisions, components, interfaces"}, }, - "required": []string{"design"}, + + Required: []string{"design"}, } } +func (DesignTool) Parameters() map[string]interface{} { + return designSchema.ToJSONSchema() +} + +// designSchema is the single source of truth for Design's input schema. +var designSchema = DesignTool{}.Schema() + func (DesignTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Design string `json:"design"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[DesignInput]("Design", input) + if err != nil { return "", err } if strings.TrimSpace(p.Design) == "" { diff --git a/internal/tool/spec_adaptive.go b/internal/tool/spec_adaptive.go index c5850681..ca52dad9 100644 --- a/internal/tool/spec_adaptive.go +++ b/internal/tool/spec_adaptive.go @@ -14,6 +14,16 @@ import ( type SpecAdaptiveTool struct{} func (SpecAdaptiveTool) Name() string { return "SpecAdaptive" } + +// SpecAdaptiveInput is the typed input for SpecAdaptiveTool. +type SpecAdaptiveInput struct { + TaskID string `json:"task_id"` + EstimatedEffort int `json:"estimated_effort"` + ActualEffort int `json:"actual_effort"` + UnplannedDeps int `json:"unplanned_deps"` + SuperScore float64 `json:"super_score"` +} + func (SpecAdaptiveTool) Aliases() []string { return []string{"spec_adaptive", "spec:adaptive"} } @@ -22,35 +32,30 @@ func (SpecAdaptiveTool) Description() string { return "Collect execution telemetry and compute drift score. Compares actual effort vs estimated, S.U.P.E.R compliance, and unplanned dependencies. Returns drift level (none/mild/significant/severe) and recommended corrective action." } -func (SpecAdaptiveTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "task_id": map[string]interface{}{ - "type": "string", - "description": "Task identifier that was just completed", - }, - "estimated_effort": map[string]interface{}{ - "type": "integer", - "description": "Estimated effort in minutes", - }, - "actual_effort": map[string]interface{}{ - "type": "integer", - "description": "Actual effort in minutes", - }, - "unplanned_deps": map[string]interface{}{ - "type": "integer", - "description": "Number of unplanned dependencies encountered", - }, - "super_score": map[string]interface{}{ - "type": "number", - "description": "S.U.P.E.R compliance score 0.0-1.0", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecAdaptiveTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "task_id": {Type: "string", Description: "Task identifier that was just completed"}, + "estimated_effort": {Type: "integer", Description: "Estimated effort in minutes"}, + "actual_effort": {Type: "integer", Description: "Actual effort in minutes"}, + "unplanned_deps": {Type: "integer", Description: "Number of unplanned dependencies encountered"}, + "super_score": {Type: "number", Description: "S.U.P.E.R compliance score 0.0-1.0"}, }, - "required": []string{"task_id"}, + + Required: []string{"task_id"}, } } +func (SpecAdaptiveTool) Parameters() map[string]interface{} { + return specAdaptiveSchema.ToJSONSchema() +} + +// specAdaptiveSchema is the single source of truth for SpecAdaptive's input schema. +var specAdaptiveSchema = SpecAdaptiveTool{}.Schema() + type AdaptiveResult struct { DriftScore float64 `json:"drift_score"` DriftLevel string `json:"drift_level"` @@ -58,14 +63,8 @@ type AdaptiveResult struct { } func (SpecAdaptiveTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - TaskID string `json:"task_id"` - EstimatedEffort int `json:"estimated_effort"` - ActualEffort int `json:"actual_effort"` - UnplannedDeps int `json:"unplanned_deps"` - SuperScore float64 `json:"super_score"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[SpecAdaptiveInput]("SpecAdaptive", input) + if err != nil { return "", err } diff --git a/internal/tool/spec_adr.go b/internal/tool/spec_adr.go index 4e954de3..3cb08b1b 100644 --- a/internal/tool/spec_adr.go +++ b/internal/tool/spec_adr.go @@ -12,6 +12,14 @@ import ( type SpecAdrTool struct{} func (SpecAdrTool) Name() string { return "SpecAdr" } + +// SpecAdrInput is the typed input for SpecAdrTool. +type SpecAdrInput struct { + Action string `json:"action"` + Title string `json:"title"` + ReqID string `json:"req_id"` +} + func (SpecAdrTool) Aliases() []string { return []string{"spec_adr", "spec:adr"} } @@ -20,34 +28,29 @@ func (SpecAdrTool) Description() string { return "Create and manage Architecture Decision Records. Documents key technical decisions with context, options considered, rationale, and consequences. Links decisions to requirements they satisfy." } -func (SpecAdrTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "action": map[string]interface{}{ - "type": "string", - "description": "Action: create (new ADR), list (show all), link (link to requirement)", - "enum": []string{"create", "list", "link"}, - }, - "title": map[string]interface{}{ - "type": "string", - "description": "ADR title (required for create)", - }, - "req_id": map[string]interface{}{ - "type": "string", - "description": "REQ ID to link to (required for link)", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecAdrTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "action": {Type: "string", Enum: []interface{}{"create", "list", "link"}, Description: "Action: create (new ADR), list (show all), link (link to requirement)"}, + "title": {Type: "string", Description: "ADR title (required for create)"}, + "req_id": {Type: "string", Description: "REQ ID to link to (required for link)"}, }, } } +func (SpecAdrTool) Parameters() map[string]interface{} { + return specAdrSchema.ToJSONSchema() +} + +// specAdrSchema is the single source of truth for SpecAdr's input schema. +var specAdrSchema = SpecAdrTool{}.Schema() + func (SpecAdrTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Action string `json:"action"` - Title string `json:"title"` - ReqID string `json:"req_id"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[SpecAdrInput]("SpecAdr", input) + if err != nil { return "", err } if p.Action == "" { diff --git a/internal/tool/spec_analyze.go b/internal/tool/spec_analyze.go index b037e981..5f21db89 100644 --- a/internal/tool/spec_analyze.go +++ b/internal/tool/spec_analyze.go @@ -24,13 +24,21 @@ func (AnalyzeTool) Description() string { return "Analyze the active spec for cross-artifact consistency: check that spec requirements are covered by plan and tasks, identify orphaned work, and report quality issues. Read-only analysis — does not modify any files." } -func (AnalyzeTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{}, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (AnalyzeTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", } } +func (AnalyzeTool) Parameters() map[string]interface{} { + return analyzeSchema.ToJSONSchema() +} + +// analyzeSchema is the single source of truth for Analyze's input schema. +var analyzeSchema = AnalyzeTool{}.Schema() + func (AnalyzeTool) Execute(ctx context.Context, _ json.RawMessage) (string, error) { dir, err := specDir(ctx) if err != nil { diff --git a/internal/tool/spec_bdd.go b/internal/tool/spec_bdd.go index 794a2ad3..2f6354b5 100644 --- a/internal/tool/spec_bdd.go +++ b/internal/tool/spec_bdd.go @@ -14,6 +14,13 @@ import ( type SpecBddTool struct{} func (SpecBddTool) Name() string { return "SpecBdd" } + +// SpecBddInput is the typed input for SpecBddTool. +type SpecBddInput struct { + Action string `json:"action"` + Format string `json:"format"` +} + func (SpecBddTool) Aliases() []string { return []string{"spec_bdd", "spec:bdd"} } @@ -22,24 +29,25 @@ func (SpecBddTool) Description() string { return "Generate Gherkin/BDD scenarios from requirements. Converts EARS-format requirements into Given/When/Then scenarios for behavior-driven testing. Links scenarios back to requirements for traceability." } -func (SpecBddTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "action": map[string]interface{}{ - "type": "string", - "description": "Action: generate (from spec), validate (check coverage), export (to feature files)", - "enum": []string{"generate", "validate", "export"}, - }, - "format": map[string]interface{}{ - "type": "string", - "description": "Output format: gherkin (default), cucumber, pytest-bdd", - "enum": []string{"gherkin", "cucumber", "pytest-bdd"}, - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecBddTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "action": {Type: "string", Enum: []interface{}{"generate", "validate", "export"}, Description: "Action: generate (from spec), validate (check coverage), export (to feature files)"}, + "format": {Type: "string", Enum: []interface{}{"gherkin", "cucumber", "pytest-bdd"}, Description: "Output format: gherkin (default), cucumber, pytest-bdd"}, }, } } +func (SpecBddTool) Parameters() map[string]interface{} { + return specBddSchema.ToJSONSchema() +} + +// specBddSchema is the single source of truth for SpecBdd's input schema. +var specBddSchema = SpecBddTool{}.Schema() + type BddScenario struct { Feature string `json:"feature"` Scenario string `json:"scenario"` @@ -48,11 +56,8 @@ type BddScenario struct { } func (SpecBddTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Action string `json:"action"` - Format string `json:"format"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[SpecBddInput]("SpecBdd", input) + if err != nil { return "", err } if p.Action == "" { diff --git a/internal/tool/spec_blast.go b/internal/tool/spec_blast.go index 6189e1b6..cb6ee85b 100644 --- a/internal/tool/spec_blast.go +++ b/internal/tool/spec_blast.go @@ -15,6 +15,12 @@ import ( type SpecBlastTool struct{} func (SpecBlastTool) Name() string { return "SpecBlast" } + +// SpecBlastInput is the typed input for SpecBlastTool. +type SpecBlastInput struct { + TargetFile string `json:"target_file"` +} + func (SpecBlastTool) Aliases() []string { return []string{"spec_blast", "spec:blast"} } @@ -23,18 +29,24 @@ func (SpecBlastTool) Description() string { return "Blast radius analysis for proposed changes. Estimates which files, functions, and dependencies will be affected by a change before implementation." } -func (SpecBlastTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "target_file": map[string]interface{}{ - "type": "string", - "description": "File to analyze for blast radius", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecBlastTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "target_file": {Type: "string", Description: "File to analyze for blast radius"}, }, } } +func (SpecBlastTool) Parameters() map[string]interface{} { + return specBlastSchema.ToJSONSchema() +} + +// specBlastSchema is the single source of truth for SpecBlast's input schema. +var specBlastSchema = SpecBlastTool{}.Schema() + type BlastResult struct { TargetFile string `json:"target_file"` DirectImpact []string `json:"direct_impact"` @@ -45,10 +57,8 @@ type BlastResult struct { } func (SpecBlastTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - TargetFile string `json:"target_file"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[SpecBlastInput]("SpecBlast", input) + if err != nil { return "", err } if p.TargetFile == "" { diff --git a/internal/tool/spec_checklist.go b/internal/tool/spec_checklist.go index 1dc83ceb..3a69aef1 100644 --- a/internal/tool/spec_checklist.go +++ b/internal/tool/spec_checklist.go @@ -16,6 +16,13 @@ import ( type ChecklistTool struct{} func (ChecklistTool) Name() string { return "Checklist" } + +// ChecklistInput is the typed input for ChecklistTool. +type ChecklistInput struct { + IncludeReferences bool `json:"include_references"` + Artifact string `json:"artifact"` +} + func (ChecklistTool) Aliases() []string { return []string{"checklist", "spec_checklist", "spec:checklist"} } @@ -24,30 +31,33 @@ func (ChecklistTool) Description() string { return "Generate a QA checklist from the active spec's requirements and scenarios. Each requirement becomes a checkable item. Optionally include reference checklists for accessibility, security, performance, observability, and testing." } -func (ChecklistTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "include_references": map[string]interface{}{ - "type": "boolean", - "description": "If true, append reference checklists (accessibility, security, performance, observability, testing) alongside spec-derived checks", - }, - "artifact": map[string]interface{}{ - "type": "string", - "description": "Which artifact to generate checklist from: spec.md (default) or tasks.md", - "enum": []string{"spec.md", "tasks.md"}, - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (ChecklistTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "include_references": {Type: "boolean", Description: "If true, append reference checklists (accessibility, security, performance, observability, testing) alongside spec-derived checks"}, + "artifact": {Type: "string", Enum: []interface{}{"spec.md", "tasks.md"}, Description: "Which artifact to generate checklist from: spec.md (default) or tasks.md"}, }, } } +func (ChecklistTool) Parameters() map[string]interface{} { + return checklistSchema.ToJSONSchema() +} + +// checklistSchema is the single source of truth for Checklist's input schema. +var checklistSchema = ChecklistTool{}.Schema() + func (ChecklistTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - IncludeReferences bool `json:"include_references"` - Artifact string `json:"artifact"` - } - if input != nil { - _ = json.Unmarshal(input, &p) + var p ChecklistInput + if len(input) > 0 && string(input) != "null" { + decoded, err := DecodeInput[ChecklistInput]("Checklist", input) + if err != nil { + return "", err + } + p = decoded } if p.Artifact == "" { p.Artifact = "spec.md" diff --git a/internal/tool/spec_clarify.go b/internal/tool/spec_clarify.go index a16f3a29..ced1cce4 100644 --- a/internal/tool/spec_clarify.go +++ b/internal/tool/spec_clarify.go @@ -21,32 +21,45 @@ var titleCaser = cases.Title(language.Und) type ClarifyTool struct{} -func (ClarifyTool) Name() string { return "Clarify" } +func (ClarifyTool) Name() string { return "Clarify" } + +// ClarifyInput is the typed input for ClarifyTool. +type ClarifyInput struct { + Artifact string `json:"artifact"` +} + func (ClarifyTool) Aliases() []string { return []string{"clarify", "spec_clarify", "spec:clarify"} } func (ClarifyTool) Description() string { return "Analyze the active spec for underspecified areas, ambiguities, and missing information. Generates targeted clarification questions that should be resolved before proceeding to implementation. Call this after Specify to refine requirements." } -func (ClarifyTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "artifact": map[string]interface{}{ - "type": "string", - "description": "Which artifact to analyze: spec.md (default), plan.md, or tasks.md", - "enum": []string{"spec.md", "plan.md", "tasks.md"}, - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (ClarifyTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "artifact": {Type: "string", Enum: []interface{}{"spec.md", "plan.md", "tasks.md"}, Description: "Which artifact to analyze: spec.md (default), plan.md, or tasks.md"}, }, } } +func (ClarifyTool) Parameters() map[string]interface{} { + return clarifySchema.ToJSONSchema() +} + +// clarifySchema is the single source of truth for Clarify's input schema. +var clarifySchema = ClarifyTool{}.Schema() + func (ClarifyTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Artifact string `json:"artifact"` - } - if input != nil { - _ = json.Unmarshal(input, &p) + var p ClarifyInput + if len(input) > 0 && string(input) != "null" { + decoded, err := DecodeInput[ClarifyInput]("Clarify", input) + if err != nil { + return "", err + } + p = decoded } if p.Artifact == "" { p.Artifact = "spec.md" @@ -86,6 +99,13 @@ func (ClarifyTool) Execute(ctx context.Context, input json.RawMessage) (string, type SpecClarifyTool struct{} func (SpecClarifyTool) Name() string { return "SpecClarify" } + +// SpecClarifyInput is the typed input for SpecClarifyTool. +type SpecClarifyInput struct { + Phase string `json:"phase"` + AutoResolve bool `json:"auto_resolve"` +} + func (SpecClarifyTool) Aliases() []string { return []string{"spec_clarify_phase", "spec:clarify_phase"} } @@ -94,24 +114,27 @@ func (SpecClarifyTool) Description() string { return "Resolve ambiguities before advancing to the next spec phase. Analyzes proposal/spec for unclear requirements, missing context, and unstated assumptions. Returns targeted questions that must be answered before proceeding." } -func (SpecClarifyTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "phase": map[string]interface{}{ - "type": "string", - "description": "Phase to clarify for: proposal, spec, design, plan, tasks", - "enum": []string{"proposal", "spec", "design", "plan", "tasks"}, - }, - "auto_resolve": map[string]interface{}{ - "type": "boolean", - "description": "If true, attempt to resolve ambiguities using codebase context", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecClarifyTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "phase": {Type: "string", Enum: []interface{}{"proposal", "spec", "design", "plan", "tasks"}, Description: "Phase to clarify for: proposal, spec, design, plan, tasks"}, + "auto_resolve": {Type: "boolean", Description: "If true, attempt to resolve ambiguities using codebase context"}, }, - "required": []string{"phase"}, + + Required: []string{"phase"}, } } +func (SpecClarifyTool) Parameters() map[string]interface{} { + return specClarifySchema.ToJSONSchema() +} + +// specClarifySchema is the single source of truth for SpecClarify's input schema. +var specClarifySchema = SpecClarifyTool{}.Schema() + type ClarifyQuestion struct { Category string `json:"category"` Question string `json:"question"` @@ -121,11 +144,8 @@ type ClarifyQuestion struct { } func (SpecClarifyTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Phase string `json:"phase"` - AutoResolve bool `json:"auto_resolve"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[SpecClarifyInput]("SpecClarify", input) + if err != nil { return "", err } diff --git a/internal/tool/spec_constitution.go b/internal/tool/spec_constitution.go index c980a39a..617b6387 100644 --- a/internal/tool/spec_constitution.go +++ b/internal/tool/spec_constitution.go @@ -16,6 +16,13 @@ import ( type ConstitutionTool struct{} func (ConstitutionTool) Name() string { return "Constitution" } + +// ConstitutionInput is the typed input for ConstitutionTool. +type ConstitutionInput struct { + Action string `json:"action"` + Rules string `json:"rules"` +} + func (ConstitutionTool) Aliases() []string { return []string{"constitution", "spec_constitution", "spec:constitution"} } @@ -24,30 +31,30 @@ func (ConstitutionTool) Description() string { return "Read or update the project constitution — non-negotiable rules that all specs must follow. Use 'get' to read, 'set' to update, 'init' to create from template, 'validate' to check a spec against constitution rules." } -func (ConstitutionTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "action": map[string]interface{}{ - "type": "string", - "description": "Action to perform: get (read), set (update rules), init (create from template), validate (check spec against rules)", - "enum": []string{"get", "set", "init", "validate"}, - }, - "rules": map[string]interface{}{ - "type": "string", - "description": "Constitution rules content (required for 'set' action)", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (ConstitutionTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "action": {Type: "string", Enum: []interface{}{"get", "set", "init", "validate"}, Description: "Action to perform: get (read), set (update rules), init (create from template), validate (check spec against rules)"}, + "rules": {Type: "string", Description: "Constitution rules content (required for 'set' action)"}, }, - "required": []string{"action"}, + + Required: []string{"action"}, } } +func (ConstitutionTool) Parameters() map[string]interface{} { + return constitutionSchema.ToJSONSchema() +} + +// constitutionSchema is the single source of truth for Constitution's input schema. +var constitutionSchema = ConstitutionTool{}.Schema() + func (ConstitutionTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Action string `json:"action"` - Rules string `json:"rules"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[ConstitutionInput]("Constitution", input) + if err != nil { return "", err } diff --git a/internal/tool/spec_converge.go b/internal/tool/spec_converge.go index d02786a5..de001d6b 100644 --- a/internal/tool/spec_converge.go +++ b/internal/tool/spec_converge.go @@ -16,31 +16,45 @@ import ( // and unresolved requirements, then optionally appends convergence tasks. type ConvergeTool struct{} -func (ConvergeTool) Name() string { return "Converge" } +func (ConvergeTool) Name() string { return "Converge" } + +// ConvergeInput is the typed input for ConvergeTool. +type ConvergeInput struct { + AppendTasks bool `json:"append_tasks"` +} + func (ConvergeTool) Aliases() []string { return []string{"converge", "spec_converge", "spec:converge"} } func (ConvergeTool) Description() string { return "Assess the gap between the active spec and the codebase. Checks incomplete tasks, missing REQ coverage, orphan citations, and constitution compliance. Optionally appends convergence tasks to tasks.md." } -func (ConvergeTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "append_tasks": map[string]interface{}{ - "type": "boolean", - "description": "If true, append convergence tasks to tasks.md for remaining work", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (ConvergeTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "append_tasks": {Type: "boolean", Description: "If true, append convergence tasks to tasks.md for remaining work"}, }, } } +func (ConvergeTool) Parameters() map[string]interface{} { + return convergeSchema.ToJSONSchema() +} + +// convergeSchema is the single source of truth for Converge's input schema. +var convergeSchema = ConvergeTool{}.Schema() + func (ConvergeTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - AppendTasks bool `json:"append_tasks"` - } - if input != nil { - _ = json.Unmarshal(input, &p) + var p ConvergeInput + if len(input) > 0 && string(input) != "null" { + decoded, err := DecodeInput[ConvergeInput]("Converge", input) + if err != nil { + return "", err + } + p = decoded } slug, err := specSlug(ctx) diff --git a/internal/tool/spec_drift.go b/internal/tool/spec_drift.go index 746f4139..24a4fc1c 100644 --- a/internal/tool/spec_drift.go +++ b/internal/tool/spec_drift.go @@ -15,6 +15,12 @@ import ( type SpecDriftTool struct{} func (SpecDriftTool) Name() string { return "SpecDrift" } + +// SpecDriftInput is the typed input for SpecDriftTool. +type SpecDriftInput struct { + ScanDir string `json:"scan_dir"` +} + func (SpecDriftTool) Aliases() []string { return []string{"spec_drift", "spec:drift"} } @@ -23,23 +29,27 @@ func (SpecDriftTool) Description() string { return "Detect drift between specs and implementation. Compares requirements in spec.md against actual code coverage." } -func (SpecDriftTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "scan_dir": map[string]interface{}{ - "type": "string", - "description": "Directory to scan (default: current directory)", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecDriftTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "scan_dir": {Type: "string", Description: "Directory to scan (default: current directory)"}, }, } } +func (SpecDriftTool) Parameters() map[string]interface{} { + return specDriftSchema.ToJSONSchema() +} + +// specDriftSchema is the single source of truth for SpecDrift's input schema. +var specDriftSchema = SpecDriftTool{}.Schema() + func (SpecDriftTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - ScanDir string `json:"scan_dir"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[SpecDriftInput]("SpecDrift", input) + if err != nil { return "", err } if p.ScanDir == "" { diff --git a/internal/tool/spec_ground.go b/internal/tool/spec_ground.go index 2bfbecb2..28c26a85 100644 --- a/internal/tool/spec_ground.go +++ b/internal/tool/spec_ground.go @@ -17,6 +17,13 @@ import ( type SpecGroundTool struct{} func (SpecGroundTool) Name() string { return "SpecGround" } + +// SpecGroundInput is the typed input for SpecGroundTool. +type SpecGroundInput struct { + Stage string `json:"stage"` + Query string `json:"query"` +} + func (SpecGroundTool) Aliases() []string { return []string{"spec_ground", "spec:ground"} } @@ -25,30 +32,30 @@ func (SpecGroundTool) Description() string { return "Gather repository context to ground the current spec stage. Probes the codebase for relevant files, patterns, dependencies, and API contracts. Use before Specify, Design, or Plan to ensure the LLM has full context." } -func (SpecGroundTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "stage": map[string]interface{}{ - "type": "string", - "description": "Which stage to gather context for: specify, design, plan, tasks, implement", - "enum": []string{"specify", "design", "plan", "tasks", "implement"}, - }, - "query": map[string]interface{}{ - "type": "string", - "description": "Optional focus area to narrow the search (e.g., 'auth', 'database', 'API')", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecGroundTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "stage": {Type: "string", Enum: []interface{}{"specify", "design", "plan", "tasks", "implement"}, Description: "Which stage to gather context for: specify, design, plan, tasks, implement"}, + "query": {Type: "string", Description: "Optional focus area to narrow the search (e.g., 'auth', 'database', 'API')"}, }, - "required": []string{"stage"}, + + Required: []string{"stage"}, } } +func (SpecGroundTool) Parameters() map[string]interface{} { + return specGroundSchema.ToJSONSchema() +} + +// specGroundSchema is the single source of truth for SpecGround's input schema. +var specGroundSchema = SpecGroundTool{}.Schema() + func (SpecGroundTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Stage string `json:"stage"` - Query string `json:"query"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[SpecGroundInput]("SpecGround", input) + if err != nil { return "", err } diff --git a/internal/tool/spec_links.go b/internal/tool/spec_links.go index d1556872..799d9dd1 100644 --- a/internal/tool/spec_links.go +++ b/internal/tool/spec_links.go @@ -17,6 +17,12 @@ import ( type SpecLinksTool struct{} func (SpecLinksTool) Name() string { return "SpecLinks" } + +// SpecLinksInput is the typed input for SpecLinksTool. +type SpecLinksInput struct { + Action string `json:"action"` +} + func (SpecLinksTool) Aliases() []string { return []string{"spec_links", "spec:links"} } @@ -27,24 +33,27 @@ func (SpecLinksTool) Description() string { "action=add writes [REQ-X.Y.Z] annotations into the spec's tasks.md checklist." } -func (SpecLinksTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "action": map[string]interface{}{ - "type": "string", - "description": "Action: check (verify coverage), add (write annotations into tasks.md)", - "enum": []string{"check", "add"}, - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecLinksTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "action": {Type: "string", Enum: []interface{}{"check", "add"}, Description: "Action: check (verify coverage), add (write annotations into tasks.md)"}, }, } } +func (SpecLinksTool) Parameters() map[string]interface{} { + return specLinksSchema.ToJSONSchema() +} + +// specLinksSchema is the single source of truth for SpecLinks's input schema. +var specLinksSchema = SpecLinksTool{}.Schema() + func (SpecLinksTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Action string `json:"action"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[SpecLinksInput]("SpecLinks", input) + if err != nil { return "", err } if p.Action == "" { diff --git a/internal/tool/spec_master.go b/internal/tool/spec_master.go index 550a22bd..e3d13e39 100644 --- a/internal/tool/spec_master.go +++ b/internal/tool/spec_master.go @@ -15,6 +15,12 @@ import ( type SpecMasterTool struct{} func (SpecMasterTool) Name() string { return "SpecMaster" } + +// SpecMasterInput is the typed input for SpecMasterTool. +type SpecMasterInput struct { + Action string `json:"action"` +} + func (SpecMasterTool) Aliases() []string { return []string{"spec_master", "spec:master"} } @@ -23,25 +29,29 @@ func (SpecMasterTool) Description() string { return "Generate or update MASTER.md progress index for cross-session continuity. Captures current spec state, completed tasks, pending work, and key decisions so work can resume across sessions." } -func (SpecMasterTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "action": map[string]interface{}{ - "type": "string", - "description": "Action: read (show current), update (regenerate), resume (load state for continuation)", - "enum": []string{"read", "update", "resume"}, - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecMasterTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "action": {Type: "string", Enum: []interface{}{"read", "update", "resume"}, Description: "Action: read (show current), update (regenerate), resume (load state for continuation)"}, }, - "required": []string{"action"}, + + Required: []string{"action"}, } } +func (SpecMasterTool) Parameters() map[string]interface{} { + return specMasterSchema.ToJSONSchema() +} + +// specMasterSchema is the single source of truth for SpecMaster's input schema. +var specMasterSchema = SpecMasterTool{}.Schema() + func (SpecMasterTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Action string `json:"action"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[SpecMasterInput]("SpecMaster", input) + if err != nil { return "", err } diff --git a/internal/tool/spec_parallel.go b/internal/tool/spec_parallel.go index 0b39b5b7..fc54a199 100644 --- a/internal/tool/spec_parallel.go +++ b/internal/tool/spec_parallel.go @@ -16,6 +16,13 @@ import ( type SpecParallelTool struct{} func (SpecParallelTool) Name() string { return "SpecParallel" } + +// SpecParallelInput is the typed input for SpecParallelTool. +type SpecParallelInput struct { + DryRun bool `json:"dry_run"` + MaxParallel int `json:"max_parallel"` +} + func (SpecParallelTool) Aliases() []string { return []string{"spec_parallel", "spec:parallel"} } @@ -24,29 +31,33 @@ func (SpecParallelTool) Description() string { return "Analyze tasks.md for parallel execution groups and execute independent tasks concurrently. Parses task dependencies, identifies conflict-free groups, and runs them in parallel sub-agents." } -func (SpecParallelTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "dry_run": map[string]interface{}{ - "type": "boolean", - "description": "If true, only analyze and report groups without executing", - }, - "max_parallel": map[string]interface{}{ - "type": "integer", - "description": "Maximum number of parallel tasks (default 8)", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecParallelTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "dry_run": {Type: "boolean", Description: "If true, only analyze and report groups without executing"}, + "max_parallel": {Type: "integer", Description: "Maximum number of parallel tasks (default 8)"}, }, } } +func (SpecParallelTool) Parameters() map[string]interface{} { + return specParallelSchema.ToJSONSchema() +} + +// specParallelSchema is the single source of truth for SpecParallel's input schema. +var specParallelSchema = SpecParallelTool{}.Schema() + func (SpecParallelTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - DryRun bool `json:"dry_run"` - MaxParallel int `json:"max_parallel"` - } - if input != nil { - _ = json.Unmarshal(input, &p) + var p SpecParallelInput + if len(input) > 0 && string(input) != "null" { + decoded, err := DecodeInput[SpecParallelInput]("SpecParallel", input) + if err != nil { + return "", err + } + p = decoded } if p.MaxParallel <= 0 { p.MaxParallel = 8 diff --git a/internal/tool/spec_plan_variations.go b/internal/tool/spec_plan_variations.go index 10c174cb..0b9ce257 100644 --- a/internal/tool/spec_plan_variations.go +++ b/internal/tool/spec_plan_variations.go @@ -13,6 +13,13 @@ import ( type SpecPlanVariationsTool struct{} func (SpecPlanVariationsTool) Name() string { return "SpecPlanVariations" } + +// SpecPlanVariationsInput is the typed input for SpecPlanVariationsTool. +type SpecPlanVariationsInput struct { + Count int `json:"count"` + Selected int `json:"selected"` +} + func (SpecPlanVariationsTool) Aliases() []string { return []string{"spec_plan_variations", "spec:plan_variations"} } @@ -21,29 +28,33 @@ func (SpecPlanVariationsTool) Description() string { return "Generate multiple implementation plan variations for the active spec. Each variation emphasizes different tradeoffs: performance, simplicity, maintainability, or speed. Outputs a comparison matrix so the agent can choose the best approach." } -func (SpecPlanVariationsTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "count": map[string]interface{}{ - "type": "integer", - "description": "Number of variations to generate (2-4, default 3)", - }, - "selected": map[string]interface{}{ - "type": "integer", - "description": "Select a variation by number (1-based) to write as plan.md", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecPlanVariationsTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "count": {Type: "integer", Description: "Number of variations to generate (2-4, default 3)"}, + "selected": {Type: "integer", Description: "Select a variation by number (1-based) to write as plan.md"}, }, } } +func (SpecPlanVariationsTool) Parameters() map[string]interface{} { + return specPlanVariationsSchema.ToJSONSchema() +} + +// specPlanVariationsSchema is the single source of truth for SpecPlanVariations's input schema. +var specPlanVariationsSchema = SpecPlanVariationsTool{}.Schema() + func (SpecPlanVariationsTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Count int `json:"count"` - Selected int `json:"selected"` - } - if input != nil { - _ = json.Unmarshal(input, &p) + var p SpecPlanVariationsInput + if len(input) > 0 && string(input) != "null" { + decoded, err := DecodeInput[SpecPlanVariationsInput]("SpecPlanVariations", input) + if err != nil { + return "", err + } + p = decoded } if p.Count < 2 || p.Count > 4 { p.Count = 3 diff --git a/internal/tool/spec_progress.go b/internal/tool/spec_progress.go index affd2baa..bb0c778d 100644 --- a/internal/tool/spec_progress.go +++ b/internal/tool/spec_progress.go @@ -15,6 +15,13 @@ import ( type SpecProgressTool struct{} func (SpecProgressTool) Name() string { return "SpecProgress" } + +// SpecProgressInput is the typed input for SpecProgressTool. +type SpecProgressInput struct { + AutoUpdate bool `json:"auto_update"` + ScanDir string `json:"scan_dir"` +} + func (SpecProgressTool) Aliases() []string { return []string{"spec_progress", "spec:progress"} } @@ -23,29 +30,33 @@ func (SpecProgressTool) Description() string { return "Analyze implementation progress by scanning code for REQ citations. Marks tasks complete when their requirements are implemented. Reports completion percentage and remaining work." } -func (SpecProgressTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "auto_update": map[string]interface{}{ - "type": "boolean", - "description": "If true, automatically mark implemented tasks as complete in tasks.md", - }, - "scan_dir": map[string]interface{}{ - "type": "string", - "description": "Directory to scan for REQ citations (default: current directory)", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecProgressTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "auto_update": {Type: "boolean", Description: "If true, automatically mark implemented tasks as complete in tasks.md"}, + "scan_dir": {Type: "string", Description: "Directory to scan for REQ citations (default: current directory)"}, }, } } +func (SpecProgressTool) Parameters() map[string]interface{} { + return specProgressSchema.ToJSONSchema() +} + +// specProgressSchema is the single source of truth for SpecProgress's input schema. +var specProgressSchema = SpecProgressTool{}.Schema() + func (SpecProgressTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - AutoUpdate bool `json:"auto_update"` - ScanDir string `json:"scan_dir"` - } - if input != nil { - _ = json.Unmarshal(input, &p) + var p SpecProgressInput + if len(input) > 0 && string(input) != "null" { + decoded, err := DecodeInput[SpecProgressInput]("SpecProgress", input) + if err != nil { + return "", err + } + p = decoded } if p.ScanDir == "" { p.ScanDir, _ = os.Getwd() diff --git a/internal/tool/spec_properties.go b/internal/tool/spec_properties.go index 0dc4b8b7..f7366e7f 100644 --- a/internal/tool/spec_properties.go +++ b/internal/tool/spec_properties.go @@ -14,6 +14,14 @@ import ( type SpecPropertiesTool struct{} func (SpecPropertiesTool) Name() string { return "SpecProperties" } + +// SpecPropertiesInput is the typed input for SpecPropertiesTool. +type SpecPropertiesInput struct { + Action string `json:"action"` + Property string `json:"property"` + ReqID string `json:"req_id"` +} + func (SpecPropertiesTool) Aliases() []string { return []string{"spec_properties", "spec:properties"} } @@ -22,27 +30,26 @@ func (SpecPropertiesTool) Description() string { return "Define and verify formal correctness properties for specs. Properties are statements that must hold true for all valid executions, serving as the bridge between human-readable specifications and machine-verifiable correctness guarantees." } -func (SpecPropertiesTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "action": map[string]interface{}{ - "type": "string", - "description": "Action: define (add property), verify (check all), list (show properties), coverage (property coverage)", - "enum": []string{"define", "verify", "list", "coverage"}, - }, - "property": map[string]interface{}{ - "type": "string", - "description": "Property statement (required for define)", - }, - "req_id": map[string]interface{}{ - "type": "string", - "description": "Requirement ID this property validates", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecPropertiesTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "action": {Type: "string", Enum: []interface{}{"define", "verify", "list", "coverage"}, Description: "Action: define (add property), verify (check all), list (show properties), coverage (property coverage)"}, + "property": {Type: "string", Description: "Property statement (required for define)"}, + "req_id": {Type: "string", Description: "Requirement ID this property validates"}, }, } } +func (SpecPropertiesTool) Parameters() map[string]interface{} { + return specPropertiesSchema.ToJSONSchema() +} + +// specPropertiesSchema is the single source of truth for SpecProperties's input schema. +var specPropertiesSchema = SpecPropertiesTool{}.Schema() + type CorrectnessProperty struct { ID string `json:"id"` Statement string `json:"statement"` @@ -51,12 +58,8 @@ type CorrectnessProperty struct { } func (SpecPropertiesTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Action string `json:"action"` - Property string `json:"property"` - ReqID string `json:"req_id"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[SpecPropertiesInput]("SpecProperties", input) + if err != nil { return "", err } if p.Action == "" { diff --git a/internal/tool/spec_provenance.go b/internal/tool/spec_provenance.go index 6c5186f9..0812608f 100644 --- a/internal/tool/spec_provenance.go +++ b/internal/tool/spec_provenance.go @@ -13,6 +13,14 @@ import ( type SpecProvenanceTool struct{} func (SpecProvenanceTool) Name() string { return "SpecProvenance" } + +// SpecProvenanceInput is the typed input for SpecProvenanceTool. +type SpecProvenanceInput struct { + Action string `json:"action"` + File string `json:"file"` + Model string `json:"model"` +} + func (SpecProvenanceTool) Aliases() []string { return []string{"spec_provenance", "spec:provenance"} } @@ -21,27 +29,26 @@ func (SpecProvenanceTool) Description() string { return "Track provenance of AI-generated code. Records which model, prompt, and session generated each code artifact. Creates audit trails for compliance and debugging." } -func (SpecProvenanceTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "action": map[string]interface{}{ - "type": "string", - "description": "Action: record (log generation), show (view history), audit (compliance report)", - "enum": []string{"record", "show", "audit"}, - }, - "file": map[string]interface{}{ - "type": "string", - "description": "File that was generated/modified", - }, - "model": map[string]interface{}{ - "type": "string", - "description": "AI model used (e.g., claude-sonnet-4, gpt-4)", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecProvenanceTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "action": {Type: "string", Enum: []interface{}{"record", "show", "audit"}, Description: "Action: record (log generation), show (view history), audit (compliance report)"}, + "file": {Type: "string", Description: "File that was generated/modified"}, + "model": {Type: "string", Description: "AI model used (e.g., claude-sonnet-4, gpt-4)"}, }, } } +func (SpecProvenanceTool) Parameters() map[string]interface{} { + return specProvenanceSchema.ToJSONSchema() +} + +// specProvenanceSchema is the single source of truth for SpecProvenance's input schema. +var specProvenanceSchema = SpecProvenanceTool{}.Schema() + type ProvenanceEntry struct { Timestamp string `json:"timestamp"` File string `json:"file"` @@ -52,12 +59,8 @@ type ProvenanceEntry struct { } func (SpecProvenanceTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Action string `json:"action"` - File string `json:"file"` - Model string `json:"model"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[SpecProvenanceInput]("SpecProvenance", input) + if err != nil { return "", err } if p.Action == "" { diff --git a/internal/tool/spec_review.go b/internal/tool/spec_review.go index 7cd9c221..63afc9aa 100644 --- a/internal/tool/spec_review.go +++ b/internal/tool/spec_review.go @@ -14,6 +14,12 @@ import ( type SpecReviewTool struct{} func (SpecReviewTool) Name() string { return "SpecReview" } + +// SpecReviewInput is the typed input for SpecReviewTool. +type SpecReviewInput struct { + Scope string `json:"scope"` +} + func (SpecReviewTool) Aliases() []string { return []string{"spec_review", "spec:review"} } @@ -22,24 +28,27 @@ func (SpecReviewTool) Description() string { return "Post-implementation review against specs." } -func (SpecReviewTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "scope": map[string]interface{}{ - "type": "string", - "description": "Review scope: spec, diff, full", - "enum": []string{"spec", "diff", "full"}, - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecReviewTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "scope": {Type: "string", Enum: []interface{}{"spec", "diff", "full"}, Description: "Review scope: spec, diff, full"}, }, } } +func (SpecReviewTool) Parameters() map[string]interface{} { + return specReviewSchema.ToJSONSchema() +} + +// specReviewSchema is the single source of truth for SpecReview's input schema. +var specReviewSchema = SpecReviewTool{}.Schema() + func (SpecReviewTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Scope string `json:"scope"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[SpecReviewInput]("SpecReview", input) + if err != nil { return "", err } if p.Scope == "" { diff --git a/internal/tool/spec_scale.go b/internal/tool/spec_scale.go index 5c575eed..fae34643 100644 --- a/internal/tool/spec_scale.go +++ b/internal/tool/spec_scale.go @@ -12,6 +12,12 @@ import ( type SpecScaleTool struct{} func (SpecScaleTool) Name() string { return "SpecScale" } + +// SpecScaleInput is the typed input for SpecScaleTool. +type SpecScaleInput struct { + ScanDir string `json:"scan_dir"` +} + func (SpecScaleTool) Aliases() []string { return []string{"spec_scale", "spec:scale"} } @@ -20,18 +26,24 @@ func (SpecScaleTool) Description() string { return "Determine project complexity and recommend planning depth. Analyzes codebase size, change scope, dependency count, and risk to recommend Quick (bug fix), Standard (feature), or Enterprise (architecture) planning track." } -func (SpecScaleTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "scan_dir": map[string]interface{}{ - "type": "string", - "description": "Directory to scan (default: current directory)", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecScaleTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "scan_dir": {Type: "string", Description: "Directory to scan (default: current directory)"}, }, } } +func (SpecScaleTool) Parameters() map[string]interface{} { + return specScaleSchema.ToJSONSchema() +} + +// specScaleSchema is the single source of truth for SpecScale's input schema. +var specScaleSchema = SpecScaleTool{}.Schema() + type ComplexityAssessment struct { Level int `json:"level"` Track string `json:"track"` @@ -43,10 +55,8 @@ type ComplexityAssessment struct { } func (SpecScaleTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - ScanDir string `json:"scan_dir"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[SpecScaleInput]("SpecScale", input) + if err != nil { return "", err } if p.ScanDir == "" { diff --git a/internal/tool/spec_super.go b/internal/tool/spec_super.go index 7088cc69..e8cffa37 100644 --- a/internal/tool/spec_super.go +++ b/internal/tool/spec_super.go @@ -17,6 +17,13 @@ import ( type SpecSuperTool struct{} func (SpecSuperTool) Name() string { return "SpecSuper" } + +// SpecSuperInput is the typed input for SpecSuperTool. +type SpecSuperInput struct { + ScanDir string `json:"scan_dir"` + Language string `json:"language"` +} + func (SpecSuperTool) Aliases() []string { return []string{"spec_super", "spec:super"} } @@ -25,22 +32,25 @@ func (SpecSuperTool) Description() string { return "Evaluate codebase against S.U.P.E.R architectural principles: Single Purpose, Unidirectional Flow, Ports over Implementation, Environment-Agnostic, Replaceable Parts. Returns per-dimension scores and actionable recommendations." } -func (SpecSuperTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "scan_dir": map[string]interface{}{ - "type": "string", - "description": "Directory to scan (default: current directory)", - }, - "language": map[string]interface{}{ - "type": "string", - "description": "Language to analyze: go, ts, py (default: auto-detect)", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecSuperTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "scan_dir": {Type: "string", Description: "Directory to scan (default: current directory)"}, + "language": {Type: "string", Description: "Language to analyze: go, ts, py (default: auto-detect)"}, }, } } +func (SpecSuperTool) Parameters() map[string]interface{} { + return specSuperSchema.ToJSONSchema() +} + +// specSuperSchema is the single source of truth for SpecSuper's input schema. +var specSuperSchema = SpecSuperTool{}.Schema() + type SuperScore struct { SinglePurpose float64 `json:"single_purpose"` Unidirectional float64 `json:"unidirectional"` @@ -58,12 +68,13 @@ type SuperFinding struct { } func (SpecSuperTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - ScanDir string `json:"scan_dir"` - Language string `json:"language"` - } - if input != nil { - _ = json.Unmarshal(input, &p) + var p SpecSuperInput + if len(input) > 0 && string(input) != "null" { + decoded, err := DecodeInput[SpecSuperInput]("SpecSuper", input) + if err != nil { + return "", err + } + p = decoded } if p.ScanDir == "" { p.ScanDir, _ = os.Getwd() diff --git a/internal/tool/spec_tasks_to_issues.go b/internal/tool/spec_tasks_to_issues.go index 7a796d51..81829e7f 100644 --- a/internal/tool/spec_tasks_to_issues.go +++ b/internal/tool/spec_tasks_to_issues.go @@ -17,6 +17,13 @@ import ( type TasksToIssuesTool struct{} func (TasksToIssuesTool) Name() string { return "TasksToIssues" } + +// TasksToIssuesInput is the typed input for TasksToIssuesTool. +type TasksToIssuesInput struct { + DryRun bool `json:"dry_run"` + Labels string `json:"labels"` +} + func (TasksToIssuesTool) Aliases() []string { return []string{"tasks_to_issues", "spec:tasks-to-issues"} } @@ -25,29 +32,33 @@ func (TasksToIssuesTool) Description() string { return "Convert unchecked tasks from tasks.md into GitHub issues. Requires `gh` CLI authenticated. Each task becomes an issue with labels derived from its phase heading." } -func (TasksToIssuesTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "dry_run": map[string]interface{}{ - "type": "boolean", - "description": "If true, show what issues would be created without actually creating them", - }, - "labels": map[string]interface{}{ - "type": "string", - "description": "Comma-separated additional labels to add to all issues (e.g., 'spec,needs-review')", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (TasksToIssuesTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "dry_run": {Type: "boolean", Description: "If true, show what issues would be created without actually creating them"}, + "labels": {Type: "string", Description: "Comma-separated additional labels to add to all issues (e.g., 'spec,needs-review')"}, }, } } +func (TasksToIssuesTool) Parameters() map[string]interface{} { + return tasksToIssuesSchema.ToJSONSchema() +} + +// tasksToIssuesSchema is the single source of truth for TasksToIssues's input schema. +var tasksToIssuesSchema = TasksToIssuesTool{}.Schema() + func (TasksToIssuesTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - DryRun bool `json:"dry_run"` - Labels string `json:"labels"` - } - if input != nil { - _ = json.Unmarshal(input, &p) + var p TasksToIssuesInput + if len(input) > 0 && string(input) != "null" { + decoded, err := DecodeInput[TasksToIssuesInput]("TasksToIssues", input) + if err != nil { + return "", err + } + p = decoded } // Check gh CLI availability diff --git a/internal/tool/spec_testgen.go b/internal/tool/spec_testgen.go index 95a1d8d4..4c35d3c4 100644 --- a/internal/tool/spec_testgen.go +++ b/internal/tool/spec_testgen.go @@ -14,6 +14,13 @@ import ( type SpecTestGenTool struct{} func (SpecTestGenTool) Name() string { return "SpecTestGen" } + +// SpecTestGenInput is the typed input for SpecTestGenTool. +type SpecTestGenInput struct { + ScanDir string `json:"scan_dir"` + Language string `json:"language"` +} + func (SpecTestGenTool) Aliases() []string { return []string{"spec_testgen", "spec:testgen"} } @@ -22,29 +29,28 @@ func (SpecTestGenTool) Description() string { return "Generate test stubs from requirements in spec.md. Creates test functions for each REQ-XXX.Y.Z requirement." } -func (SpecTestGenTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "scan_dir": map[string]interface{}{ - "type": "string", - "description": "Directory to scan (default: current directory)", - }, - "language": map[string]interface{}{ - "type": "string", - "description": "Language: go, ts, py (default: auto-detect)", - "enum": []string{"go", "ts", "py"}, - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecTestGenTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "scan_dir": {Type: "string", Description: "Directory to scan (default: current directory)"}, + "language": {Type: "string", Enum: []interface{}{"go", "ts", "py"}, Description: "Language: go, ts, py (default: auto-detect)"}, }, } } +func (SpecTestGenTool) Parameters() map[string]interface{} { + return specTestGenSchema.ToJSONSchema() +} + +// specTestGenSchema is the single source of truth for SpecTestGen's input schema. +var specTestGenSchema = SpecTestGenTool{}.Schema() + func (SpecTestGenTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - ScanDir string `json:"scan_dir"` - Language string `json:"language"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[SpecTestGenInput]("SpecTestGen", input) + if err != nil { return "", err } if p.ScanDir == "" { diff --git a/internal/tool/spec_trace.go b/internal/tool/spec_trace.go index 31f370ab..1deed72b 100644 --- a/internal/tool/spec_trace.go +++ b/internal/tool/spec_trace.go @@ -14,6 +14,13 @@ import ( type SpecTraceTool struct{} func (SpecTraceTool) Name() string { return "SpecTrace" } + +// SpecTraceInput is the typed input for SpecTraceTool. +type SpecTraceInput struct { + Action string `json:"action"` + ScanDir string `json:"scan_dir"` +} + func (SpecTraceTool) Aliases() []string { return []string{"spec_trace", "spec:trace"} } @@ -22,23 +29,25 @@ func (SpecTraceTool) Description() string { return "Requirements Traceability Matrix (RTM). Maps requirements to design decisions, implementation files, and tests. Supports forward (req->code), backward (test->req), and bidirectional traceability. Flags gaps and orphans." } -func (SpecTraceTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "action": map[string]interface{}{ - "type": "string", - "description": "Action: matrix (full RTM), forward (req->code), backward (test->req), gaps (only gaps)", - "enum": []string{"matrix", "forward", "backward", "gaps"}, - }, - "scan_dir": map[string]interface{}{ - "type": "string", - "description": "Directory to scan (default: current directory)", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecTraceTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "action": {Type: "string", Enum: []interface{}{"matrix", "forward", "backward", "gaps"}, Description: "Action: matrix (full RTM), forward (req->code), backward (test->req), gaps (only gaps)"}, + "scan_dir": {Type: "string", Description: "Directory to scan (default: current directory)"}, }, } } +func (SpecTraceTool) Parameters() map[string]interface{} { + return specTraceSchema.ToJSONSchema() +} + +// specTraceSchema is the single source of truth for SpecTrace's input schema. +var specTraceSchema = SpecTraceTool{}.Schema() + type TraceLink struct { ReqID string `json:"req_id"` DesignRef string `json:"design_ref,omitempty"` @@ -56,11 +65,8 @@ type TraceMatrix struct { } func (SpecTraceTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Action string `json:"action"` - ScanDir string `json:"scan_dir"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[SpecTraceInput]("SpecTrace", input) + if err != nil { return "", err } if p.Action == "" { diff --git a/internal/tool/spec_version.go b/internal/tool/spec_version.go index 66cfb2c1..74a0c813 100644 --- a/internal/tool/spec_version.go +++ b/internal/tool/spec_version.go @@ -15,6 +15,14 @@ import ( type SpecVersionTool struct{} func (SpecVersionTool) Name() string { return "SpecVersion" } + +// SpecVersionInput is the typed input for SpecVersionTool. +type SpecVersionInput struct { + Message string `json:"message"` + DryRun bool `json:"dry_run"` + IncludeSpecs bool `json:"include_specs"` +} + func (SpecVersionTool) Aliases() []string { return []string{"spec_version", "spec:version"} } @@ -23,34 +31,34 @@ func (SpecVersionTool) Description() string { return "Stage and commit spec artifacts alongside code changes. Ensures specs are versioned in git with proper REQ references in commit messages. Use after implementation to commit spec + code together." } -func (SpecVersionTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "message": map[string]interface{}{ - "type": "string", - "description": "Custom commit message (optional, auto-generated if empty)", - }, - "dry_run": map[string]interface{}{ - "type": "boolean", - "description": "If true, show what would be committed without actually committing", - }, - "include_specs": map[string]interface{}{ - "type": "boolean", - "description": "If true, include .rho/specs/ in the commit (default true)", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (SpecVersionTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "message": {Type: "string", Description: "Custom commit message (optional, auto-generated if empty)"}, + "dry_run": {Type: "boolean", Description: "If true, show what would be committed without actually committing"}, + "include_specs": {Type: "boolean", Description: "If true, include .rho/specs/ in the commit (default true)"}, }, } } +func (SpecVersionTool) Parameters() map[string]interface{} { + return specVersionSchema.ToJSONSchema() +} + +// specVersionSchema is the single source of truth for SpecVersion's input schema. +var specVersionSchema = SpecVersionTool{}.Schema() + func (SpecVersionTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Message string `json:"message"` - DryRun bool `json:"dry_run"` - IncludeSpecs bool `json:"include_specs"` - } - if input != nil { - _ = json.Unmarshal(input, &p) + var p SpecVersionInput + if len(input) > 0 && string(input) != "null" { + decoded, err := DecodeInput[SpecVersionInput]("SpecVersion", input) + if err != nil { + return "", err + } + p = decoded } if !p.IncludeSpecs { p.IncludeSpecs = true