diff --git a/cmd/sin-code/internal/autonomy/research_report.go b/cmd/sin-code/internal/autonomy/research_report.go new file mode 100644 index 00000000..3ae3da7f --- /dev/null +++ b/cmd/sin-code/internal/autonomy/research_report.go @@ -0,0 +1,363 @@ +// SPDX-License-Identifier: MIT +// Purpose: autonomous research-report generation (issue #384). +package autonomy + +import ( + "context" + "errors" + "fmt" + "net/http" + "regexp" + "sort" + "strings" + "time" +) + +type ResearchReport struct { + Topic string `json:"topic"` + Sources []Source `json:"sources"` + Body string `json:"body"` + GeneratedAt time.Time `json:"generated_at"` + Slug string `json:"slug"` +} + +type Source struct { + URL string `json:"url"` + Title string `json:"title"` + Snippet string `json:"snippet"` + FetchedAt time.Time `json:"fetched_at,omitempty"` + BodyBytes int `json:"body_bytes,omitempty"` + Error string `json:"error,omitempty"` +} + +type Searcher interface { + Search(ctx context.Context, query string, max int) ([]Source, error) +} + +type Fetcher interface { + Fetch(ctx context.Context, url string) (body string, fetchedAt time.Time, err error) +} + +type LLM interface { + Ask(ctx context.Context, system, user string) (string, error) +} + +type GeneratorConfig struct { + MaxSources int + MaxBytesPerFetch int + FetchTimeout time.Duration + SynthesizeTimeout time.Duration + RequireFrontmatter bool + Now func() time.Time +} + +type Generator struct { + cfg GeneratorConfig + source Searcher + body Fetcher + think LLM +} + +func NewGenerator(cfg GeneratorConfig, s Searcher, f Fetcher, l LLM) *Generator { + if cfg.MaxSources <= 0 { + cfg.MaxSources = 5 + } + if cfg.MaxBytesPerFetch <= 0 { + cfg.MaxBytesPerFetch = 64 * 1024 + } + if cfg.FetchTimeout <= 0 { + cfg.FetchTimeout = 15 * time.Second + } + if cfg.SynthesizeTimeout <= 0 { + cfg.SynthesizeTimeout = 60 * time.Second + } + if cfg.Now == nil { + cfg.Now = time.Now + } + return &Generator{cfg: cfg, source: s, body: f, think: l} +} + +var ErrInvalid = errors.New("autonomy/research: report failed validation") +var ErrNotWired = errors.New("autonomy/research: not wired") + +func (g *Generator) Generate(ctx context.Context, topic string) (*ResearchReport, error) { + if g == nil || g.source == nil || g.think == nil { + return nil, ErrNotWired + } + topic = strings.TrimSpace(topic) + if topic == "" { + return nil, fmt.Errorf("autonomy/research: empty topic") + } + + cctx, cancel := context.WithTimeout(ctx, g.cfg.FetchTimeout+g.cfg.SynthesizeTimeout+30*time.Second) + defer cancel() + + hits, err := g.source.Search(cctx, topic, g.cfg.MaxSources) + if err != nil { + return nil, fmt.Errorf("autonomy/research: search: %w", err) + } + if len(hits) == 0 { + return nil, fmt.Errorf("autonomy/research: no sources for %q", topic) + } + sources := dedupeAndRank(hits, g.cfg.MaxSources) + for i := range sources { + sources[i].FetchedAt, sources[i].BodyBytes, sources[i].Error = g.fetchSource(cctx, sources[i].URL) + } + + body, err := g.think.Ask(cctx, synthesisSystemPrompt(), synthesisUserPrompt(topic, sources)) + if err != nil { + return nil, fmt.Errorf("autonomy/research: synthesize: %w", err) + } + body = strings.TrimSpace(body) + + rep := &ResearchReport{ + Topic: topic, + Sources: sources, + Body: body, + GeneratedAt: g.cfg.Now().UTC(), + Slug: Slugify(topic), + } + if g.cfg.RequireFrontmatter { + rep.Body = wrapFrontmatter(rep) + } + if err := rep.Validate(); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalid, err) + } + return rep, nil +} + +func (g *Generator) fetchSource(ctx context.Context, url string) (time.Time, int, string) { + if g.body == nil { + return time.Time{}, 0, "" + } + fctx, cancel := context.WithTimeout(ctx, g.cfg.FetchTimeout) + defer cancel() + body, fetchedAt, err := g.body.Fetch(fctx, url) + if err != nil { + return time.Time{}, 0, err.Error() + } + if len(body) > g.cfg.MaxBytesPerFetch { + body = body[:g.cfg.MaxBytesPerFetch] + } + return fetchedAt, len(body), "" +} + +func (r *ResearchReport) Validate() error { + if r == nil { + return fmt.Errorf("nil report") + } + if strings.TrimSpace(r.Body) == "" { + return fmt.Errorf("empty body") + } + if len(r.Sources) == 0 { + return fmt.Errorf("no sources") + } + if strings.TrimSpace(r.Slug) == "" { + return fmt.Errorf("empty slug") + } + if !looksLikeMarkdown(r.Body) { + return fmt.Errorf("body is not recognizable markdown") + } + return nil +} + +func Slugify(topic string) string { + t := strings.ToLower(strings.TrimSpace(topic)) + var b strings.Builder + for _, r := range t { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + case r == ' ' || r == '_' || r == '/' || r == '.' || r == ':' || r == ',': + b.WriteByte('-') + } + } + out := collapseDashes(b.String()) + if len(out) > 80 { + out = out[:80] + out = strings.TrimRight(out, "-") + } + if out == "" { + return "report" + } + return out +} + +var dashRun = regexp.MustCompile(`-+`) + +func collapseDashes(s string) string { return dashRun.ReplaceAllString(s, "-") } + +func dedupeAndRank(hits []Source, max int) []Source { + if max <= 0 { + max = 5 + } + seen := make(map[string]int, len(hits)) + out := make([]Source, 0, len(hits)) + for _, h := range hits { + key := strings.TrimSpace(h.URL) + if key == "" { + continue + } + if idx, ok := seen[key]; ok { + if len(h.Snippet) > len(out[idx].Snippet) { + out[idx].Snippet = h.Snippet + } + continue + } + seen[key] = len(out) + out = append(out, h) + } + sort.SliceStable(out, func(i, j int) bool { + if len(out[i].Title) != len(out[j].Title) { + return len(out[i].Title) > len(out[j].Title) + } + return out[i].URL < out[j].URL + }) + if len(out) > max { + out = out[:max] + } + return out +} + +var ( + mdHeaderRe = regexp.MustCompile(`(?m)^#{1,6}\s+\S`) + mdParaRe = regexp.MustCompile(`\S`) +) + +func looksLikeMarkdown(body string) bool { + if !mdHeaderRe.MatchString(body) { + return false + } + return mdParaRe.MatchString(body) +} + +const synthesizeSystemTemplate = `You are an autonomous research synthesizer for SIN-Code (issue #384). +Produce a concise, citation-grounded Markdown report on the requested topic. +Rules: +- Output ONLY Markdown. No commentary, no preamble, no closing remarks. +- Ground every claim in one of the provided Sources; never invent URLs. +- Include 4-6 H2 sections that reflect the canonical facets of the topic. +- Embed inline citation anchors like ([Source N]) after each factual claim. +- End with a "## Sources" section that lists every numbered Source on its own line as: [N] - <URL>. +- Never use hedging language. State facts. +- Cap output at ~600 words unless the topic explicitly demands depth.` + +func synthesisSystemPrompt() string { return synthesizeSystemTemplate } + +func synthesisUserPrompt(topic string, srcs []Source) string { + var b strings.Builder + fmt.Fprintf(&b, "Topic: %s\n\n", topic) + b.WriteString("Sources (numbered for citation):\n") + for i, s := range srcs { + title := s.Title + if title == "" { + title = s.URL + } + bodyHint := "" + if s.BodyBytes > 0 { + bodyHint = fmt.Sprintf(" [%d bytes fetched]", s.BodyBytes) + } else if s.Snippet != "" { + bodyHint = fmt.Sprintf(" snippet=%q", truncate(s.Snippet, 240)) + } + if s.Error != "" { + bodyHint += fmt.Sprintf(" fetch_error=%q", s.Error) + } + fmt.Fprintf(&b, "[%d] %s - %s%s\n", i+1, title, s.URL, bodyHint) + } + b.WriteString("\nWrite the research report now.\n") + return b.String() +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} + +func wrapFrontmatter(r *ResearchReport) string { + var b strings.Builder + fmt.Fprintf(&b, "# %s\n\n", r.Topic) + fmt.Fprintf(&b, "_Generated at %s_\n\n", r.GeneratedAt.Format(time.RFC3339)) + b.WriteString(r.Body) + if !strings.HasSuffix(r.Body, "\n") { + b.WriteString("\n") + } + return b.String() +} + +type HTTPFetcher struct { + Client *http.Client + UserAgent string +} + +func NewHTTPFetcher() *HTTPFetcher { + return &HTTPFetcher{ + Client: &http.Client{Timeout: 20 * time.Second}, + UserAgent: "sin-code/1.0 (+research-report)", + } +} + +func (h *HTTPFetcher) Fetch(ctx context.Context, url string) (string, time.Time, error) { + if h == nil || h.Client == nil { + return "", time.Time{}, errors.New("HTTPFetcher: not wired") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", time.Time{}, err + } + req.Header.Set("User-Agent", h.UserAgent) + req.Header.Set("Accept", "text/html,text/plain,application/json;q=0.9,*/*;q=0.8") + resp, err := h.Client.Do(req) + if err != nil { + return "", time.Time{}, err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return "", time.Time{}, fmt.Errorf("http %d for %s", resp.StatusCode, url) + } + buf := make([]byte, 0, 1024*1024) + tmp := make([]byte, 4096) + for { + if len(buf) >= 1024*1024 { + break + } + n, rerr := resp.Body.Read(tmp) + if n > 0 { + buf = append(buf, tmp[:n]...) + } + if rerr != nil { + break + } + } + return string(buf), time.Now().UTC(), nil +} + +type StaticSearcher struct { + Hits []Source + Err error +} + +func (s *StaticSearcher) Search(ctx context.Context, query string, max int) ([]Source, error) { + if s.Err != nil { + return nil, s.Err + } + out := make([]Source, len(s.Hits)) + copy(out, s.Hits) + if max > 0 && len(out) > max { + out = out[:max] + } + return out, nil +} + +type StaticLLM struct { + Reply string + Err error +} + +func (s *StaticLLM) Ask(ctx context.Context, system, user string) (string, error) { + if s.Err != nil { + return "", s.Err + } + return s.Reply, nil +} diff --git a/cmd/sin-code/internal/autonomy/research_report_test.go b/cmd/sin-code/internal/autonomy/research_report_test.go new file mode 100644 index 00000000..d90236d5 --- /dev/null +++ b/cmd/sin-code/internal/autonomy/research_report_test.go @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: MIT +// Purpose: tests for autonomous research-report generation (issue #384). +// Race-clean (mandate M7), deterministic byte-stability for Slugify + +// Markdown output, hermetic — every dependency is a stub. +package autonomy + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestResearchReportGeneration(t *testing.T) { + searcher := &StaticSearcher{ + Hits: []Source{ + {URL: "https://example.com/a", Title: "First", Snippet: "alpha beta"}, + {URL: "https://example.com/b", Title: "Second", Snippet: "gamma delta"}, + {URL: "https://example.com/c", Title: "Third", Snippet: "epsilon zeta"}, + }, + } + body := "# Alpha Topic\n\n## Overview\nThis is the alpha topic.\n\n## Sources\n[1] First - https://example.com/a\n[2] Second - https://example.com/b\n[3] Third - https://example.com/c\n" + + gen := NewGenerator(GeneratorConfig{MaxSources: 5}, searcher, nil, &StaticLLM{Reply: body}) + rep, err := gen.Generate(context.Background(), "Alpha Topic") + if err != nil { + t.Fatalf("Generate: %v", err) + } + if rep == nil { + t.Fatal("expected non-nil report") + } + if len(rep.Sources) != 3 { + t.Fatalf("expected 3 sources, got %d", len(rep.Sources)) + } + if rep.Body == "" { + t.Fatal("expected non-empty body") + } + if rep.Topic != "Alpha Topic" { + t.Fatalf("topic echo mismatch: %q", rep.Topic) + } + if rep.Slug != "alpha-topic" { + t.Fatalf("slug mismatch: %q", rep.Slug) + } +} + +func TestResearchReportSlug(t *testing.T) { + cases := map[string]string{ + "Hello World": "hello-world", + "Go 1.23 Release Notes": "go-1-23-release-notes", + " spaces around ": "spaces-around", + "mixed/separators_here": "mixed-separators-here", + "dotted.path.style": "dotted-path-style", + "": "report", + "Punctuation!@# overloaded": "punctuation-overloaded", + } + for in, want := range cases { + if got := Slugify(in); got != want { + t.Errorf("Slugify(%q) want %q got %q", in, want, got) + } + } + if a, b := Slugify("Foo Bar"), Slugify("Foo Bar"); a != b { + t.Errorf("slugify not deterministic: %q vs %q", a, b) + } + big := strings.Repeat("alpha-", 200) + got := Slugify(big) + if len(got) > 80 { + t.Errorf("slug exceeds 80 chars: %d", len(got)) + } + if strings.HasSuffix(got, "-") { + t.Errorf("slug has trailing dash after truncation: %q", got) + } +} + +func TestResearchReportMarkdown(t *testing.T) { + searcher := &StaticSearcher{ + Hits: []Source{ + {URL: "https://example.com/x", Title: "X", Snippet: "sample snippet"}, + {URL: "https://example.com/y", Title: "Y", Snippet: "more contents"}, + }, + } + rep := &ResearchReport{ + Topic: "X", + Sources: []Source{ + {URL: "https://example.com/x", Title: "X", Snippet: "sample snippet"}, + {URL: "https://example.com/y", Title: "Y", Snippet: "more contents"}, + }, + Body: "# X\n\n## A\nstuff.\n\n## Sources\n[1] X - https://example.com/x\n[2] Y - https://example.com/y\n", + Slug: Slugify("X"), + } + if err := rep.Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } + if !strings.HasPrefix(rep.Body, "# X") { + t.Fatalf("body missing H1: %q", rep.Body) + } + rep.Body = " \n " + if err := rep.Validate(); err == nil { + t.Fatal("expected validation failure on whitespace-only body") + } + rep.Body = "Just a paragraph with no headings." + if err := rep.Validate(); err == nil { + t.Fatal("expected validation failure on header-less body") + } + if err := (*ResearchReport)(nil).Validate(); err == nil { + t.Fatal("nil Validate should fail") + } + llmBody := "# X\n\n## Intro\nhello\n\n## Sources\n[1] X - https://example.com/x\n[2] Y - https://example.com/y\n" + gen := NewGenerator(GeneratorConfig{MaxSources: 5}, searcher, nil, &StaticLLM{Reply: llmBody}) + if _, err := gen.Generate(context.Background(), "X"); err != nil { + t.Fatalf("Generate: %v", err) + } +} + +func TestResearchReportWiringGuards(t *testing.T) { + if _, err := (*Generator)(nil).Generate(context.Background(), "x"); err != ErrNotWired { + t.Errorf("nil gen: want ErrNotWired, got %v", err) + } + g := NewGenerator(GeneratorConfig{}, &StaticSearcher{}, nil, nil) + if _, err := g.Generate(context.Background(), "x"); err != ErrNotWired { + t.Errorf("nil LLM: want ErrNotWired, got %v", err) + } + g = NewGenerator(GeneratorConfig{}, &StaticSearcher{}, nil, &StaticLLM{}) + if _, err := g.Generate(context.Background(), " "); err == nil { + t.Error("empty topic: want error") + } + g = NewGenerator(GeneratorConfig{}, &StaticSearcher{Hits: []Source{{URL: "https://e.com/x", Title: "T"}}}, nil, + &StaticLLM{Err: errStatic}) + if _, err := g.Generate(context.Background(), "phi"); err == nil { + t.Error("LLM error: want propagated error") + } + g = NewGenerator(GeneratorConfig{}, &StaticSearcher{Err: errStatic}, nil, &StaticLLM{}) + if _, err := g.Generate(context.Background(), "phi"); err == nil { + t.Error("searcher error: want propagated error") + } + g = NewGenerator(GeneratorConfig{}, &StaticSearcher{}, nil, &StaticLLM{}) + if _, err := g.Generate(context.Background(), "phi"); err == nil { + t.Error("empty sources: want error") + } +} + +var errStatic = staticErr("net: simulated failure") + +type staticErr string + +func (e staticErr) Error() string { return string(e) } + +func TestResearchReportDedupRank(t *testing.T) { + in := []Source{ + {URL: "https://a", Title: "Alpha", Snippet: "short"}, + {URL: "https://b", Title: "Beta", Snippet: "longer snippet"}, + {URL: "https://a", Title: "Alpha", Snippet: "longer than short"}, + {URL: "https://c", Title: "Gamma", Snippet: "mid"}, + } + out := dedupeAndRank(in, 5) + if len(out) != 3 { + t.Fatalf("dedupe expected 3, got %d", len(out)) + } + var gotA *Source + for i := range out { + if out[i].URL == "https://a" { + gotA = &out[i] + } + } + if gotA == nil || gotA.Snippet != "longer than short" { + t.Fatalf("url a snippet upgrade failed: %+v", gotA) + } + out = dedupeAndRank(in, 2) + if len(out) != 2 { + t.Fatalf("cap max=2 expected length 2, got %d", len(out)) + } +} + +func TestResearchReportFrontmatter(t *testing.T) { + searcher := &StaticSearcher{ + Hits: []Source{ + {URL: "https://e.com/x", Title: "X"}, + }, + } + fixed := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC) + gen := NewGenerator(GeneratorConfig{ + MaxSources: 5, + RequireFrontmatter: true, + Now: func() time.Time { return fixed }, + }, searcher, nil, &StaticLLM{ + Reply: "# X\n\n## Intro\nhello\n\n## Sources\n[1] X - https://e.com/x\n", + }) + rep, err := gen.Generate(context.Background(), "X") + if err != nil { + t.Fatalf("Generate: %v", err) + } + if !strings.HasPrefix(rep.Body, "# X\n\n_Generated at 2026-06-18T") { + t.Fatalf("frontmatter header missing or malformed: %q", rep.Body) + } +} diff --git a/cmd/sin-code/internal/permission_defaults.go b/cmd/sin-code/internal/permission_defaults.go index 266f8f26..48e45fd1 100644 --- a/cmd/sin-code/internal/permission_defaults.go +++ b/cmd/sin-code/internal/permission_defaults.go @@ -141,6 +141,16 @@ func DefaultPermissionRules() []permission.Rule { {Tool: "sin_todo_dep_add", Policy: "ask"}, // Backstop catch-all (mirrors sin_bash default at line 44 for unmatched prefixes). {Tool: "autodev__*", Policy: "ask"}, + // v3.23.0: autonomous research-report generation (issue #384). + // research__* pays real LLM tokens per call; gate "ask" so the + // daemon cannot self-escalate (M4). The dry_run / list / show + // surfaces are "allow" so callers can preview a research plan + // or inspect stored reports for free. + {Tool: "research__dry_run", Policy: "allow"}, + {Tool: "research__list", Policy: "allow"}, + {Tool: "research__show", Policy: "allow"}, + {Tool: "research__run", Policy: "ask"}, + {Tool: "research__*", Policy: "ask"}, {Tool: "*", Policy: "ask"}, } } diff --git a/cmd/sin-code/main.go b/cmd/sin-code/main.go index f46ac44e..658db057 100644 --- a/cmd/sin-code/main.go +++ b/cmd/sin-code/main.go @@ -107,6 +107,7 @@ func init() { NewImageGraphCmd(), // image-graph: deterministic chart generation (bar/line/pie/area) NewStatusCmd(), // v3.22.0 NewFusionCmd(), // v3.22.0 — fusion benchmark/rank/recommend (issue #395) — readiness/status snapshot (issue #326) + NewResearchCmd(), // v3.23.0 — autonomous research-report generation (issue #384) ) // Pass build-time version to self-update module. diff --git a/cmd/sin-code/research_cmd.go b/cmd/sin-code/research_cmd.go new file mode 100644 index 00000000..939ab58a --- /dev/null +++ b/cmd/sin-code/research_cmd.go @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: MIT +// Purpose: `sin-code research <topic>` — autonomous research-report +// generation (issue #384). +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/autonomy" +) + +func NewResearchCmd() *cobra.Command { + var ( + priority int + retries int + sourcesCap int + fetchBytes int + outDir string + dryRun bool + jsonOut bool + frontmatter bool + criteria []string + ) + + cmd := &cobra.Command{ + Use: "research <topic>", + Short: "Autonomous research-report generation (issue #384)", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + topic := strings.TrimSpace(strings.Join(args, " ")) + if topic == "" { + return fmt.Errorf("research: empty topic") + } + ws, err := os.Getwd() + if err != nil { + return err + } + + if dryRun { + resolvedDir := resolveOutDir(outDir, ws) + payload := map[string]any{ + "topic": topic, + "priority": priority, + "retries": retries, + "sources_cap": sourcesCap, + "fetch_bytes": fetchBytes, + "frontmatter": frontmatter, + "workspace": ws, + "criteria": criteria, + "out_dir": resolvedDir, + "estimated_path": filepath.Join(resolvedDir, autonomy.Slugify(topic)+".md"), + } + if jsonOut { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(payload) + } + fmt.Fprintf(cmd.OutOrStdout(), "DRY-RUN: topic=%q priority=%d retries=%d out=%s\n", + topic, priority, retries, payload["estimated_path"]) + return nil + } + + q, err := autonomy.Open(autonomy.DefaultPath()) + if err != nil { + return err + } + defer q.Close() + + defaults := []string{ + "Report body is non-empty Markdown (>= 1 H1 / H2 header)", + "Report cites at least one Source URL", + fmt.Sprintf("Report written to .sin-code/reports/%s.md", autonomy.Slugify(topic)), + "Report passes byte-stable validation (ResearchReport.Validate)", + } + contractCriteria := append(defaults, criteria...) + prompt := buildResearchPrompt(topic, resolveOutDir(outDir, ws), sourcesCap, fetchBytes, frontmatter) + + id, err := q.AddWithContract(cmd.Context(), prompt, ws, priority, retries, marshalContract(contractCriteria)) + if err != nil { + return fmt.Errorf("research: enqueue: %w", err) + } + + payload := struct { + GoalID int64 `json:"goal_id"` + Topic string `json:"topic"` + Slug string `json:"slug"` + OutPath string `json:"out_path"` + Workspace string `json:"workspace"` + Priority int `json:"priority"` + Retries int `json:"retries"` + Criteria int `json:"criteria"` + }{ + GoalID: id, + Topic: topic, + Slug: autonomy.Slugify(topic), + OutPath: filepath.Join(resolveOutDir(outDir, ws), autonomy.Slugify(topic)+".md"), + Workspace: ws, + Priority: priority, + Retries: retries, + Criteria: len(contractCriteria), + } + if jsonOut { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(payload) + } + fmt.Fprintf(cmd.OutOrStdout(), + "research: goal %d enqueued topic=%q slug=%s out=%s retries=%d\n", + id, topic, payload.Slug, payload.OutPath, retries) + return nil + }, + } + + cmd.Flags().IntVar(&priority, "priority", 0, "higher runs sooner") + cmd.Flags().IntVar(&retries, "retries", 3, "retry budget when contract criteria unmet") + cmd.Flags().IntVar(&sourcesCap, "sources", 5, "max sources to fetch before synthesis") + cmd.Flags().IntVar(&fetchBytes, "fetch-bytes", 64*1024, "max bytes fetched per source") + cmd.Flags().StringVar(&outDir, "out-dir", "", "override .sin-code/reports/ output directory") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print plan without enqueueing") + cmd.Flags().BoolVar(&jsonOut, "json", false, "emit JSON envelope") + cmd.Flags().BoolVar(&frontmatter, "frontmatter", false, "wrap report body in topic + timestamp header") + cmd.Flags().StringArrayVar(&criteria, "criteria", nil, "additional acceptance criterion (repeatable)") + return cmd +} + +func resolveOutDir(override, workspace string) string { + base := workspace + if base == "" { + base = "." + } + if strings.TrimSpace(override) != "" { + return override + } + return filepath.Join(base, ".sin-code", "reports") +} + +func buildResearchPrompt(topic, outDir string, sourcesCap, fetchBytes int, frontmatter bool) string { + var b strings.Builder + fmt.Fprintf(&b, "Generate an autonomous research report on %q.\n", topic) + b.WriteString("Pipeline: cmd/sin-code/internal/autonomy/research_report.go (Searcher -> Fetcher -> LLM).\n") + fmt.Fprintf(&b, "Constraints: max_sources=%d, max_bytes_per_fetch=%d, frontmatter=%t.\n", + sourcesCap, fetchBytes, frontmatter) + fmt.Fprintf(&b, "Output path: %s/%s.md\n", outDir, autonomy.Slugify(topic)) + b.WriteString("Verify the report is valid Markdown with at least one citation.\n") + b.WriteString("Mark the goal complete only after ResearchReport.Validate returns nil.\n") + return b.String() +} + +func marshalContract(criteria []string) string { + payload := struct { + SemanticCriteria []string `json:"semantic_criteria"` + }{SemanticCriteria: criteria} + b, err := json.Marshal(payload) + if err != nil { + return "" + } + return string(b) +} + +func WriteReport(outDir, slug, body string) (string, int, error) { + if err := os.MkdirAll(outDir, 0o755); err != nil { + return "", 0, err + } + path := filepath.Join(outDir, slug+".md") + data := []byte(body) + if err := os.WriteFile(path, data, 0o644); err != nil { + return "", 0, err + } + return path, len(data), nil +}