diff --git a/internal/collections/playbook_library.go b/internal/collections/playbook_library.go index 9a11fb2b..3ca0cf5c 100644 --- a/internal/collections/playbook_library.go +++ b/internal/collections/playbook_library.go @@ -8,7 +8,8 @@ package collections // both unset for trigger-only checklist playbooks (legacy shape). type LibraryPlaybook struct { Title string `json:"title"` - Content string `json:"content"` + Content string `json:"content,omitempty"` // omitted when summary mode is on (?summary=true) + Summary string `json:"summary,omitempty"` // injected when summary mode is on; first non-heading paragraph, ~240 char cap Category string `json:"category"` // workflow, planning, quality, operations Trigger string `json:"trigger"` // on-implement, on-triage, on-release, on-plan, on-review, on-deploy, manual Scope string `json:"scope"` // all, backend, frontend, etc. diff --git a/internal/collections/summary.go b/internal/collections/summary.go new file mode 100644 index 00000000..d76a9baf --- /dev/null +++ b/internal/collections/summary.go @@ -0,0 +1,56 @@ +package collections + +// PlaybookSummary extracts a short prose hint from a playbook body. Picks the +// first non-heading non-empty paragraph and caps at ~240 chars so summary +// payloads stay compact. +// +// Lives here (rather than in internal/server/) so the bootstrap handler, the +// library HTTP endpoints, and any future surface that wants a playbook +// summary all share one algorithm. The bootstrap handler previously owned +// this helper (handlers_bootstrap.go) — TASK-1561 hoisted it to the +// collections package alongside the library data it summarizes. +func PlaybookSummary(body string) string { + const maxLen = 240 + const ellipsis = "…" + for _, line := range splitLines(body) { + trimmed := trimLeadingSpaces(line) + if trimmed == "" { + continue + } + // Skip markdown headings — they're labels, not summaries. + if len(trimmed) > 0 && trimmed[0] == '#' { + continue + } + if len(trimmed) > maxLen { + return trimmed[:maxLen-len(ellipsis)] + ellipsis + } + return trimmed + } + return "" +} + +// splitLines is a small dependency-free helper. We avoid bufio.Scanner here +// because the typical body is small (under 50KB) and allocating a scanner +// per playbook is wasteful at this scale. +func splitLines(s string) []string { + out := []string{} + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + out = append(out, s[start:i]) + start = i + 1 + } + } + if start < len(s) { + out = append(out, s[start:]) + } + return out +} + +func trimLeadingSpaces(s string) string { + i := 0 + for i < len(s) && (s[i] == ' ' || s[i] == '\t') { + i++ + } + return s[i:] +} diff --git a/internal/server/handlers_bootstrap.go b/internal/server/handlers_bootstrap.go index 91c1e525..ab361643 100644 --- a/internal/server/handlers_bootstrap.go +++ b/internal/server/handlers_bootstrap.go @@ -6,6 +6,7 @@ import ( "sort" "strings" + "github.com/PerpetualSoftware/pad/internal/collections" "github.com/PerpetualSoftware/pad/internal/models" ) @@ -686,7 +687,7 @@ func (s *Server) collectPlaybookMetadata(workspaceID string, collIDs []string, i Scope: strField("scope"), Status: strField("status"), HasArguments: hasArgs, - Summary: playbookSummary(it.Content), + Summary: collections.PlaybookSummary(it.Content), }) } // Stable order: invocation_slug-bearing first (the user-facing, @@ -702,55 +703,6 @@ func (s *Server) collectPlaybookMetadata(workspaceID string, collIDs []string, i return out, nil } -// playbookSummary extracts a short prose hint from a playbook body. Picks -// the first non-heading non-empty paragraph and caps at ~240 chars so the -// bootstrap stays compact. -func playbookSummary(body string) string { - const maxLen = 240 - const ellipsis = "…" - for _, line := range splitLines(body) { - trimmed := trimLeadingSpaces(line) - if trimmed == "" { - continue - } - // Skip markdown headings — they're labels, not summaries. - if len(trimmed) > 0 && trimmed[0] == '#' { - continue - } - if len(trimmed) > maxLen { - return trimmed[:maxLen-len(ellipsis)] + ellipsis - } - return trimmed - } - return "" -} - -// splitLines is a small dependency-free helper. We avoid bufio.Scanner -// here because the typical body is small (under 50KB) and allocating a -// scanner per playbook is wasteful at this scale. -func splitLines(s string) []string { - out := []string{} - start := 0 - for i := 0; i < len(s); i++ { - if s[i] == '\n' { - out = append(out, s[start:i]) - start = i + 1 - } - } - if start < len(s) { - out = append(out, s[start:]) - } - return out -} - -func trimLeadingSpaces(s string) string { - i := 0 - for i < len(s) && (s[i] == ' ' || s[i] == '\t') { - i++ - } - return s[i:] -} - // capBootstrapDashboard wraps a DashboardResponse with the bootstrap's // per-section caps. The underlying *DashboardResponse is shallow-copied // before the slice headers are reslized so the caller's pointer (used by diff --git a/internal/server/handlers_bootstrap_test.go b/internal/server/handlers_bootstrap_test.go index d2f2eb57..558cca23 100644 --- a/internal/server/handlers_bootstrap_test.go +++ b/internal/server/handlers_bootstrap_test.go @@ -7,6 +7,7 @@ import ( "reflect" "testing" + "github.com/PerpetualSoftware/pad/internal/collections" "github.com/PerpetualSoftware/pad/internal/models" "github.com/PerpetualSoftware/pad/internal/store" ) @@ -853,9 +854,9 @@ func TestPlaybookSummaryPrefersFirstParagraph(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got := playbookSummary(tc.body) + got := collections.PlaybookSummary(tc.body) if got != tc.want { - t.Errorf("playbookSummary() = %q, want %q", got, tc.want) + t.Errorf("collections.PlaybookSummary() = %q, want %q", got, tc.want) } }) } @@ -866,7 +867,7 @@ func TestPlaybookSummaryPrefersFirstParagraph(t *testing.T) { for i := 0; i < 100; i++ { long += "abcdefghij" } - got := playbookSummary(long) + got := collections.PlaybookSummary(long) if len(got) > 240 { t.Errorf("long summary not capped at 240 chars; got %d", len(got)) } diff --git a/internal/server/handlers_convention_library.go b/internal/server/handlers_convention_library.go index 3b5ef992..b1e985f5 100644 --- a/internal/server/handlers_convention_library.go +++ b/internal/server/handlers_convention_library.go @@ -6,11 +6,30 @@ import ( "github.com/PerpetualSoftware/pad/internal/collections" ) +// handleConventionLibrary returns the global convention library. +// +// Query params (all optional): +// - category= — return only the matching category. Case-sensitive +// exact match against LibraryCategory.Name. Unknown categories return +// an empty Categories slice (NOT 404 — the library itself exists; the +// filter just produced no rows). TASK-1561 / PLAN-1560. +// +// No workspace context — the library is global content. func (s *Server) handleConventionLibrary(w http.ResponseWriter, r *http.Request) { type response struct { Categories []collections.LibraryCategory `json:"categories"` } - writeJSON(w, http.StatusOK, response{ - Categories: collections.ConventionLibrary(), - }) + + cats := collections.ConventionLibrary() + if category := r.URL.Query().Get("category"); category != "" { + filtered := make([]collections.LibraryCategory, 0, 1) + for _, cat := range cats { + if cat.Name == category { + filtered = append(filtered, cat) + } + } + cats = filtered + } + + writeJSON(w, http.StatusOK, response{Categories: cats}) } diff --git a/internal/server/handlers_library_entry.go b/internal/server/handlers_library_entry.go new file mode 100644 index 00000000..2b6753e8 --- /dev/null +++ b/internal/server/handlers_library_entry.go @@ -0,0 +1,62 @@ +package server + +import ( + "net/http" + + "github.com/PerpetualSoftware/pad/internal/collections" +) + +// libraryEntryResponse is the envelope returned by the /library/entry +// endpoint — type is "convention" or "playbook" so callers can deserialize +// the entry into the right shape without inspecting fields. Only one of +// Convention or Playbook is set per response. +type libraryEntryResponse struct { + Type string `json:"type"` + Convention *collections.LibraryConvention `json:"convention,omitempty"` + Playbook *collections.LibraryPlaybook `json:"playbook,omitempty"` +} + +// handleLibraryEntry returns a single library entry by exact title match. +// +// Lookup precedence — conventions first, then playbooks — mirrors the +// dispatcher's `library activate` so the two stay in lockstep: if a title +// resolves to a convention for activate, it resolves to a convention here +// too. +// +// Required query param: +// - title= +// +// Returns 400 if title is missing, 404 if not found in either library. +// Full body is included; this endpoint is the canonical "get one entry's +// full content" path complementing the list endpoints' summary mode. +// +// TASK-1561 / PLAN-1560. No workspace context — the library is global. +func (s *Server) handleLibraryEntry(w http.ResponseWriter, r *http.Request) { + title := r.URL.Query().Get("title") + if title == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "title query parameter is required", + }) + return + } + + if conv := collections.GetLibraryConvention(title); conv != nil { + writeJSON(w, http.StatusOK, libraryEntryResponse{ + Type: "convention", + Convention: conv, + }) + return + } + + if pb := collections.GetLibraryPlaybook(title); pb != nil { + writeJSON(w, http.StatusOK, libraryEntryResponse{ + Type: "playbook", + Playbook: pb, + }) + return + } + + writeJSON(w, http.StatusNotFound, map[string]string{ + "error": "not found in convention or playbook library: " + title, + }) +} diff --git a/internal/server/handlers_library_test.go b/internal/server/handlers_library_test.go new file mode 100644 index 00000000..a0548018 --- /dev/null +++ b/internal/server/handlers_library_test.go @@ -0,0 +1,290 @@ +package server + +import ( + "net/http" + "net/url" + "testing" + + "github.com/PerpetualSoftware/pad/internal/collections" +) + +// TestConventionLibrary_NoParams verifies the default endpoint shape stays +// stable — the web UI library page and the MCP dispatcher both consume this +// without query params and expect full bodies. +func TestConventionLibrary_NoParams(t *testing.T) { + srv := testServer(t) + rr := doRequest(srv, "GET", "/api/v1/convention-library", nil) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp struct { + Categories []collections.LibraryCategory `json:"categories"` + } + parseJSON(t, rr, &resp) + if len(resp.Categories) == 0 { + t.Fatal("expected at least one category in the library") + } + // Spot-check: a known category and a known full-content body. + foundGit := false + for _, cat := range resp.Categories { + if cat.Name == "git" { + foundGit = true + if len(cat.Conventions) == 0 { + t.Errorf("git category has no conventions") + } + for _, conv := range cat.Conventions { + if conv.Content == "" { + t.Errorf("expected full content for convention %q, got empty", conv.Title) + } + } + } + } + if !foundGit { + t.Errorf("expected git category in convention library") + } +} + +// TestConventionLibrary_CategoryFilter — ?category=git returns only git. +func TestConventionLibrary_CategoryFilter(t *testing.T) { + srv := testServer(t) + rr := doRequest(srv, "GET", "/api/v1/convention-library?category=git", nil) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + + var resp struct { + Categories []collections.LibraryCategory `json:"categories"` + } + parseJSON(t, rr, &resp) + if len(resp.Categories) != 1 { + t.Fatalf("expected exactly 1 category, got %d", len(resp.Categories)) + } + if resp.Categories[0].Name != "git" { + t.Errorf("expected category name 'git', got %q", resp.Categories[0].Name) + } +} + +// TestConventionLibrary_UnknownCategory — empty categories slice, NOT 404. +// The library itself exists; the filter just produced no rows. +func TestConventionLibrary_UnknownCategory(t *testing.T) { + srv := testServer(t) + rr := doRequest(srv, "GET", "/api/v1/convention-library?category=nonexistent-zzz", nil) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + var resp struct { + Categories []collections.LibraryCategory `json:"categories"` + } + parseJSON(t, rr, &resp) + if len(resp.Categories) != 0 { + t.Errorf("expected 0 categories for unknown filter, got %d", len(resp.Categories)) + } +} + +// TestPlaybookLibrary_NoParams — legacy shape: full Content, no Summary. +func TestPlaybookLibrary_NoParams(t *testing.T) { + srv := testServer(t) + rr := doRequest(srv, "GET", "/api/v1/playbook-library", nil) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + var resp struct { + Categories []collections.PlaybookCategory `json:"categories"` + } + parseJSON(t, rr, &resp) + if len(resp.Categories) == 0 { + t.Fatal("expected at least one playbook category") + } + for _, cat := range resp.Categories { + for _, pb := range cat.Playbooks { + if pb.Content == "" { + t.Errorf("expected full content for playbook %q (no summary mode), got empty", pb.Title) + } + if pb.Summary != "" { + t.Errorf("expected empty summary for playbook %q without ?summary=true, got %q", pb.Title, pb.Summary) + } + } + } +} + +// TestPlaybookLibrary_CategoryFilter — ?category=agent-workflows. +func TestPlaybookLibrary_CategoryFilter(t *testing.T) { + srv := testServer(t) + rr := doRequest(srv, "GET", "/api/v1/playbook-library?category=agent-workflows", nil) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + var resp struct { + Categories []collections.PlaybookCategory `json:"categories"` + } + parseJSON(t, rr, &resp) + if len(resp.Categories) != 1 { + t.Fatalf("expected exactly 1 category, got %d", len(resp.Categories)) + } + if resp.Categories[0].Name != "agent-workflows" { + t.Errorf("expected agent-workflows, got %q", resp.Categories[0].Name) + } +} + +// TestPlaybookLibrary_SummaryMode — Content stripped, Summary populated. +func TestPlaybookLibrary_SummaryMode(t *testing.T) { + srv := testServer(t) + rr := doRequest(srv, "GET", "/api/v1/playbook-library?summary=true", nil) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + var resp struct { + Categories []collections.PlaybookCategory `json:"categories"` + } + parseJSON(t, rr, &resp) + if len(resp.Categories) == 0 { + t.Fatal("expected at least one playbook category") + } + checked := 0 + for _, cat := range resp.Categories { + for _, pb := range cat.Playbooks { + if pb.Content != "" { + t.Errorf("expected empty content in summary mode for %q, got %d chars", pb.Title, len(pb.Content)) + } + if pb.Summary == "" { + t.Errorf("expected non-empty summary for %q", pb.Title) + } + checked++ + } + } + if checked == 0 { + t.Fatal("no playbooks found to assert against") + } +} + +// TestPlaybookLibrary_SummaryDoesNotMutateGlobal — guards against the +// summary-mode handler clobbering the package-level library data via +// shared slice backing. A second non-summary request must still see full +// bodies after a summary request. +func TestPlaybookLibrary_SummaryDoesNotMutateGlobal(t *testing.T) { + srv := testServer(t) + + // Request with summary=true first. + rr := doRequest(srv, "GET", "/api/v1/playbook-library?summary=true", nil) + if rr.Code != http.StatusOK { + t.Fatalf("summary req: expected 200, got %d", rr.Code) + } + + // Now request without summary — bodies must be back to full. + rr = doRequest(srv, "GET", "/api/v1/playbook-library", nil) + if rr.Code != http.StatusOK { + t.Fatalf("plain req: expected 200, got %d", rr.Code) + } + var resp struct { + Categories []collections.PlaybookCategory `json:"categories"` + } + parseJSON(t, rr, &resp) + for _, cat := range resp.Categories { + for _, pb := range cat.Playbooks { + if pb.Content == "" { + t.Errorf("library data was mutated by a prior summary request: playbook %q has empty Content", pb.Title) + } + } + } + + // And the global library accessor still has full bodies. + for _, cat := range collections.PlaybookLibrary() { + for _, pb := range cat.Playbooks { + if pb.Content == "" { + t.Errorf("global PlaybookLibrary() mutated: %q has empty Content", pb.Title) + } + } + } +} + +// TestPlaybookLibrary_CategoryAndSummary — both flags together. +func TestPlaybookLibrary_CategoryAndSummary(t *testing.T) { + srv := testServer(t) + rr := doRequest(srv, "GET", "/api/v1/playbook-library?category=agent-workflows&summary=true", nil) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + var resp struct { + Categories []collections.PlaybookCategory `json:"categories"` + } + parseJSON(t, rr, &resp) + if len(resp.Categories) != 1 { + t.Fatalf("expected 1 category, got %d", len(resp.Categories)) + } + for _, pb := range resp.Categories[0].Playbooks { + if pb.Content != "" { + t.Errorf("expected empty content in combined mode for %q", pb.Title) + } + if pb.Summary == "" { + t.Errorf("expected non-empty summary for %q", pb.Title) + } + } +} + +// TestLibraryEntry_Convention — title resolves to a convention, full body. +func TestLibraryEntry_Convention(t *testing.T) { + srv := testServer(t) + rr := doRequest(srv, "GET", "/api/v1/library/entry?title="+url.QueryEscape("Commit after task completion"), nil) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + var resp libraryEntryResponse + parseJSON(t, rr, &resp) + if resp.Type != "convention" { + t.Errorf("expected type=convention, got %q", resp.Type) + } + if resp.Convention == nil { + t.Fatal("expected Convention to be set") + } + if resp.Convention.Content == "" { + t.Error("expected full content on convention entry") + } + if resp.Playbook != nil { + t.Error("expected Playbook to be nil for convention entry") + } +} + +// TestLibraryEntry_Playbook — title resolves to a playbook, full body. +func TestLibraryEntry_Playbook(t *testing.T) { + srv := testServer(t) + rr := doRequest(srv, "GET", "/api/v1/library/entry?title="+url.QueryEscape("Ship tasks"), nil) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + var resp libraryEntryResponse + parseJSON(t, rr, &resp) + if resp.Type != "playbook" { + t.Errorf("expected type=playbook, got %q", resp.Type) + } + if resp.Playbook == nil { + t.Fatal("expected Playbook to be set") + } + if resp.Playbook.Content == "" { + t.Error("expected full content on playbook entry") + } + if resp.Playbook.InvocationSlug != "ship" { + t.Errorf("expected invocation_slug=ship, got %q", resp.Playbook.InvocationSlug) + } + if resp.Convention != nil { + t.Error("expected Convention to be nil for playbook entry") + } +} + +// TestLibraryEntry_MissingTitle — 400 when title is missing. +func TestLibraryEntry_MissingTitle(t *testing.T) { + srv := testServer(t) + rr := doRequest(srv, "GET", "/api/v1/library/entry", nil) + if rr.Code != http.StatusBadRequest { + t.Errorf("expected 400 for missing title, got %d", rr.Code) + } +} + +// TestLibraryEntry_NotFound — 404 when title doesn't match anything. +func TestLibraryEntry_NotFound(t *testing.T) { + srv := testServer(t) + rr := doRequest(srv, "GET", "/api/v1/library/entry?title="+url.QueryEscape("definitely not a real library entry"), nil) + if rr.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", rr.Code) + } +} diff --git a/internal/server/handlers_playbook_library.go b/internal/server/handlers_playbook_library.go index 2c113a80..9ee0d5c6 100644 --- a/internal/server/handlers_playbook_library.go +++ b/internal/server/handlers_playbook_library.go @@ -6,11 +6,52 @@ import ( "github.com/PerpetualSoftware/pad/internal/collections" ) +// handlePlaybookLibrary returns the global playbook library. +// +// Query params (all optional): +// - category= — return only the matching category. Case-sensitive +// exact match against PlaybookCategory.Name. Unknown categories return +// an empty Categories slice. +// - summary=true — strip Content from each LibraryPlaybook and inject +// a Summary field via collections.PlaybookSummary. Keeps payloads +// compact for browsing surfaces (CLI default, MCP). Web UI and other +// consumers that want the full body omit the flag. +// +// TASK-1561 / PLAN-1560. No workspace context — the library is global. func (s *Server) handlePlaybookLibrary(w http.ResponseWriter, r *http.Request) { type response struct { Categories []collections.PlaybookCategory `json:"categories"` } - writeJSON(w, http.StatusOK, response{ - Categories: collections.PlaybookLibrary(), - }) + + cats := collections.PlaybookLibrary() + + if category := r.URL.Query().Get("category"); category != "" { + filtered := make([]collections.PlaybookCategory, 0, 1) + for _, cat := range cats { + if cat.Name == category { + filtered = append(filtered, cat) + } + } + cats = filtered + } + + if r.URL.Query().Get("summary") == "true" { + // Materialize a summary-mode copy. Categories carry value-type + // playbook slices, so we can mutate freely after copying without + // touching the package-level library data. + summarized := make([]collections.PlaybookCategory, len(cats)) + for i, cat := range cats { + pbs := make([]collections.LibraryPlaybook, len(cat.Playbooks)) + for j, pb := range cat.Playbooks { + pb.Summary = collections.PlaybookSummary(pb.Content) + pb.Content = "" + pbs[j] = pb + } + cat.Playbooks = pbs + summarized[i] = cat + } + cats = summarized + } + + writeJSON(w, http.StatusOK, response{Categories: cats}) } diff --git a/internal/server/server.go b/internal/server/server.go index 9f75906f..3d523dc9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -961,6 +961,10 @@ func (s *Server) setupRouter() { // Playbook Library r.Get("/playbook-library", s.handlePlaybookLibrary) + // Single library entry by title (conventions first, then playbooks). + // TASK-1561 / PLAN-1560. + r.Get("/library/entry", s.handleLibraryEntry) + // URL import — fetch a remote page and return markdown. // Side-effect-free; the client decides what to do with the // markdown. See PLAN-1467 / TASK-1472 / internal/urlimport.