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]
- .
+- 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 ` — 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 ",
+ 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
+}