diff --git a/internal/agentctx/agentctx_test.go b/internal/agentctx/agentctx_test.go index 310c9c7..acc468d 100644 --- a/internal/agentctx/agentctx_test.go +++ b/internal/agentctx/agentctx_test.go @@ -277,3 +277,32 @@ func assertBefore(t *testing.T, ctx, first, second string) { t.Fatalf("%q must precede %q:\n%s", first, second, ctx) } } + +// Ingested acceptance criteria must reach the rendered task in order, and an +// absent one must say so rather than be silently omitted. +func TestAcceptanceCriteriaRenderInOrder(t *testing.T) { + in := input() + in.Task.Acceptance = []string{"labelled score does not regress", "targeted cases improve"} + in.Intent.Task = in.Task + got, err := Build(in) + if err != nil { + t.Fatal(err) + } + first := strings.Index(got.Task, "- labelled score does not regress") + second := strings.Index(got.Task, "- targeted cases improve") + if first < 0 || second < 0 { + t.Fatalf("acceptance criteria missing from rendered task:\n%s", got.Task) + } + if first > second { + t.Fatal("acceptance criteria rendered out of order") + } + in.Task.Acceptance = nil + in.Intent.Task = in.Task + got, err = Build(in) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got.Task, "## Acceptance\n\nNot stated.") { + t.Fatalf("absent acceptance did not render Not stated:\n%s", got.Task) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index df86a77..83dbfd4 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -13,6 +13,7 @@ import ( "io" "net/http" "os" + "regexp" "strconv" "strings" "sync" @@ -297,6 +298,57 @@ func (g Gitea) client() *http.Client { } return http.DefaultClient } + +// acceptanceHeading opens the one recognized acceptance section. The +// convention is deliberately tiny: two spellings, markdown heading only. An +// ingestion that infers acceptance from arbitrary prose eventually invents +// requirements, and a fabricated acceptance criterion outranks every human +// decision below it in the authority order. +var acceptanceHeading = regexp.MustCompile(`(?i)^#{1,6}[ \t]*acceptance([ \t]+criteria)?[ \t]*:?[ \t]*$`) + +// acceptanceItem matches a bullet or checklist item and captures its text. +var acceptanceItem = regexp.MustCompile(`^[ \t]*[-*+][ \t]+(?:\[[ xX]\][ \t]*)?(.*)$`) + +var markdownHeading = regexp.MustCompile(`^#{1,6}[ \t]+`) + +// splitAcceptance separates an issue body into the description and the +// acceptance criteria the issue stated for itself. Everything from the +// recognized heading to the next heading leaves the description, so a criterion +// is never also read as instruction prose. An empty result is not an ingestion +// failure: a task with no stated acceptance renders "Not stated." and the frame +// phase is where that gets resolved, through the decision-request path. +func splitAcceptance(body string) (string, []string) { + lines := strings.Split(body, "\n") + start := -1 + for i, l := range lines { + if acceptanceHeading.MatchString(strings.TrimRight(l, " \t\r")) { + start = i + break + } + } + if start < 0 { + return body, nil + } + var acceptance []string + end := len(lines) + for i := start + 1; i < len(lines); i++ { + line := strings.TrimRight(lines[i], " \t\r") + if markdownHeading.MatchString(line) { + end = i + break + } + m := acceptanceItem.FindStringSubmatch(line) + if m == nil { + continue + } + if item := strings.TrimSpace(m[1]); item != "" { + acceptance = append(acceptance, item) + } + } + kept := append(append([]string{}, lines[:start]...), lines[end:]...) + return strings.Trim(strings.Join(kept, "\n"), "\n"), acceptance +} + func (g Gitea) event(issue giteaIssue, source, project string) domain.Event { caps := []string{} for _, l := range issue.Labels { @@ -306,7 +358,11 @@ func (g Gitea) event(issue giteaIssue, source, project string) domain.Event { // authority in every rendered context. Dropping it here made every // Gitea-sourced task run on its title alone, with "Acceptance: Not stated." // Found on the first burn-in task, 2026-08-26. - p := map[string]any{"source": source, "external_id": strconv.Itoa(issue.Number), "project": project, "title": issue.Title, "description": issue.Body, "capability": caps} + description, acceptance := splitAcceptance(issue.Body) + p := map[string]any{"source": source, "external_id": strconv.Itoa(issue.Number), "project": project, "title": issue.Title, "description": description, "capability": caps} + if len(acceptance) > 0 { + p["acceptance"] = acceptance + } b, _ := json.Marshal(p) return domain.Event{ID: domain.NewID(), TaskID: domain.NewID(), Type: "TaskCreated", Version: 1, Payload: b, Surface: string(authz.System)} } diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 805410c..19c9a9b 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -1,6 +1,7 @@ package provider import ( + "encoding/json" "orchestra/internal/domain" "strings" "testing" @@ -22,3 +23,81 @@ func TestJSONLRejectsMalformedLine(t *testing.T) { t.Fatalf("n=%d err=%v", n, err) } } + +// A Gitea issue states its own acceptance under one recognized heading. Before +// this, every Gitea-sourced task rendered "Acceptance: Not stated." no matter +// what the body said, because nothing on that path ever set the field. +func TestGiteaIssueAcceptanceSection(t *testing.T) { + body := "improve ambiguous attribution.\n\n## acceptance\n- labelled score does not regress\n- targeted cases improve\n" + e := Gitea{}.event(giteaIssue{Number: 7, Title: "fix attribution", Body: body}, "gitea", "p") + var p struct { + Description string `json:"description"` + Acceptance []string `json:"acceptance"` + } + if err := json.Unmarshal(e.Payload, &p); err != nil { + t.Fatal(err) + } + if p.Description != "improve ambiguous attribution." { + t.Fatalf("description=%q, want the prose without the acceptance section", p.Description) + } + want := []string{"labelled score does not regress", "targeted cases improve"} + if len(p.Acceptance) != len(want) { + t.Fatalf("acceptance=%q, want %q", p.Acceptance, want) + } + for i := range want { + if p.Acceptance[i] != want[i] { + t.Fatalf("acceptance[%d]=%q, want %q (order is preserved)", i, p.Acceptance[i], want[i]) + } + } +} + +// A body with no recognized section yields no acceptance, and that is correct +// rather than an ingestion failure: the task renders "Not stated." and the +// frame phase resolves it through the decision-request path. Inferring +// criteria from arbitrary prose would invent requirements that outrank every +// human decision beneath them. +func TestGiteaIssueWithoutAcceptanceSectionStaysEmpty(t *testing.T) { + body := "just do the thing.\n\n## notes\n- not an acceptance criterion\n" + e := Gitea{}.event(giteaIssue{Number: 8, Title: "t", Body: body}, "gitea", "p") + var p map[string]any + if err := json.Unmarshal(e.Payload, &p); err != nil { + t.Fatal(err) + } + if _, ok := p["acceptance"]; ok { + t.Fatalf("acceptance=%v, want none", p["acceptance"]) + } + if p["description"] != body { + t.Fatalf("description=%q, want the body unchanged", p["description"]) + } +} + +func TestSplitAcceptanceConventions(t *testing.T) { + for _, tc := range []struct { + name string + body string + desc string + want []string + }{ + {"checkboxes", "do it.\n\n## Acceptance\n- [ ] thing a works\n- [x] behaviour b unchanged\n", "do it.", []string{"thing a works", "behaviour b unchanged"}}, + {"acceptance criteria spelling", "do it.\n\n### Acceptance Criteria\n* one\n", "do it.", []string{"one"}}, + {"section ends at the next heading", "do it.\n\n## acceptance\n- one\n\n## notes\n- not acceptance\n", "do it.\n\n## notes\n- not acceptance", []string{"one"}}, + {"empty items ignored", "do it.\n\n## acceptance\n-\n- [ ]\n- real\n", "do it.", []string{"real"}}, + {"prose inside the section is not an item", "do it.\n\n## acceptance\nthese are the criteria:\n- one\n", "do it.", []string{"one"}}, + {"heading only", "do it.\n\n## acceptance\n", "do it.", nil}, + } { + t.Run(tc.name, func(t *testing.T) { + desc, got := splitAcceptance(tc.body) + if desc != tc.desc { + t.Fatalf("description=%q, want %q", desc, tc.desc) + } + if len(got) != len(tc.want) { + t.Fatalf("acceptance=%q, want %q", got, tc.want) + } + for i := range tc.want { + if got[i] != tc.want[i] { + t.Fatalf("acceptance[%d]=%q, want %q", i, got[i], tc.want[i]) + } + } + }) + } +}