Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 26 additions & 13 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
# Exports use the workspace-aware shape:
#
# prompts:
# - name: coder
# - id: coder
# name: coder
# content: |
# ...
# workspaces:
Expand Down Expand Up @@ -104,46 +105,58 @@ skills:
# prompts, reusable task/personality contracts
# ──────────────────────────────────────────────
prompts:
- name: scout-issues
- id: scout-issues
name: scout-issues
content: |
Inspect the repository for user-visible bugs, stale docs, or maintenance gaps.
Open or update GitHub issues with concrete evidence and next steps.
- name: scout-code
- id: scout-code
name: scout-code
content: |
Inspect the codebase for architectural debt, risky complexity, and weak invariants.
Capture findings as concrete GitHub issues with reproduction details when possible.
- name: coder
- id: coder
name: coder
content: |
Implement the requested change end-to-end.
Run the narrowest meaningful tests and open a pull request when the work is ready.
- name: refactorer
- id: refactorer
name: refactorer
content: |
Refactor code for clarity and maintainability without changing behavior.
Leave the repo in a state where tests still pass.
- name: docs-writer
- id: docs-writer
name: docs-writer
content: |
Read the code before editing docs.
Keep README, CLAUDE.md, AGENTS.md, and docs/*.md aligned with shipped behavior.
- name: pr-reviewer
- id: pr-reviewer
name: pr-reviewer
content: |
Review pull requests for correctness, regressions, and missing tests.
Prefer concrete findings over broad summaries.
- name: product-strategist
- id: product-strategist
name: product-strategist
content: |
Convert technical findings into product priorities, tradeoffs, and sequencing advice.
- name: arch-reviewer
- id: arch-reviewer
name: arch-reviewer
content: |
Review the change for architecture boundaries, coupling, and long-term maintainability.
- name: sec-reviewer
- id: sec-reviewer
name: sec-reviewer
content: |
Review the change for security risks, trust-boundary violations, and unsafe defaults.
- name: test-reviewer
- id: test-reviewer
name: test-reviewer
content: |
Review the change for missing tests, brittle assertions, and blind spots in coverage.
- name: ops-reviewer
- id: ops-reviewer
name: ops-reviewer
content: |
Review the change for CI reliability, deploy safety, and observability gaps.
- name: dx-reviewer
- id: dx-reviewer
name: dx-reviewer
content: |
Review the change for onboarding friction, confusing naming, and stale documentation.

Expand Down
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ Prompt catalog rows expose a stable public `id` plus a display `name`; the SQLit
|---|---|---|
| `GET` | `/{resource}` | List entries for a resource type (`workspaces`, `prompts`, `skills`, `backends`, `repos`, `guardrails`) with `limit`, `offset`, `total`, and `items`. Note: `GET /agents` is the workspace-filterable fleet snapshot above, not the CRUD list, but uses the same paginated envelope. |
| `GET` | `/{resource}/{name-or-id}` | Fetch one entry. Repos use two path segments: `/repos/{owner}/{repo}`. Catalog routes (`prompts`, `skills`, `guardrails`) use stable public refs; legacy global names are accepted as a compatibility fallback. |
| `POST` | `/{resource}` | Create or replace an entry. Resources: `workspaces`, `prompts`, `agents`, `skills`, `backends`, `repos`, `guardrails`. |
| `POST` | `/{resource}` | Create or replace an entry. Resources: `workspaces`, `prompts`, `agents`, `skills`, `backends`, `repos`, `guardrails`. New catalog entries (`prompts`, `skills`, `guardrails`) must include an explicit stable public `id`; omitting `id` is accepted only when updating an existing same-scope/name catalog row. |
| `PATCH` | `/{resource}/{name-or-id}` | Partial update of an entry. Only fields present in the JSON body are applied; unset fields are preserved. At least one field required. Resources: `workspaces`, `prompts`, `agents`, `skills`, `backends`, `guardrails`. Catalog routes (`prompts`, `skills`, `guardrails`) use stable public refs; legacy global names are accepted as a compatibility fallback. |
| `PATCH` | `/repos/{owner}/{repo}` | Toggle a repo's `enabled` flag. Only `enabled` is patchable; binding edits go through `/repos/{owner}/{repo}/bindings/{id}`, and full repo replacement (including bindings) goes through `POST /repos`. |
| `DELETE` | `/{resource}/{name-or-id}` | Remove an entry. Catalog routes (`prompts`, `skills`, `guardrails`) use stable public refs; legacy global names are accepted as a compatibility fallback. |
Expand Down
13 changes: 9 additions & 4 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,14 +139,16 @@ backends:

```yaml
prompts:
- name: coder
- id: coder
name: coder
description: Implements approved work
content: |
Implement the requested change end-to-end.
Run focused tests before opening a pull request.
```

Prompts are reusable assets. Empty `workspace_id` and `repo` make a prompt
Prompts are reusable assets. `id` is the stable public prompt ref and is
required for new prompt rows. Empty `workspace_id` and `repo` make a prompt
globally visible; `workspace_id` with empty `repo` makes it visible only inside
that workspace; `workspace_id` plus `repo` makes it visible only to repo-scoped
agents for that repo. Agents use stable public `prompt_id` refs. Human-facing
Expand Down Expand Up @@ -176,7 +178,8 @@ skills:
Avoid combined method-switch handlers; method policy should stay explicit and testable at the route boundary.
```

Skills are keyed by stable public ref. For compatibility, agents may reference a visible
Skills are keyed by explicit stable public ref. New skill rows require a
non-empty map key. For compatibility, agents may reference a visible
skill by display `name` when that name is unambiguous; import stores the stable
ref so later duplicate names across global, workspace, and repo scopes remain
deterministic. SQLite stores opaque internal IDs behind those refs for FK
Expand Down Expand Up @@ -347,7 +350,8 @@ guardrails:
position: 0

# Operator-added guardrails: any policy block you want prepended to every run.
- name: code-style
- id: code-style
name: code-style
description: "Project coding conventions."
content: |
Always run `gofmt` before committing. Prefer `any` over `interface{}` in new
Expand All @@ -364,6 +368,7 @@ guardrail refs; imports may use a visible display name when it is unambiguous.

Rules:

- `id` is the stable public guardrail ref and is required for new guardrail rows. Omitting `id` is accepted only when importing over an existing same-scope/name row, so historical refs are preserved.
- `name` is a stable identifier, normalised to lowercase + dash-joined.
- `content` is the text the agent sees, prepended verbatim to the System portion of its prompt.
- `enabled` on the catalog record stores the default state copied into new workspace references. The workspace reference's `enabled` flag controls whether that workspace renders it.
Expand Down
4 changes: 2 additions & 2 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,12 @@ For agent lifecycle changes, use `create_agent` when adding a new agent or inten
| `delete_agent` | Delete an agent. `cascade=true` also removes repo bindings. |
| `list_skills` | List all skill catalog entries with prompt content, including global, workspace-scoped, and repo-scoped skills. |
| `get_skill` | Fetch one skill by stable public ref; legacy global display-name lookup is accepted as a fallback. |
| `create_skill` | Create or update a skill catalog entry. |
| `create_skill` | Create or update a skill catalog entry. New skills require an explicit stable public `id`; omitting `id` is accepted only when updating an existing same-scope/name skill. |
| `update_skill` | Partially update a skill by stable public ref; legacy global display-name lookup is accepted as a fallback. |
| `delete_skill` | Delete a skill by stable public ref; legacy global display-name lookup is accepted as a fallback. |
| `list_prompts` | List all prompt catalog entries, including global, workspace-scoped, and repo-scoped prompts. |
| `get_prompt` | Fetch one prompt by stable public ref, or by `name` plus optional `workspace_id` / `repo` when unambiguous. |
| `create_prompt` | Create or update a prompt catalog entry. |
| `create_prompt` | Create or update a prompt catalog entry. New prompts require an explicit stable public `id`; omitting `id` is accepted only when updating an existing same-scope/name prompt. |
| `update_prompt` | Partially update a prompt by stable public ref, or by `name` plus optional `workspace_id` / `repo` when unambiguous. |
| `delete_prompt` | Delete a prompt by stable public ref, or by `name` plus optional `workspace_id` / `repo` when unambiguous. |
| `list_workspaces` | List all workspaces. |
Expand Down
76 changes: 57 additions & 19 deletions internal/daemon/crud_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ func seedStoreSkill(t *testing.T, s *daemon.Daemon, name string) {

func seedStorePrompt(t *testing.T, s *daemon.Daemon, name string) {
t.Helper()
if _, err := s.Store().UpsertPrompt(fleet.Prompt{Name: name, Content: "prompt body"}); err != nil {
if _, err := s.Store().ReadPrompt(name); err == nil {
return
}
if _, err := s.Store().UpsertPrompt(fleet.Prompt{ID: "prompt_" + name, Name: name, Content: "prompt body"}); err != nil {
t.Fatalf("seedStorePrompt %s: %v", name, err)
}
}
Expand Down Expand Up @@ -212,11 +215,11 @@ func TestStoreCRUDAgentCreateAcceptsPromptIDWithDerivedRef(t *testing.T) {
seedStorePrompt(t, s, "coder")

rr := doRawCRUDRequest(t, s, http.MethodPost, "/agents", map[string]any{
"name": "coder", "backend": "claude", "prompt_id": "prompt_coder", "prompt_ref": "coder",
"name": "coder", "backend": "claude", "prompt_id": "coder", "prompt_ref": "coder",
"description": "coding agent", "skills": []string{}, "can_dispatch": []string{},
})
if rr.Code != http.StatusOK {
t.Fatalf("POST /agents prompt id plus derived ref: got %d, want 200, %s", rr.Code, rr.Body.String())
t.Fatalf("POST /agents prompt id plus ref: got %d, want 200, %s", rr.Code, rr.Body.String())
}
var out storeAgentJSON
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
Expand Down Expand Up @@ -424,7 +427,7 @@ func TestStoreCRUDSkillCreateAndDelete(t *testing.T) {
s := openCRUDTestServer(t)

rr := doCRUDRequest(t, s, http.MethodPost, "/skills", map[string]any{
"name": "architect", "prompt": "Focus on architecture.",
"id": "architect", "name": "architect", "prompt": "Focus on architecture.",
})
if rr.Code != http.StatusOK {
t.Fatalf("POST skill: got %d, %s", rr.Code, rr.Body.String())
Expand Down Expand Up @@ -460,6 +463,7 @@ func TestStoreCRUDPromptCreatePatchDelete(t *testing.T) {
s := openCRUDTestServer(t)

rr := doCRUDRequest(t, s, http.MethodPost, "/prompts", map[string]any{
"id": "prompt_release-notes",
"name": "release-notes",
"description": "Drafts releases",
"content": "Summarize merged work.",
Expand Down Expand Up @@ -508,13 +512,39 @@ func TestStoreCRUDPromptCreatePatchDelete(t *testing.T) {
}
}

func TestStoreCRUDCatalogCreateRequiresExplicitID(t *testing.T) {
t.Parallel()
s := openCRUDTestServer(t)

tests := []struct {
name string
path string
body map[string]any
}{
{name: "prompt", path: "/prompts", body: map[string]any{"name": "new-prompt", "content": "body"}},
{name: "skill", path: "/skills", body: map[string]any{"name": "new-skill", "prompt": "body"}},
{name: "guardrail", path: "/guardrails", body: map[string]any{"name": "new-guardrail", "content": "body", "enabled": true}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
rr := doCRUDRequest(t, s, http.MethodPost, tc.path, tc.body)
if rr.Code != http.StatusBadRequest {
t.Fatalf("POST %s without id: got %d, want 400, %s", tc.path, rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "requires explicit id") {
t.Fatalf("POST %s error = %q, want explicit id guidance", tc.path, rr.Body.String())
}
})
}
}

func TestStoreCRUDPromptScopedDuplicatesUseStableID(t *testing.T) {
t.Parallel()
s := openCRUDTestServer(t)

for _, body := range []map[string]any{
{"workspace_id": "team-a", "name": "shared", "content": "Team prompt."},
{"workspace_id": "team-b", "name": "shared", "content": "Other prompt."},
{"id": "prompt_team-a_shared", "workspace_id": "team-a", "name": "shared", "content": "Team prompt."},
{"id": "prompt_team-b_shared", "workspace_id": "team-b", "name": "shared", "content": "Other prompt."},
} {
rr := doCRUDRequest(t, s, http.MethodPost, "/prompts", body)
if rr.Code != http.StatusOK {
Expand Down Expand Up @@ -817,6 +847,7 @@ func TestStoreCRUDGuardrailCreatePatchDelete(t *testing.T) {

// Create operator-added guardrail.
rr := doCRUDRequest(t, s, http.MethodPost, "/guardrails", map[string]any{
"id": "code-style",
"name": "Code Style",
"description": "Project conventions",
"content": "Always run gofmt.",
Expand Down Expand Up @@ -892,7 +923,7 @@ func TestStoreCRUDGuardrailReset(t *testing.T) {

// Reset on an operator-added row (no default) returns 400.
if rr := doCRUDRequest(t, s, http.MethodPost, "/guardrails", map[string]any{
"name": "code-style", "content": "x", "enabled": true,
"id": "code-style", "name": "code-style", "content": "x", "enabled": true,
}); rr.Code != http.StatusOK {
t.Fatalf("POST seed code-style: got %d, %s", rr.Code, rr.Body.String())
}
Expand Down Expand Up @@ -2029,6 +2060,7 @@ func TestStoreCRUDPostReturnsCanonicalForm(t *testing.T) {
// POST with mixed-case name and whitespace-padded prompt; response must
// have lowercase name and trimmed prompt.
rr = doCRUDRequest(t, s, http.MethodPost, "/skills", map[string]any{
"id": "architect",
"name": "Architect",
"prompt": " Focus on design. ",
})
Expand Down Expand Up @@ -2246,7 +2278,8 @@ func TestStoreImportWorkspaceShape(t *testing.T) {
claude:
command: claude
prompts:
- name: imported-prompt
- id: prompt_imported-prompt
name: imported-prompt
content: imported prompt
skills: {}
workspaces:
Expand Down Expand Up @@ -2342,7 +2375,8 @@ func TestStoreImportRoundTrip(t *testing.T) {
skills: []
can_dispatch: []
prompts:
- name: imported-agent
- id: prompt_imported-agent
name: imported-agent
content: imported prompt
skills:
imported-skill:
Expand Down Expand Up @@ -2744,7 +2778,8 @@ func TestImportYAMLPreservesRuntimeWhenOmitted(t *testing.T) {
claude:
command: claude
prompts:
- name: imported-agent
- id: prompt_imported-agent
name: imported-agent
content: imported prompt
agents:
- name: imported-agent
Expand Down Expand Up @@ -2810,7 +2845,8 @@ agents:
skills: []
can_dispatch: []
prompts:
- name: new-agent
- id: prompt_new-agent
name: new-agent
content: fresh
repos:
- name: owner/new-repo
Expand Down Expand Up @@ -2955,7 +2991,8 @@ agents:
skills: []
can_dispatch: []
prompts:
- name: scout
- id: prompt_scout
name: scout
content: p
repos:
- name: owner/existing-repo
Expand Down Expand Up @@ -3010,7 +3047,8 @@ agents:
skills: []
can_dispatch: []
prompts:
- name: scout
- id: prompt_scout
name: scout
content: p
repos:
- name: owner/new-repo
Expand Down Expand Up @@ -3133,11 +3171,11 @@ func TestStoreCRUDAgentPatchAcceptsPromptIDWithDerivedRef(t *testing.T) {
}

rr := doRawCRUDRequest(t, s, http.MethodPatch, "/agents/coder", map[string]any{
"prompt_id": "prompt_coder",
"prompt_id": "coder",
"prompt_ref": "coder",
})
if rr.Code != http.StatusOK {
t.Fatalf("PATCH /agents/coder prompt id plus derived ref: got %d, want 200, %s", rr.Code, rr.Body.String())
t.Fatalf("PATCH /agents/coder prompt id plus ref: got %d, want 200, %s", rr.Code, rr.Body.String())
}
var out storeAgentJSON
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
Expand Down Expand Up @@ -3312,7 +3350,7 @@ func TestStoreCRUDCatalogPatchPublishesCurrentVersion(t *testing.T) {
}

rr = doCRUDRequest(t, s, http.MethodPost, "/skills", map[string]any{
"name": "architect", "prompt": "architecture v1",
"id": "architect", "name": "architect", "prompt": "architecture v1",
})
if rr.Code != http.StatusOK {
t.Fatalf("POST architect skill: got %d, %s", rr.Code, rr.Body.String())
Expand Down Expand Up @@ -3346,7 +3384,7 @@ func TestStoreCRUDCatalogPatchPublishesCurrentVersion(t *testing.T) {
}

rr = doCRUDRequest(t, s, http.MethodPost, "/guardrails", map[string]any{
"name": "Guardrail A", "description": "v1", "content": "guardrail v1", "enabled": true, "position": 10,
"id": "guardrail-a", "name": "Guardrail A", "description": "v1", "content": "guardrail v1", "enabled": true, "position": 10,
})
if rr.Code != http.StatusOK {
t.Fatalf("POST guardrail: got %d, %s", rr.Code, rr.Body.String())
Expand Down Expand Up @@ -3376,8 +3414,8 @@ func TestStoreCRUDCatalogPatchPublishesCurrentVersion(t *testing.T) {
if len(guardrailVersions) != 2 || guardrailVersions[0].ID != guardrailVersionID || guardrailVersions[0].State != "published" {
t.Fatalf("guardrail versions = %+v, want current published v2 first", guardrailVersions)
}
if guardrailVersions[0].AssetID != "guardrail_guardrail-a" {
t.Fatalf("guardrail version asset_id = %q, want public ref guardrail_guardrail-a", guardrailVersions[0].AssetID)
if guardrailVersions[0].AssetID != "guardrail-a" {
t.Fatalf("guardrail version asset_id = %q, want public ref guardrail-a", guardrailVersions[0].AssetID)
}
}

Expand Down
2 changes: 2 additions & 0 deletions internal/daemon/daemon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ func seedProposalBundleRecommendation(t *testing.T, srv *daemon.Daemon, suffix s
t.Helper()
st := srv.Store()
prompt, err := st.UpsertPrompt(fleet.Prompt{
ID: "rest-bundle-prompt-" + suffix,
Name: "rest-bundle-prompt-" + suffix,
Description: "prompt desc",
Content: "prompt v1",
Expand All @@ -260,6 +261,7 @@ func seedProposalBundleRecommendation(t *testing.T, srv *daemon.Daemon, suffix s
guardrailName += "-" + suffix
}
if err := st.UpsertGuardrail(fleet.Guardrail{
ID: guardrailName,
Name: guardrailName,
Description: "guard desc v1",
Content: "guard v1",
Expand Down
6 changes: 5 additions & 1 deletion internal/daemon/daemontest/daemontest.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ func New(t *testing.T, cfg *config.Config) *daemon.Daemon {
if agents[i].PromptRef == "" && agents[i].PromptID == "" {
agents[i].PromptRef = agents[i].Name
}
if _, err := st.UpsertPrompt(fleet.Prompt{Name: agents[i].PromptRef, Content: "test prompt"}); err != nil {
promptRef := agents[i].PromptRef
if promptRef == "" {
promptRef = agents[i].PromptID
}
if _, err := st.UpsertPrompt(fleet.Prompt{ID: promptRef, Name: promptRef, Content: "test prompt"}); err != nil {
t.Fatalf("seed prompt %s: %v", agents[i].PromptRef, err)
}
}
Expand Down
Loading
Loading