diff --git a/CHANGELOG.md b/CHANGELOG.md index 59032f41..c19e5245 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ All notable changes to the SIN-Code unified binary will be documented in this fi ## [Unreleased] - 2026-06-16 +### Added — `sin-code grill` (issue #141 fusion, native implementation) +- **New package** `cmd/sin-code/internal/grill/` with 4 source + files (`types.go`, `catalog.go`, `manager.go`, `grill_test.go`) + + 14 race-clean unit tests. The native Go implementation of + the external `SIN-Code-Grill-Me-Skill` Python MCP server + (38 KB). Ships in v0 with JSON-file storage; SQLite session + storage is a v1 follow-up. +- **New subcommand** `sin-code grill` (5 subcommands): + - `grill start ` — begin a grilling session, print the id + - `grill next ` — ask the next adversarial question + - `grill answer ` — record the response + (use "done" to resolve a decision) + - `grill status ` — show resolved + open decisions + - `grill synthesize [--json]` — produce a structured + summary of decisions, assumptions, and open questions +- **Question catalog** — 8 seed anti-patterns (Hidden + Assumptions, Rollback Plan, Failure Modes, Operator Cost, + Premature Optimization, Scope Creep, Single Point of Failure, + Verification Gap). Each has 2-3 example questions. The CLI + picks one per `grill next` call (hash-seeded for determinism). +- **Storage** — `$SIN_CODE_HOME/grill/.json`, atomic writes + (temp + rename). v1 will migrate to SQLite via the existing + `internal/session/store`. +- **14 race-clean tests** covering the full flow (Start → Next → + Answer → Synthesize → round-trip across restarts). + ### 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): diff --git a/cmd/sin-code/grill_cmd.go b/cmd/sin-code/grill_cmd.go new file mode 100644 index 00000000..3dd49c31 --- /dev/null +++ b/cmd/sin-code/grill_cmd.go @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: MIT +// Purpose: `sin-code grill` — native adversarial design-review interview +// (issue #141 fusion with the external SIN-Code-Grill-Me-Skill +// Python MCP server). Replaces the bundled SKILL.md doc with a +// real subcommand. +// +// Subcommands: +// +// sin-code grill start begin a grilling session +// sin-code grill next ask the next adversarial question +// sin-code grill answer record the operator's response +// sin-code grill status show resolved + open decision branches +// sin-code grill synthesize produce a summary of decisions +// +// Sessions are stored in $SIN_CODE_HOME/grill/.json as +// content-addressed JSON files. v0 ships in-memory + JSON storage; +// the issue's "SQLite session/ledger DB" target is a follow-up +// (issue #141 follow-up #1: rewire to internal/session/store). +// +// Docs: cmd/sin-code/internal/grill/grill.doc.md +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/grill" +) + +// grillDir returns the directory for grilling sessions. Honors +// SIN_CODE_HOME, then XDG_DATA_HOME, then ~/.local/share/sin-code/grill. +func grillDir() (string, error) { + if v := os.Getenv("SIN_CODE_HOME"); v != "" { + return filepath.Join(v, "grill"), nil + } + if v := os.Getenv("XDG_DATA_HOME"); v != "" { + return filepath.Join(v, "sin-code", "grill"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".local", "share", "sin-code", "grill"), nil +} + +// NewGrillCmd builds the `grill` cobra subcommand. +func NewGrillCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "grill", + Short: "Native adversarial design-review interview (issue #141 fusion)", + Long: `sin-code grill is the native Go implementation of the +external SIN-Code-Grill-Me-Skill Python MCP server. Use it to +stress-test a plan, design, or decision before building it. + +Subcommands: + start begin a grilling session, print the session id + next ask the next adversarial question + answer record the operator's response + (use "done" to resolve a decision) + status show resolved + open decision branches + synthesize produce a summary of decisions + assumptions`, + } + cmd.AddCommand(newGrillStartCmd()) + cmd.AddCommand(newGrillNextCmd()) + cmd.AddCommand(newGrillAnswerCmd()) + cmd.AddCommand(newGrillStatusCmd()) + cmd.AddCommand(newGrillSynthesizeCmd()) + return cmd +} + +func newGrillStartCmd() *cobra.Command { + return &cobra.Command{ + Use: "start ", + Short: "Begin a grilling session on the given topic", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + dir, err := grillDir() + if err != nil { + return err + } + m, err := grill.NewManager(dir) + if err != nil { + return err + } + s, err := m.Start(args[0]) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "started session %s\n", s.ID) + fmt.Fprintf(cmd.OutOrStdout(), " topic: %s\n", s.Topic) + fmt.Fprintf(cmd.OutOrStdout(), " seed question: %s\n", s.Decisions[0].Question) + fmt.Fprintln(cmd.OutOrStdout(), "") + fmt.Fprintf(cmd.OutOrStdout(), " next: sin-code grill next %s\n", s.ID) + return nil + }, + } +} + +func newGrillNextCmd() *cobra.Command { + return &cobra.Command{ + Use: "next ", + Short: "Ask the next adversarial question", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + dir, err := grillDir() + if err != nil { + return err + } + m, err := grill.NewManager(dir) + if err != nil { + return err + } + child, parent, err := m.Next(args[0]) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "sub-question under %s:\n", parent) + fmt.Fprintf(cmd.OutOrStdout(), " %s: %s\n", child.ID, child.Question) + fmt.Fprintln(cmd.OutOrStdout(), "") + fmt.Fprintf(cmd.OutOrStdout(), " answer: sin-code grill answer %s %s \"\"\n", args[0], child.ID) + fmt.Fprintf(cmd.OutOrStdout(), " resolve: sin-code grill answer %s %s done\n", args[0], child.ID) + return nil + }, + } +} + +func newGrillAnswerCmd() *cobra.Command { + return &cobra.Command{ + Use: "answer ", + Short: "Record the operator's response to a decision (use 'done' to resolve)", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + dir, err := grillDir() + if err != nil { + return err + } + m, err := grill.NewManager(dir) + if err != nil { + return err + } + if err := m.Answer(args[0], args[1], args[2]); err != nil { + return err + } + action := "answered" + if args[2] == "done" || args[2] == "skip" { + action = "resolved" + } + fmt.Fprintf(cmd.OutOrStdout(), "%s decision %s in session %s\n", action, args[1], args[0]) + return nil + }, + } +} + +func newGrillStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status ", + Short: "Show resolved + open decision branches", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + dir, err := grillDir() + if err != nil { + return err + } + m, err := grill.NewManager(dir) + if err != nil { + return err + } + s, err := m.Get(args[0]) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Session %s\n", s.ID) + fmt.Fprintf(cmd.OutOrStdout(), " topic: %s\n", s.Topic) + fmt.Fprintf(cmd.OutOrStdout(), " started: %s\n", s.StartedAt.Format("2006-01-02 15:04:05")) + fmt.Fprintf(cmd.OutOrStdout(), " decisions: %d (open=%d)\n", len(s.Decisions), s.OpenQuestions) + for _, d := range s.Decisions { + marker := "[ ]" + switch d.Status { + case "answered": + marker = "[~]" + case "resolved": + marker = "[x]" + case "deferred": + marker = "[>]" + } + fmt.Fprintf(cmd.OutOrStdout(), " %s %s: %s\n", marker, d.ID, d.Question) + if d.Answer != "" { + fmt.Fprintf(cmd.OutOrStdout(), " answer: %s\n", d.Answer) + } + } + return nil + }, + } +} + +func newGrillSynthesizeCmd() *cobra.Command { + var jsonOut bool + cmd := &cobra.Command{ + Use: "synthesize ", + Short: "Produce a summary of decisions, assumptions, and open questions", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + dir, err := grillDir() + if err != nil { + return err + } + m, err := grill.NewManager(dir) + if err != nil { + return err + } + syn, err := m.Synthesize(args[0]) + if err != nil { + return err + } + if jsonOut { + enc := json.NewEncoder(c.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(syn) + } + fmt.Fprintln(c.OutOrStdout(), "# Grilling Synthesis") + fmt.Fprintln(c.OutOrStdout(), "") + fmt.Fprintln(c.OutOrStdout(), "## Resolved") + for _, r := range syn.Resolved { + fmt.Fprintf(c.OutOrStdout(), "- %s\n", r) + } + if len(syn.Assumptions) > 0 { + fmt.Fprintln(c.OutOrStdout(), "") + fmt.Fprintln(c.OutOrStdout(), "## Assumptions") + for _, a := range syn.Assumptions { + fmt.Fprintf(c.OutOrStdout(), "- %s\n", a) + } + } + if len(syn.Open) > 0 { + fmt.Fprintln(c.OutOrStdout(), "") + fmt.Fprintln(c.OutOrStdout(), "## Open") + for _, o := range syn.Open { + fmt.Fprintf(c.OutOrStdout(), "- %s\n", o) + } + } + return nil + }, + } + cmd.Flags().BoolVar(&jsonOut, "json", false, "emit JSON") + return cmd +} diff --git a/cmd/sin-code/internal/grill/catalog.go b/cmd/sin-code/internal/grill/catalog.go new file mode 100644 index 00000000..72f1490f --- /dev/null +++ b/cmd/sin-code/internal/grill/catalog.go @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +// Purpose: question catalog — the canonical anti-patterns and +// grilling questions (issue #141). Ported from the external +// SIN-Code-Grill-Me-Skill catalog. v0 ships a small seed; v1 will +// import the full list from the upstream SKILL.md. +// +// The catalog is intentionally a Go slice (not a YAML file) so +// that: +// 1. The CLI has zero file-deps for `grill next`. +// 2. The questions are typed (no string-template magic). +// 3. Operators can grep the binary for the question set. +package grill + +// AntiPattern is one entry in the catalog: the "what to look for" +// plus 2-3 example questions. The CLI picks one anti-pattern at +// random (or round-robin) per `grill next` call. +type AntiPattern struct { + Name string + Description string + Questions []string +} + +// Catalog is the seed of anti-patterns the v0 grill ships with. +// v1 will import the full upstream catalog and add domain-specific +// patterns (security, perf, ergonomics, etc.). +var Catalog = []AntiPattern{ + { + Name: "Hidden Assumptions", + Description: "The plan depends on conditions the operator hasn't verified yet.", + Questions: []string{ + "What does this plan assume about the operator's environment that you have not verified?", + "If the assumption is wrong, what's the failure mode?", + "Could you state the assumption in a single sentence and label it 'unverified'?", + }, + }, + { + Name: "Rollback Plan", + Description: "The plan has no path back if it goes wrong.", + Questions: []string{ + "If the first step of this plan goes wrong, what do you do?", + "Can the operator undo step 1 cleanly, or is it a one-way door?", + "Would adding a checkpoint before step 2 change the operator's confidence?", + }, + }, + { + Name: "Failure Modes", + Description: "The plan only describes the happy path.", + Questions: []string{ + "List the three most likely ways this plan fails.", + "For each failure, is it silent (no signal) or loud (error/log)?", + "Which failure would the operator discover last?", + }, + }, + { + Name: "Operator Cost", + Description: "The plan costs the operator more than it returns.", + Questions: []string{ + "How many operator-hours does this plan consume?", + "How many operator-hours does it save per occurrence?", + "At what frequency of occurrence is the saving worth the cost?", + }, + }, + { + Name: "Premature Optimization", + Description: "The plan optimizes a non-bottleneck.", + Questions: []string{ + "What is the slowest step of this plan?", + "Is that the same as the most expensive step?", + "Would removing the optimization make the plan simpler without losing the win?", + }, + }, + { + Name: "Scope Creep", + Description: "The plan grows beyond its stated goal.", + Questions: []string{ + "What is the smallest version of this plan that delivers the stated value?", + "Which of the 'nice to have' items can be deferred?", + "If you could only ship one feature, which one is it?", + }, + }, + { + Name: "Single Point of Failure", + Description: "The plan has a single point whose failure cascades.", + Questions: []string{ + "Which component, if it fails, takes down the whole plan?", + "Is the SPOF documented in the runbook?", + "Could you remove the SPOF or replace it with a redundant subsystem?", + }, + }, + { + Name: "Verification Gap", + Description: "The plan claims to verify, but the verification is itself unverified.", + Questions: []string{ + "How do you know the verification works?", + "Has the verification ever caught a failure?", + "What would a failure of the verification look like?", + }, + }, +} diff --git a/cmd/sin-code/internal/grill/doc.go b/cmd/sin-code/internal/grill/doc.go new file mode 100644 index 00000000..bf98615f --- /dev/null +++ b/cmd/sin-code/internal/grill/doc.go @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +// Purpose: grill — adversarial design-review interview session manager +// (issue #141 fusion). The native Go implementation of the external +// SIN-Code-Grill-Me-Skill Python MCP server. +// +// A grilling session is a multi-turn Q&A between the operator and +// the binary. The session keeps a tree of decisions: each answer +// can branch into sub-questions (e.g. "what about edge case X?") +// or resolve the parent decision. +// +// v0 storage: in-memory + JSON file via the Persister interface +// (same pattern as internal/rag). The issue body's "SQLite session/ +// ledger DB" target is a follow-up: wire it to internal/session/store +// once the schema is stable. For now the JSON file is human- +// inspectable and ≤ 1 KB per session. +// +// Mandates (issue #141): +// - M2: no new third-party deps, no CGO. +// - M5: this package lives under cmd/sin-code/internal/grill/. +// - M6: reuses the existing session/ledger package for storage +// (deferred to v1). +// +// Docs: grill.doc.md +package grill diff --git a/cmd/sin-code/internal/grill/grill_test.go b/cmd/sin-code/internal/grill/grill_test.go new file mode 100644 index 00000000..80a39d91 --- /dev/null +++ b/cmd/sin-code/internal/grill/grill_test.go @@ -0,0 +1,428 @@ +// SPDX-License-Identifier: MIT +// Purpose: tests for the grill package (issue #141 fusion). The +// round-trip test (TestSession_RoundTrip) is load-bearing: a session +// is written to disk, loaded back, and must be identical. +package grill + +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" + "time" +) + +func newTestManager(t *testing.T) *Manager { + t.Helper() + dir := t.TempDir() + m, err := NewManager(dir) + if err != nil { + t.Fatal(err) + } + return m +} + +func TestStart_CreatesSessionWithSeedQuestion(t *testing.T) { + m := newTestManager(t) + s, err := m.Start("add OAuth login") + if err != nil { + t.Fatal(err) + } + if s.Topic != "add OAuth login" { + t.Errorf("expected topic=add OAuth login, got %q", s.Topic) + } + if s.ID == "" { + t.Error("expected non-empty id") + } + if len(s.Decisions) != 1 { + t.Errorf("expected 1 seed decision, got %d", len(s.Decisions)) + } + if s.Decisions[0].Status != "open" { + t.Errorf("expected seed status=open, got %q", s.Decisions[0].Status) + } + if !strings.Contains(s.Decisions[0].Question, "add OAuth login") { + t.Errorf("expected question to mention topic, got %q", s.Decisions[0].Question) + } + if s.OpenQuestions != 1 { + t.Errorf("expected OpenQuestions=1, got %d", s.OpenQuestions) + } +} + +func TestStart_EmptyTopicErrors(t *testing.T) { + m := newTestManager(t) + if _, err := m.Start(""); err == nil { + t.Error("expected error on empty topic") + } +} + +func TestStart_UniqueIDsForSameTopic(t *testing.T) { + // Two starts of the same topic must produce different session IDs + // (the time salt makes them unique even within the same second). + m := newTestManager(t) + s1, _ := m.Start("same topic") + // Force a different timestamp by sleeping > 0 seconds. We use + // 1ms to keep the test fast. + time.Sleep(time.Millisecond) + s2, _ := m.Start("same topic") + if s1.ID == s2.ID { + t.Error("expected different session IDs") + } +} + +func TestNext_AppendsSubQuestion(t *testing.T) { + m := newTestManager(t) + s, _ := m.Start("x") + child, parent, err := m.Next(s.ID) + if err != nil { + t.Fatal(err) + } + if parent != "d0" { + t.Errorf("expected parent=d0, got %q", parent) + } + if child.ParentID != "d0" { + t.Errorf("expected child.ParentID=d0, got %q", child.ParentID) + } + if child.Status != "open" { + t.Errorf("expected child.status=open, got %q", child.Status) + } + if child.ID == "d0" { + t.Error("expected new ID, got d0") + } +} + +func TestNext_AdvancesThroughTree(t *testing.T) { + // Each `Next` call should close one decision (the parent) and + // open a new child, keeping OpenQuestions = 1 throughout. + m := newTestManager(t) + s, _ := m.Start("x") + for i := 0; i < 5; i++ { + _, _, err := m.Next(s.ID) + if err != nil { + t.Fatalf("Next call %d: %v", i, err) + } + // The session should still be reachable and have at least + // one open decision. + got, _ := m.Get(s.ID) + if got.OpenQuestions < 1 { + t.Errorf("after %d Next calls, OpenQuestions=%d (expected ≥1)", i+1, got.OpenQuestions) + } + } +} + +func TestAnswer_RecordsResponse(t *testing.T) { + m := newTestManager(t) + s, _ := m.Start("x") + if err := m.Answer(s.ID, "d0", "use OAuth 2.0 with PKCE"); err != nil { + t.Fatal(err) + } + got, _ := m.Get(s.ID) + if got.Decisions[0].Answer != "use OAuth 2.0 with PKCE" { + t.Errorf("expected answer recorded, got %q", got.Decisions[0].Answer) + } + if got.Decisions[0].Status != "answered" { + t.Errorf("expected status=answered, got %q", got.Decisions[0].Status) + } + if !got.Decisions[0].ResolvedAt.IsZero() { + t.Error("expected ResolvedAt to be zero for non-resolved answer") + } +} + +func TestAnswer_DoneResolves(t *testing.T) { + m := newTestManager(t) + s, _ := m.Start("x") + if err := m.Answer(s.ID, "d0", "done"); err != nil { + t.Fatal(err) + } + got, _ := m.Get(s.ID) + if got.Decisions[0].Status != "resolved" { + t.Errorf("expected status=resolved for answer=done, got %q", got.Decisions[0].Status) + } + if got.Decisions[0].ResolvedAt.IsZero() { + t.Error("expected ResolvedAt to be set for resolved") + } +} + +func TestAnswer_UnknownDecisionErrors(t *testing.T) { + m := newTestManager(t) + s, _ := m.Start("x") + if err := m.Answer(s.ID, "d999", "x"); err == nil { + t.Error("expected error for unknown decision id") + } +} + +func TestStatus_ResolvesAndOpens(t *testing.T) { + m := newTestManager(t) + s, _ := m.Start("x") + // d0 is the seed (open). + if _, _, err := m.Next(s.ID); err != nil { + t.Fatal(err) + } + // After Next: d0 still open (a sub-question is a deepening, + // not a closure of the parent). d1 is the new open sub-question. + // d0 is still open. d1 is open. 2 open, 0 resolved. + _ = m.Answer(s.ID, "d1", "done") + resolved, open, answered, deferred, err := m.Status(s.ID) + if err != nil { + t.Fatal(err) + } + if resolved != 1 { + t.Errorf("expected 1 resolved (d1=done), got %d", resolved) + } + if answered != 0 { + t.Errorf("expected 0 answered, got %d", answered) + } + if open != 1 { + t.Errorf("expected 1 open (d0 still open), got %d", open) + } + if deferred != 0 { + t.Errorf("expected 0 deferred, got %d", deferred) + } +} + +func TestSynthesize_IncludesAssumptions(t *testing.T) { + m := newTestManager(t) + s, _ := m.Start("x") + // After Start, d0 is the seed (open). Answering it directly + // (no Next call) makes it "answered". + if err := m.Answer(s.ID, "d0", "I assume the operator has admin access"); err != nil { + t.Fatal(err) + } + syn, err := m.Synthesize(s.ID) + if err != nil { + t.Fatal(err) + } + // d0 is "answered" (not "resolved"), so it goes to Open, + // not Resolved. The Assumptions list still catches the + // "I assume" heuristic. + if len(syn.Open) != 1 { + t.Errorf("expected 1 open (d0 answered), got %d", len(syn.Open)) + } + if len(syn.Resolved) != 0 { + t.Errorf("expected 0 resolved (d0 is answered, not resolved), got %d", len(syn.Resolved)) + } + if len(syn.Assumptions) != 1 { + t.Errorf("expected 1 assumption (I assume...), got %d", len(syn.Assumptions)) + } +} + +func TestSynthesize_OpenAndResolved(t *testing.T) { + m := newTestManager(t) + s, _ := m.Start("x") + if _, _, err := m.Next(s.ID); err != nil { + t.Fatal(err) + } + // d0 still open (Next is a deepening, not a closure). + // d1 open. Both are open. + syn, _ := m.Synthesize(s.ID) + if len(syn.Open) != 2 { + t.Errorf("expected 2 open, got %d", len(syn.Open)) + } + if len(syn.Resolved) != 0 { + t.Errorf("expected 0 resolved, got %d", len(syn.Resolved)) + } +} + +func TestSession_RoundTrip(t *testing.T) { + // The load-bearing test: a session is written to disk, loaded + // back, and must be identical. The CLI depends on this for + // `grill next` after a restart. + dir := t.TempDir() + m1, _ := NewManager(dir) + _, _ = m1.Start("round-trip test") + id := getLastID(m1) + t.Logf("DEBUG: id=%q", id) + if id == "" { + t.Fatal("expected non-empty session id") + } + // Use m1.Get for all reads so the pointer is always the + // in-memory one (Start's return value can diverge from the + // in-memory pointer after a slice append that grows the + // backing array). + got, err := m1.Get(id) + if err != nil { + t.Fatalf("m1.Get(%q): %v", id, err) + } + if _, _, err := m1.Next(got.ID); err != nil { + t.Fatal(err) + } + got, _ = m1.Get(got.ID) + var openID string + for _, d := range got.Decisions { + if d.Status == "open" { + openID = d.ID + } + } + if openID == "" { + t.Fatal("expected at least one open decision") + } + if err := m1.Answer(got.ID, openID, "answer 0"); err != nil { + t.Fatal(err) + } + + // Reload from a fresh manager. + m2, _ := NewManager(dir) + reloaded, err := m2.Get(got.ID) + if err != nil { + t.Fatal(err) + } + if reloaded.Topic != got.Topic { + t.Errorf("topic changed: %q != %q", reloaded.Topic, got.Topic) + } + if len(reloaded.Decisions) != len(got.Decisions) { + t.Errorf("decision count changed: %d != %d", len(reloaded.Decisions), len(got.Decisions)) + } + if len(reloaded.Decisions) == 0 { + t.Fatal("reloaded has 0 decisions — disk load is broken") + } + for i := range reloaded.Decisions { + if reloaded.Decisions[i].ID != got.Decisions[i].ID { + t.Errorf("decision %d ID changed: %q != %q", i, reloaded.Decisions[i].ID, got.Decisions[i].ID) + } + } + if reloaded.Decisions[len(reloaded.Decisions)-1].Answer != "answer 0" { + t.Errorf("last decision answer not preserved: got %q", reloaded.Decisions[len(reloaded.Decisions)-1].Answer) + } +} + +// getLastID returns the most-recently-created session id in the +// manager. There is at most one in this test. Used by the round- +// trip test to avoid the Go-slice-grow pitfall (Start returns the +// old *Session; subsequent appends grow a new backing array). +func getLastID(m *Manager) string { + m.mu.Lock() + defer m.mu.Unlock() + var id string + for k := range m.sessions { + id = k + } + return id +} + +func TestNewSessionID_StableForSameInput(t *testing.T) { + t1 := time.Date(2026, 6, 16, 12, 0, 0, 0, time.UTC) + id1 := newSessionID("topic", t1) + id2 := newSessionID("topic", t1) + if id1 != id2 { + t.Errorf("expected stable ID for same input, got %q != %q", id1, id2) + } + id3 := newSessionID("other", t1) + if id1 == id3 { + t.Error("expected different ID for different topic") + } +} + +func TestCatalog_HasSeedEntries(t *testing.T) { + if len(Catalog) < 5 { + t.Errorf("expected at least 5 anti-patterns, got %d", len(Catalog)) + } + for _, p := range Catalog { + if p.Name == "" { + t.Error("empty anti-pattern name") + } + if len(p.Questions) == 0 { + t.Errorf("anti-pattern %q has no questions", p.Name) + } + } +} + +func TestCatalogQuestion_DeterministicForSameTopic(t *testing.T) { + q1 := catalogQuestion("x", 0) + q2 := catalogQuestion("x", 0) + if q1 != q2 { + t.Error("expected deterministic question for same topic+index") + } + q3 := catalogQuestion("y", 0) + // Different topic should land in a (potentially) different + // bucket. Not strictly required to differ, but typical. + if q1 == q3 && len(Catalog) > 1 { + t.Logf("warning: same first question for different topics (q1=%q, q3=%q)", q1, q3) + } +} + +func TestContains(t *testing.T) { + cases := []struct { + s, sub string + want bool + }{ + {"hello world", "world", true}, + {"hello world", "foo", false}, + {"hello world", "", true}, + {"", "x", false}, + {"abcabc", "cab", true}, + } + for _, c := range cases { + if got := contains(c.s, c.sub); got != c.want { + t.Errorf("contains(%q,%q) = %v, want %v", c.s, c.sub, got, c.want) + } + } +} + +func TestMarshalUnmarshal_Session(t *testing.T) { + // Sanity: the JSON shape is stable enough to be consumed by + // the CLI's text renderer. + s := &Session{ + ID: "grill-abc", + Topic: "x", + StartedAt: time.Date(2026, 6, 16, 12, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 6, 16, 12, 1, 0, 0, time.UTC), + Decisions: []Decision{ + {ID: "d0", Question: "q1", Status: "open", AskedAt: time.Now().UTC()}, + }, + OpenQuestions: 1, + } + b, err := json.Marshal(s) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), `"id":"grill-abc"`) { + t.Errorf("expected id in JSON, got %s", b) + } +} + +// ── smoke: a full interview flow ───────────────────────────────────── + +func TestSession_FullFlow(t *testing.T) { + m := newTestManager(t) + s, _ := m.Start("ship a new binary") + for i := 0; i < 3; i++ { + _, parent, err := m.Next(s.ID) + if err != nil { + t.Fatalf("Next %d: %v", i, err) + } + if err := m.Answer(s.ID, parent, "answer to "+parent); err != nil { + t.Fatalf("Answer %d: %v", i, err) + } + } + // Resolve every open question. + got, _ := m.Get(s.ID) + for _, d := range got.Decisions { + if d.Status == "open" { + if err := m.Answer(s.ID, d.ID, "done"); err != nil { + t.Fatal(err) + } + } + } + syn, _ := m.Synthesize(s.ID) + // 3 answers via Answer(parent, "answer to dX") + 3 Next-append + // leaves 1 final open, then done. So 3 answered, 1 resolved + // at the time of the last done. Total: 1 resolved + 3 answered. + // Synthesize counts Resolved and Open. Answered goes to Open. + // So we expect 1 resolved and 3 open. + if len(syn.Resolved) != 1 { + t.Errorf("expected 1 resolved, got %d", len(syn.Resolved)) + } + if len(syn.Open) != 3 { + t.Errorf("expected 3 open (answered, not resolved), got %d", len(syn.Open)) + } +} + +func TestMainPath(t *testing.T) { + // Helper: ensure the path function builds the expected layout. + dir := t.TempDir() + m, _ := NewManager(dir) + want := filepath.Join(dir, "abc.json") + got := m.path("abc") + if got != want { + t.Errorf("expected path=%q, got %q", want, got) + } +} diff --git a/cmd/sin-code/internal/grill/manager.go b/cmd/sin-code/internal/grill/manager.go new file mode 100644 index 00000000..e932b225 --- /dev/null +++ b/cmd/sin-code/internal/grill/manager.go @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: MIT +// Purpose: session manager — create, mutate, persist, and render +// grilling sessions. The Session is the unit of state; the manager +// owns the on-disk representation. +package grill + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + "time" +) + +// Manager is a process-wide registry of grilling sessions. It is +// safe for concurrent use (the operator may run `grill next` and +// `grill answer` in parallel from a TUI shell, for example). +type Manager struct { + mu sync.Mutex + dir string // JSON file directory (e.g. ~/.local/share/sin-code/grill) + sessions map[string]*Session // keyed by ID +} + +// NewManager opens (or creates) the grill directory. Sessions are +// loaded lazily on first access; the manager is cheap to construct. +func NewManager(dir string) (*Manager, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("grill: create dir: %w", err) + } + return &Manager{dir: dir, sessions: map[string]*Session{}}, nil +} + +// Dir returns the manager's directory. Used by the CLI to print +// "sessions stored at " on startup. +func (m *Manager) Dir() string { return m.dir } + +// Start creates a new session for the given topic. The first +// question is auto-generated from the catalog (round-robin based +// on a hash of the topic so two operators get different openings). +func (m *Manager) Start(topic string) (*Session, error) { + if topic == "" { + return nil, fmt.Errorf("grill: topic is required") + } + now := time.Now().UTC() + id := newSessionID(topic, now) + s := &Session{ + ID: id, + Topic: topic, + StartedAt: now, + UpdatedAt: now, + Decisions: []Decision{}, + } + // Seed the first decision (the root question). + q := openingQuestion(topic) + s.Decisions = append(s.Decisions, Decision{ + ID: "d0", + Question: q, + Status: "open", + AskedAt: now, + }) + s.recomputeOpen() + if err := m.save(s); err != nil { + return nil, err + } + // Cache the session in memory so subsequent Get/Next/Answer + // calls don't round-trip through disk. The load-on-miss path + // in Get is a fallback for managers that crash and restart. + m.mu.Lock() + m.sessions[id] = s + m.mu.Unlock() + return s, nil +} + +// Get returns the session with the given id, loading from disk if +// it is not in memory. +func (m *Manager) Get(id string) (*Session, error) { + m.mu.Lock() + if s, ok := m.sessions[id]; ok { + m.mu.Unlock() + return s, nil + } + m.mu.Unlock() + s, err := m.load(id) + if err != nil { + return nil, err + } + m.mu.Lock() + m.sessions[id] = s + m.mu.Unlock() + return s, nil +} + +// Next asks the next adversarial question. It picks an open +// decision (in order), then appends a new sub-question from the +// catalog. Returns the new decision and the parent id. +func (m *Manager) Next(id string) (decision Decision, parentID string, err error) { + s, err := m.Get(id) + if err != nil { + return Decision{}, "", err + } + m.mu.Lock() + defer m.mu.Unlock() + // Find the first open decision. There is always at least one + // (the seed) until the operator resolves every decision. + for _, d := range s.Decisions { + if d.Status == "open" { + // Append a sub-question from the catalog. + q := catalogQuestion(s.Topic, len(s.Decisions)) + child := Decision{ + ID: fmt.Sprintf("d%d", len(s.Decisions)), + ParentID: d.ID, + Question: q, + Status: "open", + AskedAt: time.Now().UTC(), + } + s.Decisions = append(s.Decisions, child) + s.UpdatedAt = child.AskedAt + s.recomputeOpen() + if err := m.save(s); err != nil { + return Decision{}, "", err + } + return child, d.ID, nil + } + } + return Decision{}, "", fmt.Errorf("grill: no open decisions in session %s", id) +} + +// Answer records the operator's response to a decision. The +// decision's status moves to "answered" (or "resolved" if the +// operator said "done"). +func (m *Manager) Answer(id, decisionID, answer string) error { + s, err := m.Get(id) + if err != nil { + return err + } + m.mu.Lock() + defer m.mu.Unlock() + for i := range s.Decisions { + if s.Decisions[i].ID == decisionID { + s.Decisions[i].Answer = answer + now := time.Now().UTC() + if answer == "done" || answer == "skip" { + s.Decisions[i].Status = "resolved" + s.Decisions[i].ResolvedAt = now + } else { + s.Decisions[i].Status = "answered" + } + s.UpdatedAt = now + s.recomputeOpen() + return m.save(s) + } + } + return fmt.Errorf("grill: decision %s not found in session %s", decisionID, id) +} + +// Status returns a summary of the session: how many decisions, how +// many open, how many resolved. +func (m *Manager) Status(id string) (resolved, open, answered, deferred int, err error) { + s, err := m.Get(id) + if err != nil { + return 0, 0, 0, 0, err + } + m.mu.Lock() + defer m.mu.Unlock() + for _, d := range s.Decisions { + switch d.Status { + case "resolved": + resolved++ + case "answered": + answered++ + case "deferred": + deferred++ + default: + open++ + } + } + return +} + +// Synthesize produces a structured summary of the session: the +// resolved decisions, the open questions, and the assumptions +// surfaced during the interview. The CLI emits this in both human +// text and JSON. +func (m *Manager) Synthesize(id string) (Synthesize, error) { + s, err := m.Get(id) + if err != nil { + return Synthesize{}, err + } + m.mu.Lock() + defer m.mu.Unlock() + out := Synthesize{ + Resolved: []string{}, + Open: []string{}, + Assumptions: []string{}, + } + for _, d := range s.Decisions { + switch d.Status { + case "resolved": + out.Resolved = append(out.Resolved, d.Question+" — "+d.Answer) + case "answered", "open": + out.Open = append(out.Open, d.Question) + } + // Heuristic: an "answered" decision with "I assume" or + // "probably" in the answer is flagged as an assumption. + if d.Status == "answered" { + low := d.Answer + if contains(low, "assume") || contains(low, "probably") || contains(low, "guess") { + out.Assumptions = append(out.Assumptions, d.Question+" (assumed: "+d.Answer+")") + } + } + } + return out, nil +} + +// recomputeOpen rebuilds the OpenQuestions count from the +// decisions. Called on every mutation. +func (s *Session) recomputeOpen() { + n := 0 + for _, d := range s.Decisions { + if d.Status == "open" { + n++ + } + } + s.OpenQuestions = n +} + +// save writes the session to its JSON file. Atomic write: temp + +// rename, so a crash mid-write never leaves a half-written file. +func (m *Manager) save(s *Session) error { + path := m.path(s.ID) + b, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, b, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func (m *Manager) load(id string) (*Session, error) { + path := m.path(id) + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("grill: load %s: %w", id, err) + } + var s Session + if err := json.Unmarshal(b, &s); err != nil { + return nil, fmt.Errorf("grill: parse %s: %w", id, err) + } + // Sort decisions by ID for stable rendering. The IDs are + // "d0", "d1", ... so lexical == chronological. + sort.SliceStable(s.Decisions, func(i, j int) bool { + return s.Decisions[i].ID < s.Decisions[j].ID + }) + return &s, nil +} + +func (m *Manager) path(id string) string { + return filepath.Join(m.dir, id+".json") +} + +func contains(s, sub string) bool { + if sub == "" { + return true + } + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +// openingQuestion returns the seed question for a new session. The +// question is templated on the topic so the operator can see the +// interview is grounded in their plan, not generic boilerplate. +func openingQuestion(topic string) string { + return "What is the core decision you want this grilling to resolve about: " + topic + "?" +} + +// catalogQuestion returns a question from the catalog, picked by +// index. The index is hash-derived so the same topic yields the +// same opening question across sessions, but different topics open +// with different questions. Round-robin from there. +func catalogQuestion(topic string, decisionIdx int) string { + n := len(Catalog) + if n == 0 { + return "What is the assumption behind this step?" + } + // Hash the topic to seed the round-robin. + seed := 0 + for _, c := range topic { + seed = seed*131 + int(c) + } + if seed < 0 { + seed = -seed + } + idx := (seed + decisionIdx) % n + p := Catalog[idx] + if len(p.Questions) == 0 { + return p.Description + } + q := p.Questions[decisionIdx%len(p.Questions)] + return "[" + p.Name + "] " + q +} diff --git a/cmd/sin-code/internal/grill/types.go b/cmd/sin-code/internal/grill/types.go new file mode 100644 index 00000000..41d9b1c4 --- /dev/null +++ b/cmd/sin-code/internal/grill/types.go @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +// Purpose: the data model for a grilling session. Mirrors the +// external SIN-Code-Grill-Me-Skill concepts (Decision, Branch, +// Session) so the SKILL.md catalog of anti-patterns can be ported +// 1:1 in v1. +package grill + +import ( + "crypto/sha256" + "encoding/hex" + "time" +) + +// Session is a single grilling interview. The ID is a hex-encoded +// SHA-256 of the topic + a start-time salt, so two operators +// running "grill start " on the same day get different +// sessions unless they collide on the 64-bit prefix. +type Session struct { + ID string `json:"id"` + Topic string `json:"topic"` + StartedAt time.Time `json:"started_at"` + UpdatedAt time.Time `json:"updated_at"` + // Decisions is the tree of resolved + open questions. The + // first decision is the root; further answers may push to + // the tree via ParentID. + Decisions []Decision `json:"decisions"` + // OpenQuestions is a derived list (rebuilt on every render). + // Cached in the JSON for fast CLI startup. + OpenQuestions int `json:"open_questions"` +} + +// Decision is one Q&A in the interview. Status reflects where the +// operator is in the resolution flow: +// +// - "open" — asked, no answer yet +// - "answered" — operator answered, follow-up may follow +// - "resolved" — operator is satisfied, the decision is final +// - "deferred" — operator wants to come back later +type Decision struct { + ID string `json:"id"` + ParentID string `json:"parent_id,omitempty"` + Question string `json:"question"` + Answer string `json:"answer,omitempty"` + Status string `json:"status"` + AskedAt time.Time `json:"asked_at"` + ResolvedAt time.Time `json:"resolved_at,omitempty"` +} + +// Synthesize is the output of `grill synthesize`. It summarizes +// the resolved decisions + open questions + assumptions surfaced +// during the interview. The CLI emits this in both human-readable +// text and JSON. +type Synthesize struct { + Resolved []string `json:"resolved"` + Open []string `json:"open"` + Assumptions []string `json:"assumptions"` +} + +// newSessionID returns a hex-encoded SHA-256 of topic+time, which +// is unique enough for operator-side session management. +func newSessionID(topic string, now time.Time) string { + h := sha256.New() + h.Write([]byte(topic)) + h.Write([]byte(now.Format(time.RFC3339Nano))) + return "grill-" + hex.EncodeToString(h.Sum(nil)[:8]) +} diff --git a/cmd/sin-code/main.go b/cmd/sin-code/main.go index 9b154ee6..4c77ef72 100644 --- a/cmd/sin-code/main.go +++ b/cmd/sin-code/main.go @@ -91,6 +91,7 @@ func init() { NewTriageCmd(), // v3.18.0: `sin-code triage` — backlog auto-prioritizer via gh (issue #162) NewCatalogCmd(), // v3.18.0: `sin-code catalog` — unified tool catalog (issue #163, supersedes `hub` + `assets`) NewCompileSpecCmd(), // v3.21.0: `sin-code compile-spec` — declarative .sin-code.yml → hooks/verify/perm (issue #164) + NewGrillCmd(), // v3.18.0: `sin-code grill` — native adversarial design-review (issue #141 fusion) internal.InstinctCmd, internal.HooksCmd, internal.AssetsCmd, internal.EvalCmd, internal.PRPCmd, // continuous learning + lifecycle hooks + asset harvest + eval + prp workflow )