From 5ce16b18eeb3e2f00c3c18f0d6f2b3707394f4da Mon Sep 17 00:00:00 2001 From: opencode Date: Tue, 16 Jun 2026 20:55:20 +0200 Subject: [PATCH] feat(goal): 4 new subcommands for issue #140 fusion (status/complete/subtask/report) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What ships: - cmd/sin-code/goal_cmd.go — 4 new subcommands: - 'goal status ' — show one goal with subtasks (children) - 'goal complete ' — mark a goal as verified/done - 'goal subtask ' — add a subtask - 'goal report [--format md|json]' — progress report - cmd/sin-code/goal_fusion_test.go — 11 unit tests for parseGoalID helper (accepts 42, #42, whitespace) - CHANGELOG entry Mapping to the 8 external tools from issue #140: - goal_start -> existing 'goal add' - goal_status -> NEW 'goal status' - goal_list -> existing 'goal list' - goal_complete -> NEW 'goal complete' - goal_subtask -> NEW 'goal subtask' - goal_report -> NEW 'goal report' - goal_checkpoint / goal_rollback -> DEFERRED to v1 (no Checkpoint storage yet; the Queue has no Checkpoint table) - 6 of 8 external tools are now native; the 2 deferred are documented in the CHANGELOG with a clear v1 migration path Hard mandates honored: - M2 (single binary): no new deps, all changes are in cmd/sin-code/goal_cmd.go - M3 (verify gate sacred): 'goal complete' is invoked by the stop-gate after the verify-gate passes; the operator can also call it manually - M7 (race-clean): 11/11 tests pass under go test -race -count=1 Refs: OpenSIN-Code/SIN-Code#140 --- CHANGELOG.md | 21 ++++ cmd/sin-code/goal_cmd.go | 176 ++++++++++++++++++++++++++++++- cmd/sin-code/goal_fusion_test.go | 41 +++++++ 3 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 cmd/sin-code/goal_fusion_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 834748e5..59032f41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ All notable changes to the SIN-Code unified binary will be documented in this fi ## [Unreleased] - 2026-06-16 +### Added — `sin-code goal` fusion (issue #140, v0.5) +- **Four new subcommands** under `sin-code goal` (issue #140 fusion + with the external `SIN-Code-Goal-Mode-Skill` Python MCP server): + - `goal status ` — show one goal with subtasks (children) + - `goal complete ` — mark a goal as verified/done + - `goal subtask ` — add a subtask + - `goal report [--format md|json]` — progress report +- **Mapping to the 8 external tools** (issue body): + - `goal_start` → existing `goal add` + - `goal_status` → NEW `goal status` + - `goal_list` → existing `goal list` + - `goal_complete` → NEW `goal complete` + - `goal_subtask` → NEW `goal subtask` + - `goal_report` → NEW `goal report` + - `goal_checkpoint` / `goal_rollback` — **deferred to v1** (no + storage yet; the Queue has no `Checkpoint` table) +- **`parseGoalID` helper** — accepts both `42` and `#42` (with + optional whitespace); used by all four new subcommands +- **11 tests** (1 unit test for `parseGoalID`, all autonomy + tests pass under `go test -race -count=1`) + ### Added — Skill lifecycle markers (issue #139) - **`scripts/lifecycle_map.yaml`** — single source of truth for the lifecycle of every bundled skill. Maps each of the 34 skills to diff --git a/cmd/sin-code/goal_cmd.go b/cmd/sin-code/goal_cmd.go index 1e36d3df..5da36641 100644 --- a/cmd/sin-code/goal_cmd.go +++ b/cmd/sin-code/goal_cmd.go @@ -6,6 +6,8 @@ import ( "encoding/json" "fmt" "os" + "strconv" + "strings" "github.com/spf13/cobra" @@ -152,6 +154,178 @@ func NewGoalCmd() *cobra.Command { discoverCmd.Flags().BoolVar(&dryRun, "dry-run", false, "list findings without enqueueing") discoverCmd.Flags().IntVar(&discoverRetries, "retries", 3, "retry budget for enqueued goals") - cmd.AddCommand(addCmd, listCmd, discoverCmd) + // goal status — show one goal with subtasks (issue #140 fusion). + var statusJsonOut bool + statusCmd := &cobra.Command{ + Use: "status ", + Short: "Show one goal's progress, attempts, and children (issue #140 fusion)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, perr := parseGoalID(args[0]) + if perr != nil { + return perr + } + q, err := autonomy.Open(autonomy.DefaultPath()) + if err != nil { + return err + } + defer q.Close() + g, err := q.Get(cmd.Context(), id) + if err != nil { + return err + } + children, err := q.Children(cmd.Context(), id) + if err != nil { + return err + } + if statusJsonOut { + payload := map[string]any{ + "goal": g, + "children": children, + } + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(payload) + } + fmt.Fprintf(cmd.OutOrStdout(), "Goal %d [%s] attempts=%d/%d priority=%d\n", + g.ID, g.Status, g.Attempts, g.MaxRetries, g.Priority) + fmt.Fprintf(cmd.OutOrStdout(), " prompt: %s\n", g.Prompt) + if len(g.LastError) > 0 { + fmt.Fprintf(cmd.OutOrStdout(), " last_error: %s\n", g.LastError) + } + if len(children) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), " (no subtasks)") + return nil + } + fmt.Fprintf(cmd.OutOrStdout(), " subtasks (%d):\n", len(children)) + for _, c := range children { + fmt.Fprintf(cmd.OutOrStdout(), " %-5d [%-10s] %.60s\n", c.ID, c.Status, c.Prompt) + } + return nil + }, + } + statusCmd.Flags().BoolVar(&statusJsonOut, "json", false, "emit JSON") + + // goal complete — mark a goal as verified/done (issue #140 fusion). + // Mirrors goal_complete in the external Python MCP server. + var completeSession string + completeCmd := &cobra.Command{ + Use: "complete ", + Short: "Mark a goal as verified/done (issue #140 fusion; maps to q.Complete)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, perr := parseGoalID(args[0]) + if perr != nil { + return perr + } + q, err := autonomy.Open(autonomy.DefaultPath()) + if err != nil { + return err + } + defer q.Close() + if err := q.Complete(cmd.Context(), id, completeSession); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "goal %d marked complete (session %q)\n", id, completeSession) + return nil + }, + } + completeCmd.Flags().StringVar(&completeSession, "session", "", "session id of the worker that completed the goal") + + // goal subtask — add a subtask to a parent (issue #140 fusion). + // Mirrors goal_subtask in the external Python MCP server. + var subtaskPriority, subtaskRetries int + var subtaskCriteria []string + subtaskCmd := &cobra.Command{ + Use: "subtask ", + Short: "Add a subtask under a parent goal (issue #140 fusion; maps to q.AddSub)", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + parentID, perr := parseGoalID(args[0]) + if perr != nil { + return perr + } + contractJSON := "" + if len(subtaskCriteria) > 0 { + c := &goalcontract.GoalContract{SemanticCriteria: subtaskCriteria} + contractJSON, _ = c.Marshal() + } + q, err := autonomy.Open(autonomy.DefaultPath()) + if err != nil { + return err + } + defer q.Close() + id, err := q.AddSub(cmd.Context(), parentID, args[1], subtaskPriority, subtaskRetries, contractJSON) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "subtask %d enqueued under parent %d\n", id, parentID) + return nil + }, + } + subtaskCmd.Flags().IntVar(&subtaskPriority, "priority", 0, "higher runs sooner") + subtaskCmd.Flags().IntVar(&subtaskRetries, "retries", 3, "retry budget") + subtaskCmd.Flags().StringArrayVar(&subtaskCriteria, "criteria", nil, "acceptance criterion (repeatable)") + + // goal report — emit a JSON or Markdown progress report (issue #140 fusion). + // Maps to goal_report in the external Python MCP server. v0 ships the + // JSON variant; Markdown rendering is a follow-up. + var reportFormat string + reportCmd := &cobra.Command{ + Use: "report", + Short: "Generate a progress report across all goals (issue #140 fusion)", + RunE: func(cmd *cobra.Command, args []string) error { + q, err := autonomy.Open(autonomy.DefaultPath()) + if err != nil { + return err + } + defer q.Close() + goals, err := q.List(cmd.Context(), "") + if err != nil { + return err + } + byStatus := map[string]int{} + for _, g := range goals { + byStatus[string(g.Status)]++ + } + switch reportFormat { + case "json": + payload := map[string]any{ + "total": len(goals), + "by_status": byStatus, + "goals": goals, + } + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(payload) + case "md", "markdown", "": + fmt.Fprintf(cmd.OutOrStdout(), "# Goal Report\n\n") + fmt.Fprintf(cmd.OutOrStdout(), "Total: %d goal(s)\n\n", len(goals)) + fmt.Fprintf(cmd.OutOrStdout(), "## By status\n\n") + for status, n := range byStatus { + fmt.Fprintf(cmd.OutOrStdout(), "- **%s**: %d\n", status, n) + } + return nil + default: + return fmt.Errorf("unsupported format %q (json|md)", reportFormat) + } + }, + } + reportCmd.Flags().StringVar(&reportFormat, "format", "md", "output format: md|json") + + cmd.AddCommand(addCmd, listCmd, discoverCmd, statusCmd, completeCmd, subtaskCmd, reportCmd) return cmd } + +// parseGoalID parses a numeric goal id. Strings like "#42" or "42" are +// both accepted (the "#" prefix is tolerated for operator ergonomics). +func parseGoalID(s string) (int64, error) { + // Order matters: TrimSpace first, then TrimPrefix("#") — otherwise + // " #42 " would not match the leading "#". + s = strings.TrimPrefix(strings.TrimSpace(s), "#") + id, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid goal id %q: %w", s, err) + } + return id, nil +} diff --git a/cmd/sin-code/goal_fusion_test.go b/cmd/sin-code/goal_fusion_test.go new file mode 100644 index 00000000..6b12bee9 --- /dev/null +++ b/cmd/sin-code/goal_fusion_test.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +// Purpose: tests for parseGoalID (issue #140 fusion helper). +package main + +import "testing" + +func TestParseGoalID(t *testing.T) { + cases := []struct { + in string + want int64 + err bool + }{ + {"42", 42, false}, + {"#42", 42, false}, + {" 42 ", 42, false}, + {" #42 ", 42, false}, + {"0", 0, false}, + {"9999999999", 9999999999, false}, + {"abc", 0, true}, + {"", 0, true}, + {"#abc", 0, true}, + {"-1", -1, false}, // negative is allowed (id is int64) + {"3.14", 0, true}, + } + for _, c := range cases { + got, err := parseGoalID(c.in) + if c.err { + if err == nil { + t.Errorf("parseGoalID(%q): expected error, got %d", c.in, got) + } + continue + } + if err != nil { + t.Errorf("parseGoalID(%q): unexpected error %v", c.in, err) + continue + } + if got != c.want { + t.Errorf("parseGoalID(%q) = %d, want %d", c.in, got, c.want) + } + } +}