mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 05:19:30 +02:00
Compare commits
28 Commits
agent/lamb
...
feat/built
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8ed9f7250 | ||
|
|
b2a43cf58a | ||
|
|
cc67b2088f | ||
|
|
fdd5e82dfd | ||
|
|
6ca27ad749 | ||
|
|
496890011d | ||
|
|
635f653f71 | ||
|
|
75250774af | ||
|
|
ca78c02ab9 | ||
|
|
e214b1332c | ||
|
|
5dadc38e6d | ||
|
|
a9405d5eac | ||
|
|
8fea549eaf | ||
|
|
923d895a39 | ||
|
|
181337f1ad | ||
|
|
fc0bb57d78 | ||
|
|
fa7437dff0 | ||
|
|
47d716a939 | ||
|
|
fbbfa80181 | ||
|
|
c46c381bc3 | ||
|
|
29fafd0b06 | ||
|
|
f08f1db219 | ||
|
|
1a820cca67 | ||
|
|
532e1fa570 | ||
|
|
5de237ffcc | ||
|
|
afdaedc1c2 | ||
|
|
d782b5745e | ||
|
|
fa9919a969 |
@@ -176,6 +176,7 @@ make start-worktree # Start using .env.worktree
|
||||
- Avoid broad refactors unless required by the task.
|
||||
- New global (pre-workspace) routes MUST use a single word (`/login`, `/inbox`) or a `/{noun}/{verb}` pair (`/workspaces/new`). NEVER add hyphenated word-group root routes (`/new-workspace`, `/create-team`) — they collide with common user workspace names and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
|
||||
- The reserved-slug list lives in **one** place: `server/internal/handler/reserved_slugs.json`. The Go side embeds the JSON; `packages/core/paths/reserved-slugs.ts` is generated from it by `pnpm generate:reserved-slugs`. Edit the JSON, run the generator, commit both. CI re-runs the generator and fails on any drift, so a stale TS file cannot land.
|
||||
- When you change a CLI command or flag, an API request/response field, or product behavior that a built-in skill documents (`server/internal/service/builtin_skills/*`), update that skill's `SKILL.md` **and** its `references/*-source-map.md` in the same PR. The built-in skills are source-traced contracts shipped to agents — if the code moves and the skill doesn't, it silently teaches stale behavior.
|
||||
|
||||
### API Response Compatibility
|
||||
|
||||
|
||||
@@ -658,7 +658,16 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
b.WriteString("Detailed skill instructions are in `.agent_context/skills/`. Each subdirectory contains a `SKILL.md`.\n\n")
|
||||
}
|
||||
for _, skill := range ctx.AgentSkills {
|
||||
fmt.Fprintf(&b, "- **%s**\n", skill.Name)
|
||||
// Emit the skill's one-line description alongside its name so the
|
||||
// brief carries a "when to load" trigger signal. Claude-family
|
||||
// providers get this natively from frontmatter discovery; providers
|
||||
// without native discovery (hermes/default) only ever see this
|
||||
// list, so a bare name gives them no signal for on-demand loading.
|
||||
if desc := strings.TrimSpace(skill.Description); desc != "" {
|
||||
fmt.Fprintf(&b, "- **%s** — %s\n", skill.Name, desc)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "- **%s**\n", skill.Name)
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
@@ -1109,7 +1109,11 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
// Build response with fresh agent data (name + skills + custom_env + custom_args).
|
||||
resp := taskToResponse(*task, runtimeWorkspaceID)
|
||||
if agent, err := h.Queries.GetAgent(r.Context(), task.AgentID); err == nil {
|
||||
// Workspace-bound skills first, then platform built-in skills. Built-in
|
||||
// names carry a "multica-" prefix so their on-disk slugs never collide
|
||||
// with a user-authored workspace skill (see writeSkillFiles).
|
||||
skills := h.TaskService.LoadAgentSkills(r.Context(), task.AgentID)
|
||||
skills = append(skills, h.TaskService.BuiltinSkills()...)
|
||||
var customEnv map[string]string
|
||||
if agent.CustomEnv != nil {
|
||||
if err := json.Unmarshal(agent.CustomEnv, &customEnv); err != nil {
|
||||
|
||||
72
server/internal/service/builtin_skills.go
Normal file
72
server/internal/service/builtin_skills.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed builtin_skills
|
||||
var builtinSkillsFS embed.FS
|
||||
|
||||
const builtinSkillsRoot = "builtin_skills"
|
||||
|
||||
// BuiltinSkills returns the platform's built-in skills, embedded at compile
|
||||
// time. Every agent receives these on top of its workspace-bound skills, so
|
||||
// they teach platform-wide "how to" workflows (e.g. mentioning) that the
|
||||
// runtime brief intentionally leaves to skills.
|
||||
//
|
||||
// Layout: builtin_skills/<name>/SKILL.md plus optional supporting files. The
|
||||
// <name> directory carries a "multica-" prefix so its on-disk slug can never
|
||||
// collide with a workspace skill a user authored (see writeSkillFiles, which
|
||||
// derives the skill directory from AgentSkillData.Name).
|
||||
func (s *TaskService) BuiltinSkills() []AgentSkillData {
|
||||
return loadBuiltinSkills()
|
||||
}
|
||||
|
||||
func loadBuiltinSkills() []AgentSkillData {
|
||||
entries, err := fs.ReadDir(builtinSkillsFS, builtinSkillsRoot)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var skills []AgentSkillData
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if skill, ok := loadBuiltinSkill(entry.Name()); ok {
|
||||
skills = append(skills, skill)
|
||||
}
|
||||
}
|
||||
return skills
|
||||
}
|
||||
|
||||
func loadBuiltinSkill(name string) (AgentSkillData, bool) {
|
||||
dir := path.Join(builtinSkillsRoot, name)
|
||||
content, err := fs.ReadFile(builtinSkillsFS, path.Join(dir, "SKILL.md"))
|
||||
if err != nil {
|
||||
// A skill directory without a SKILL.md is malformed — skip it rather
|
||||
// than ship an empty skill.
|
||||
return AgentSkillData{}, false
|
||||
}
|
||||
skill := AgentSkillData{Name: name, Content: string(content)}
|
||||
// Any other file in the directory becomes a supporting file, preserving
|
||||
// its relative path so subdirectories (e.g. rules/styling.md) survive.
|
||||
_ = fs.WalkDir(builtinSkillsFS, dir, func(p string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil || d.IsDir() {
|
||||
return walkErr
|
||||
}
|
||||
rel := strings.TrimPrefix(p, dir+"/")
|
||||
if rel == "SKILL.md" {
|
||||
return nil
|
||||
}
|
||||
data, readErr := fs.ReadFile(builtinSkillsFS, p)
|
||||
if readErr != nil {
|
||||
return nil
|
||||
}
|
||||
skill.Files = append(skill.Files, AgentSkillFileData{Path: rel, Content: string(data)})
|
||||
return nil
|
||||
})
|
||||
return skill, true
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: multica-autopilots
|
||||
description: Use when creating, updating, inspecting, triggering, or debugging Multica autopilots. Covers the full chain: schedule/webhook/manual trigger, create_issue vs run_only execution, agent/squad leader admission, runs, created issues/tasks, webhook URL rotation, and side-effect boundaries.
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(multica *)
|
||||
---
|
||||
|
||||
# Multica Autopilots
|
||||
|
||||
## Quick start
|
||||
|
||||
Autopilots are durable automations. Read before mutating:
|
||||
|
||||
```bash
|
||||
multica autopilot list --output json
|
||||
multica autopilot get <autopilot-id> --output json
|
||||
multica autopilot runs <autopilot-id> --output json
|
||||
```
|
||||
|
||||
Do not run `trigger`, `delete`, `trigger-delete`, or `trigger-rotate-url` to test. Those are real side effects.
|
||||
|
||||
## Core model
|
||||
|
||||
An autopilot is not an agent. It is a rule that dispatches work to an agent, or to a squad's leader agent.
|
||||
|
||||
The chain is: trigger fires (`schedule`, `webhook`, or `manual`) -> `autopilot_run` row -> `execution_mode` decides output -> assignee readiness check -> issue/task execution -> run status sync.
|
||||
|
||||
Execution modes:
|
||||
|
||||
- `create_issue` creates a Multica issue, making the run visible as issue state.
|
||||
- `run_only` creates an agent task directly. No issue is created; any durable
|
||||
report location has to come from other task context or instructions.
|
||||
|
||||
`issue-title-template` only supports `{{date}}`. Do not invent `{{trigger_id}}`, `{{branch}}`, or other variables.
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
multica autopilot list --output json
|
||||
multica autopilot get <autopilot-id> --output json
|
||||
multica autopilot create --title "<title>" --description "<task prompt>" --agent <agent-name-or-id> --mode create_issue|run_only --output json
|
||||
multica autopilot update <autopilot-id> --status active|paused --output json
|
||||
multica autopilot runs <autopilot-id> --output json
|
||||
multica autopilot trigger-add <autopilot-id> --kind schedule --cron "0 9 * * *" --timezone Asia/Shanghai --output json
|
||||
multica autopilot trigger-add <autopilot-id> --kind webhook --label "ci" --output json
|
||||
multica autopilot trigger <autopilot-id> --output json
|
||||
multica autopilot trigger-rotate-url <autopilot-id> <trigger-id> --yes --output json
|
||||
```
|
||||
|
||||
Use `trigger` only when the user explicitly asks for a manual run. Use `trigger-rotate-url` only when rotating a webhook URL; the old URL stops being valid.
|
||||
|
||||
Webhook trigger output can include a URL/token. Do not paste webhook tokens or signing material into comments, logs, docs, or PRs. Redact secrets.
|
||||
|
||||
## Debugging
|
||||
|
||||
For "why didn't it run":
|
||||
|
||||
1. `multica autopilot get <id> --output json` — status, mode, assignee, triggers.
|
||||
2. `multica autopilot runs <id> --output json` — run status and failure reason.
|
||||
3. If assigned to a squad, inspect the squad: `multica squad get <squad-id> --output json`; execution goes to the leader.
|
||||
4. Inspect the target agent/runtime: `multica agent get <agent-id> --output json` and `multica runtime list --output json`.
|
||||
5. For `create_issue`, inspect the created issue if the run records one.
|
||||
|
||||
## Side effects
|
||||
|
||||
These mutate durable state or start work: `create`, `update`, `delete`, trigger add/update/delete/rotate, `trigger`, and webhook calls to `/api/webhooks/autopilots/{token}`.
|
||||
|
||||
More source-backed details: `references/autopilots-source-map.md`.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Autopilots source map
|
||||
|
||||
- `server/cmd/multica/cmd_autopilot.go` registers `list`, `get`, `create`, `update`, `delete`, `trigger`, `runs`, `trigger-add`, `trigger-update`, `trigger-delete`, and `trigger-rotate-url`.
|
||||
- The CLI maps reads/writes to `/api/autopilots`, `/api/autopilots/{id}`, `/api/autopilots/{id}/trigger`, `/api/autopilots/{id}/runs`, and trigger subroutes.
|
||||
- `server/internal/service/autopilot.go` has `DispatchAutopilot`, creates `autopilot_run`, and switches on `execution_mode`.
|
||||
- `create_issue` calls `dispatchCreateIssue`; `run_only` calls `dispatchRunOnly`.
|
||||
- `resolveAutopilotLeader` resolves squad-assigned autopilots to the squad leader.
|
||||
- `AgentReadiness` blocks archived/runtime-unready agents before enqueue.
|
||||
- `server/cmd/server/router.go` exposes authenticated `/api/autopilots` routes and unauthenticated webhook ingress `/api/webhooks/autopilots/{token}`.
|
||||
@@ -0,0 +1,187 @@
|
||||
---
|
||||
name: multica-creating-agents
|
||||
description: Use when creating, inspecting, or debugging a Multica agent through the `multica agent` CLI or `POST /api/agents` — what each field is, its persisted shape, whether it is metadata-only or consumed by the daemon at claim time, which inputs are validated/rejected, how custom_env secrets are gated, and how skill binding behaves. Not for assigning issues to existing agents or for runtime task prompts.
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(multica *)
|
||||
---
|
||||
|
||||
# Creating Multica agents
|
||||
|
||||
This is the contract for Multica's agent-creation path: what the create entry
|
||||
points accept, what the server validates and rejects, how each field is
|
||||
persisted, and which fields the daemon actually reads at claim time. It is
|
||||
not a parameter manual — it states source-traced facts, and every claim is
|
||||
backed by `file:line` in `references/creating-agents-source-map.md`.
|
||||
|
||||
## Quick start (read-only inspection)
|
||||
|
||||
These commands read state and have no side effects:
|
||||
|
||||
```bash
|
||||
multica agent get <agent-id> --output json # full persisted agent record
|
||||
multica agent skills list <agent-id> --output json # current skill bindings
|
||||
multica agent env get <agent-id> --output json # plaintext env (owner/admin only, agents denied)
|
||||
```
|
||||
|
||||
`agent get` returns the persisted agent including `runtime_id`, `model`,
|
||||
`thinking_level`, `custom_args`, `has_custom_env`, `custom_env_key_count`, and
|
||||
`skills`. It never returns plaintext `custom_env`.
|
||||
|
||||
## Core model
|
||||
|
||||
An agent is a workspace-scoped row (table `agent`). Creation is a single
|
||||
`POST /api/agents` (`multica agent create`). At task claim time the daemon
|
||||
re-reads the agent row and assembles the runtime payload — so the persisted
|
||||
fields, not the create-time output, are what the agent runs on.
|
||||
|
||||
Two distinct text fields, often confused:
|
||||
|
||||
- `description` is a catalog summary. It is stored and shown in listings; the
|
||||
daemon does NOT inject it into the agent's runtime prompt. Treat it as
|
||||
human-facing metadata only. Capped at 255 Unicode code points.
|
||||
- `instructions` is the runtime behavior contract. The daemon reads it at
|
||||
claim time and ships it to the provider as the agent's durable instructions.
|
||||
Persona, responsibilities, boundaries, output and escalation rules go here,
|
||||
not in `description`.
|
||||
|
||||
## CLI / API entry points
|
||||
|
||||
Minimum create call (`--name` and `--runtime-id` are both required):
|
||||
|
||||
```bash
|
||||
multica agent create --name <name> --runtime-id <runtime-id> \
|
||||
--description "<short catalog summary>" \
|
||||
--instructions "<runtime behavior contract>" \
|
||||
--output json
|
||||
```
|
||||
|
||||
`runAgentCreate` builds a JSON body and posts it to `/api/agents`. It only
|
||||
adds a key when its flag was provided — `description`/`instructions` on a
|
||||
non-empty value, the rest (`runtime-config`, `custom-args`, `model`,
|
||||
`visibility`, …) on the flag being `Changed` — so omitted flags fall through
|
||||
to server defaults rather than sending empty strings.
|
||||
|
||||
The HTTP body (`CreateAgentRequest`) accepts: `name`, `description`,
|
||||
`instructions`, `runtime_id`, `runtime_config`, `custom_env`, `custom_args`,
|
||||
`model`, `thinking_level`, `visibility`, `max_concurrent_tasks`, `mcp_config`.
|
||||
|
||||
## Field contracts
|
||||
|
||||
| Field | Persisted as | Validated? | Consumed by |
|
||||
|---|---|---|---|
|
||||
| `name` | `agent.name` | required, 400 if empty | listings, runtime payload |
|
||||
| `description` | `agent.description` | 400 if > 255 code points | catalog/listing only — NOT the runtime prompt |
|
||||
| `instructions` | `agent.instructions` | none | daemon → provider at claim time |
|
||||
| `runtime_id` | `agent.runtime_id` | required (400) + must resolve to a runtime in this workspace | selects runtime/provider |
|
||||
| `model` | `agent.model` (nullable) | none beyond runtime support | daemon reads; empty = runtime default |
|
||||
| `thinking_level` | `agent.thinking_level` (nullable) | provider-level enum; unknown literal → 400 | daemon; empty = runtime default |
|
||||
| `custom_args` | `agent.custom_args` (JSON array) | JSON shape checked CLI-side; server stores as-is | daemon (extra CLI switches); defaults to `[]` |
|
||||
| `runtime_config` | `agent.runtime_config` (JSON) | JSON shape checked CLI-side; server stores as-is | runtime-specific config; defaults to `{}` |
|
||||
| `custom_env` | `agent.custom_env` (JSON object) | — | daemon (process env); see Env & secrets |
|
||||
| `mcp_config` | `agent.mcp_config` (raw JSON) | literal `null` is dropped at create | daemon → provider (MCP servers) — **runtime-consumed**; redacted on read |
|
||||
| `visibility` | `agent.visibility` | — | access control; defaults to `private`; gates who can read/route a private agent (e.g. a private squad leader) — NOT the runtime prompt |
|
||||
| `max_concurrent_tasks` | `agent.max_concurrent_tasks` | — | scheduler task cap; defaults to `6` |
|
||||
|
||||
Defaults when omitted: `runtime_config` → `{}`, `custom_env` → `{}`,
|
||||
`custom_args` → `[]`, `visibility` → `private`, `max_concurrent_tasks` → `6`
|
||||
(all materialized server-side before the insert). `custom_args`/`runtime_config`
|
||||
are typed `[]string`/`any` and marshaled as-is — the JSON-shape rejection
|
||||
happens in the CLI, not the create handler.
|
||||
|
||||
`thinking_level` is validated only at the provider level: an unrecognized
|
||||
literal returns 400, but a value that is valid for the provider yet
|
||||
unsupported for the chosen model is NOT rejected here — that gap surfaces as a
|
||||
daemon-side task error at execution time.
|
||||
|
||||
### model vs custom_args
|
||||
|
||||
`model` is a first-class persisted column the daemon reads directly.
|
||||
`custom_args` are raw provider CLI args. The CLI help notes that some providers
|
||||
(codex app-server, openclaw) reject `--model` inside `custom_args` — but that is
|
||||
documented CLI guidance, not a server-enforced invariant; nothing in the create
|
||||
handler inspects `custom_args` for a model flag.
|
||||
|
||||
## Env & secrets
|
||||
|
||||
`custom_env` is secret material. The CLI offers three input channels; two keep
|
||||
secrets out of shell history and the process list:
|
||||
|
||||
```bash
|
||||
multica agent create --name <name> --runtime-id <runtime-id> --custom-env-stdin --output json
|
||||
multica agent create --name <name> --runtime-id <runtime-id> --custom-env-file <0600-json> --output json
|
||||
```
|
||||
|
||||
`--custom-env-stdin` reads the JSON object from stdin; `--custom-env-file`
|
||||
reads it from a file (suggested mode 0600). The third channel,
|
||||
`--custom-env <json>`, puts the value on the command line where shell history
|
||||
and `ps` can see it — avoid it for real secrets.
|
||||
|
||||
Read-side facts (these are the wrong assumptions to avoid):
|
||||
|
||||
- Agent resources never expose plaintext `custom_env`. `agent
|
||||
list/get/create/update` and WS events return only `has_custom_env` (bool) and
|
||||
`custom_env_key_count` (int).
|
||||
- Reading plaintext values requires the dedicated `GET /api/agents/{id}/env`
|
||||
endpoint (`multica agent env get`). It is gated to workspace **owner/admin**
|
||||
members, and **agent actors are denied** regardless of the backing member's
|
||||
role — a running agent cannot read another agent's secrets.
|
||||
- Writing values after creation does NOT go through `agent update`. The generic
|
||||
update handler rejects any `custom_env` field with a 400 ("use PUT
|
||||
/api/agents/{id}/env"). Plaintext env writes are handled by
|
||||
`PUT /api/agents/{id}/env` (`multica agent env set`), which is owner/admin-only
|
||||
and writes an audit row.
|
||||
|
||||
## Skill binding
|
||||
|
||||
Creating an agent does NOT bind any workspace skill — binding is a separate
|
||||
call after the agent exists. Two distinct verbs:
|
||||
|
||||
- `add` is additive — it merges the given ids with existing bindings
|
||||
(`POST /api/agents/{id}/skills/add`).
|
||||
- `set` is replace-all — it overwrites the entire binding list with exactly
|
||||
the given ids (`PUT /api/agents/{id}/skills`); `--skill-ids ''` clears all.
|
||||
|
||||
```bash
|
||||
multica agent skills add <agent-id> --skill-ids <skill-id> --output json
|
||||
multica agent skills list <agent-id> --output json
|
||||
```
|
||||
|
||||
At claim time the daemon assembles the agent's skills as workspace-bound skills
|
||||
FIRST, then appends the platform built-in skills. `LoadAgentSkills` loads each
|
||||
bound skill's content plus its supporting files; built-in skills are embedded
|
||||
at compile time and loaded from `SKILL.md` + sibling files. Both reach the
|
||||
provider as skill content — which is why capability belongs in a bound skill,
|
||||
not pasted into `instructions`.
|
||||
|
||||
## Side effects needing approval
|
||||
|
||||
Read-only (safe): `agent get`, `agent skills list`, `agent env get`.
|
||||
|
||||
State-changing (require an explicit instruction — do not run speculatively):
|
||||
|
||||
- `multica agent create` — inserts a new agent row.
|
||||
- `multica agent skills add` / `set` — mutate bindings (`set` is destructive:
|
||||
it drops bindings not in the new list).
|
||||
- `multica agent env set` — overwrites the full `custom_env` map and writes an
|
||||
audit row.
|
||||
|
||||
## Common wrong assumptions
|
||||
|
||||
- "`description` is the prompt." It is not — only `instructions` reaches the
|
||||
runtime. A rich description with empty instructions yields a named shell with
|
||||
no operating contract.
|
||||
- "Create binds the agent's skills." It does not; bind explicitly afterward.
|
||||
- "`agent update` can rotate env." It cannot — it 400s on `custom_env`; use the
|
||||
env endpoint.
|
||||
- "`agent get` shows env values." It shows only `has_custom_env` and
|
||||
`custom_env_key_count`.
|
||||
- "An invalid `thinking_level`/`model` combo is caught at create." Only an
|
||||
unknown provider-level literal is — model-specific gaps fail at run time.
|
||||
- "`set` and `add` are interchangeable for skills." `set` replaces all
|
||||
bindings; using it when you meant `add` silently removes capabilities.
|
||||
|
||||
## References
|
||||
|
||||
`references/creating-agents-source-map.md` maps every contract above to its
|
||||
`file:line` on the current tree, the runtime effect, and a safe read-only
|
||||
verification command.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Creating agents — source map
|
||||
|
||||
Evidence layer for `SKILL.md`. Every contract maps to `file:line` on the
|
||||
current tree (branch `feat/builtin-skills`, latest `main` merged), the runtime
|
||||
effect, and a safe read-only check. Line numbers were re-derived against this
|
||||
tree — re-derive again if the files move, the surrounding context (not the
|
||||
number) is the anchor.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Conformance eval for this skill (and the shared template invariants):
|
||||
go test ./internal/service -run TestCreatingAgentsSkillCoversAgentCreationContracts
|
||||
go test ./internal/service -run TestBuiltinSkillsConformToTemplate
|
||||
```
|
||||
|
||||
## CLI entry points — `server/cmd/multica/cmd_agent.go`
|
||||
|
||||
| Contract | Line | Behavior | Safe check |
|
||||
|---|---|---|---|
|
||||
| Create flags: `name`, `description`, `instructions`, `runtime-id` | 159–162 | Registered create flags; `name`/`runtime-id` enforced in `runAgentCreate` | `multica agent create --help` |
|
||||
| `runtime-config`, `model`, `custom-args` flags | 169–171 | `model` help: "Prefer this over passing --model in --custom-args"; `custom-args` help names codex/openclaw rejecting `--model` (CLI help only, not server-enforced) | `multica agent create --help` |
|
||||
| Secret-safe env input: `custom-env`, `custom-env-stdin`, `custom-env-file` | 172–174 | `--custom-env` warns about shell history / `ps`; stdin and file modes keep secrets off the command line; mutually exclusive | `multica agent create --help` |
|
||||
| `runAgentCreate` builds body + `POST /api/agents` | 409 | Only sets a body key when the flag `Changed`; posts to `/api/agents` (line 480) | read 409–491 |
|
||||
| Body assembly: description/instructions/runtime-config/custom-args/custom-env/model | 432–474 | `resolveCustomEnv` (458) gates the three env channels; omitted flags are not sent | read 432–474 |
|
||||
| `agent skills set` = replace-all | 814 | `PUT /api/agents/{id}/skills` (832); `--skill-ids ''` clears all (821) | `multica agent skills set --help` |
|
||||
| `agent skills add` = additive | 839 | `POST /api/agents/{id}/skills/add` (860); requires ≥1 id (849) | `multica agent skills add --help` |
|
||||
| `agent skills list` | 782 | reads bindings, no side effect | `multica agent skills list --help` |
|
||||
| `agent env get` | 916 | `GET /api/agents/{id}/env` | `multica agent env get --help` |
|
||||
| `agent env set` | 951 | `PUT /api/agents/{id}/env` with full `custom_env` map (965, 971) | `multica agent env set --help` |
|
||||
|
||||
Note: `--from-template` exists at line 168 and short-circuits to
|
||||
`runAgentCreateFromTemplate` (line 498). It is intentionally NOT taught — the
|
||||
template path is immature and out of scope for this skill.
|
||||
|
||||
## Create handler — `server/internal/handler/agent.go`
|
||||
|
||||
| Contract | Line | Behavior |
|
||||
|---|---|---|
|
||||
| `maxAgentDescriptionLength = 255` | 31 | Cap is 255 **Unicode code points** (comment: counted via `utf8.RuneCountInString`, matches Postgres `char_length`) |
|
||||
| `AgentResponse` omits plaintext `custom_env` | 33–53 | Exposes only `has_custom_env` (52) and `custom_env_key_count` (53); comment cites MUL-2600 |
|
||||
| `CreateAgentRequest` fields | 565–585 | `description`, `instructions`, `runtime_config`, `custom_env`, `custom_args`, `model`, `thinking_level` (plus name/avatar/visibility/mcp_config/max_concurrent_tasks) |
|
||||
| `name` required | 623–625 | 400 "name is required" |
|
||||
| `description` ≤ 255 code points | 627–629 | `utf8.RuneCountInString(req.Description) > maxAgentDescriptionLength` → 400 |
|
||||
| `runtime_id` required | 631–633 | `if req.RuntimeID == ""` → 400 "runtime_id is required" |
|
||||
| `runtime_id` must resolve in workspace | 642–658 | parsed + `GetAgentRuntimeForWorkspace`; unknown → 400 "invalid runtime_id" |
|
||||
| `thinking_level` provider-level validation | 673–676 | `!agent.IsKnownThinkingValue(runtime.Provider, req.ThinkingLevel)` → 400; per-model gaps deferred to daemon (comment 669–672, MUL-2339) |
|
||||
| Defaults: `{}` config/env, `[]` args | 688–701 | `RuntimeConfig`→`{}`, `CustomEnv`→`{}`, `CustomArgs`→`[]` when nil, before insert |
|
||||
| `visibility` default | 635–636 | `if req.Visibility == "" { req.Visibility = "private" }` — access-control field, not the runtime prompt |
|
||||
| `max_concurrent_tasks` default | 638–639 | `if req.MaxConcurrentTasks == 0 { req.MaxConcurrentTasks = 6 }` — scheduler cap |
|
||||
| `mcp_config` null-skip on create | 704–705 | raw JSON copied through unless the body value is the literal `null` |
|
||||
| `mcp_config` redacted on read | 54, 848–851 | `redactMcpConfig` sets `McpConfigRedacted=true`; a private agent read by a member also redacts (494, 509) |
|
||||
| `CreateAgent` insert params | 708–722 | persists runtime_config, instructions, custom_env, custom_args, model, thinking_level, mcp_config, visibility, max_concurrent_tasks |
|
||||
| `UpdateAgent` rejects `custom_env` | 910–913 | if `custom_env` present in body → 400 "use PUT /api/agents/{id}/env (or `multica agent env set`)" |
|
||||
| `description` ≤ 255 on update too | 921–924 | same cap re-checked on update |
|
||||
|
||||
## Env endpoint — `server/internal/handler/agent_env.go`
|
||||
|
||||
| Contract | Line | Behavior |
|
||||
|---|---|---|
|
||||
| `authorizeAgentEnv` gate | 66 | loads agent, then applies the two checks below |
|
||||
| Agent actors denied | 80–84 | `if actorType == "agent"` → 403 "agents may not access env management endpoints" (MUL-2600 impersonation guard) |
|
||||
| Owner/admin only | 86 | `requireWorkspaceRole(..., "owner", "admin")` |
|
||||
|
||||
## Routes — `server/cmd/server/router.go`
|
||||
|
||||
| Contract | Line | Behavior |
|
||||
|---|---|---|
|
||||
| `GET /env` | 603 | `h.GetAgentEnv` (plaintext read, gated) |
|
||||
| `PUT /env` | 604 | `h.UpdateAgentEnv` (full-map overwrite, gated) |
|
||||
|
||||
## Claim-time injection — `server/internal/handler/daemon.go`
|
||||
|
||||
| Contract | Line | Behavior |
|
||||
|---|---|---|
|
||||
| Fresh agent re-read on claim | 1109–1111 | `GetAgent(task.AgentID)` — claim uses persisted fields, not create output |
|
||||
| Workspace skills FIRST | 1115 | `skills := h.TaskService.LoadAgentSkills(...)` |
|
||||
| Built-ins appended | 1116 | `skills = append(skills, h.TaskService.BuiltinSkills()...)` |
|
||||
| Runtime payload | 1130–1143 | `TaskAgentData` carries `Instructions`, `Skills`, `CustomEnv`, `CustomArgs`, `Model`, `ThinkingLevel`, `McpConfig` (1130–1131, 1140) — confirms these are runtime-consumed; `description`, `visibility`, and `max_concurrent_tasks` are absent (not runtime-prompt fields) |
|
||||
|
||||
## Skill loading — `server/internal/service/task.go`
|
||||
|
||||
| Contract | Line | Behavior |
|
||||
|---|---|---|
|
||||
| `LoadAgentSkills` | 1685 | `ListAgentSkills` + per-skill `ListSkillFiles` → content + supporting files for execution |
|
||||
|
||||
## Built-in skills — `server/internal/service/builtin_skills.go`
|
||||
|
||||
| Contract | Line | Behavior |
|
||||
|---|---|---|
|
||||
| `go:embed builtin_skills` | 10–11 | skills embedded at compile time |
|
||||
| `loadBuiltinSkill` | 45 | reads `<name>/SKILL.md` (47) + walks sibling files into `Files` (56–68) |
|
||||
|
||||
## Persisted columns — `server/pkg/db/generated/agent.sql.go`
|
||||
|
||||
| Contract | Line | Behavior |
|
||||
|---|---|---|
|
||||
| `CreateAgent` INSERT | 730–736 | columns include `runtime_config, runtime_id, instructions, custom_env, custom_args, mcp_config, model, thinking_level` |
|
||||
| `CreateAgentParams` | 739–756 | typed params: `RuntimeConfig []byte`, `Instructions string`, `CustomEnv []byte`, `CustomArgs []byte`, `Model pgtype.Text`, `ThinkingLevel pgtype.Text` |
|
||||
| `UpdateAgent` SET | 2552–2566 | COALESCE updates of `runtime_config, instructions, custom_env, custom_args, model, thinking_level` — note `custom_env` is COALESCE-guarded but the handler rejects it before this query runs |
|
||||
| `UpdateAgentCustomEnv` (called by the `UpdateAgentEnv` handler) | 2652 | `SET custom_env = $2` — the only write path for env values |
|
||||
@@ -0,0 +1,136 @@
|
||||
---
|
||||
name: multica-mentioning
|
||||
description: Use when an issue comment needs to @mention someone — link to a person, trigger another agent, hand work to a squad, or broadcast with @all. Documents the verified mention contract: how a mention link is built from a real UUID, the four mention types and exactly what each one enqueues (agent → a run for that agent, squad → a run for the squad leader, member and issue → a rendered link with NO run), the @all broadcast and how it suppresses the assignee's auto-trigger, and the silent no-op cases (a name where a UUID belongs, a bad/unknown UUID, an already-pending task, an archived agent, a private agent you cannot access). WHETHER to mention — loop avoidance, staying silent on acknowledgements — lives in the runtime brief's Mentions section, not here. This skill is the backend contract only, traced to server/internal/util/mention.go and server/internal/handler/comment.go.
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(multica *)
|
||||
---
|
||||
|
||||
# Mentioning & Delegating
|
||||
|
||||
This skill states WHAT a mention link does in the Multica backend, traced to
|
||||
source. WHETHER to mention at all — loop avoidance, staying silent on
|
||||
acknowledgements — is in your runtime brief's Mentions section; follow that and
|
||||
do not repeat it here.
|
||||
|
||||
Every claim below is pinned to source in
|
||||
`references/mentioning-source-map.md`. If behavior ever differs from this
|
||||
document, the source map is where to re-check it.
|
||||
|
||||
## A mention link is built from a real UUID
|
||||
|
||||
The backend recognizes a mention only through this Markdown shape:
|
||||
|
||||
[@Label](mention://<type>/<id>)
|
||||
|
||||
The parser (`util.MentionRe` in `server/internal/util/mention.go`) accepts
|
||||
exactly four `<type>` values plus the `all` sentinel, and the `<id>` group
|
||||
accepts only hex characters and dashes, OR the literal string `all`:
|
||||
|
||||
(member|agent|squad|issue|all)/([0-9a-fA-F-]+|all)
|
||||
|
||||
So the link target is a real entity UUID (or `all`), never a display name. The
|
||||
label between the brackets is free text — that is where the human-readable name
|
||||
goes.
|
||||
|
||||
## Step 1 — look up the UUID with `--output json`
|
||||
|
||||
A name is not a UUID. Look the UUID up first, from the matching list command:
|
||||
|
||||
- a person → `multica workspace member list --output json` → use `user_id`
|
||||
- an agent → `multica agent list --output json` → use `id`
|
||||
- a squad → `multica squad list --output json` → use `id`
|
||||
|
||||
For a person the mention id is the `user_id`, NOT the membership-row id — the
|
||||
backend's own roster formatter uses `user_id` for member mentions. Match by
|
||||
display name. If the name is ambiguous or absent, do not guess — say so in your
|
||||
comment instead of emitting a broken link.
|
||||
|
||||
## Step 2 — the four types and exactly what each enqueues
|
||||
|
||||
Format: `[@Name](mention://<type>/<uuid>)`. The `<type>` and the id source must
|
||||
match, or the link resolves to the wrong entity (or to nothing).
|
||||
|
||||
| To… | type | uuid from | What the backend does |
|
||||
| -------------------- | -------- | --------------- | -------------------------------------------------------- |
|
||||
| trigger an agent | `agent` | agent.id | enqueues a run for that agent (`EnqueueTaskForMention`) |
|
||||
| hand work to a squad | `squad` | squad.id | resolves the squad's `leader_id` and enqueues a run for the LEADER agent |
|
||||
| link a person | `member` | member.user_id | renders a link; enqueues NOTHING — no agent run |
|
||||
| reference an issue | `issue` | issue.id | renders a link; enqueues NOTHING — always safe |
|
||||
|
||||
The enqueue logic lives in `enqueueMentionedAgentTasks`
|
||||
(`server/internal/handler/comment.go`). It iterates the parsed mentions and
|
||||
acts on two types only: the `squad` branch resolves the squad and enqueues its
|
||||
leader; everything that is not `agent` after that is skipped
|
||||
(`if m.Type != "agent" { continue }`), then the `agent` branch enqueues the
|
||||
run. A `member` or `issue` mention reaches neither branch, so it enqueues no
|
||||
task.
|
||||
|
||||
A `member` mention therefore does NOT make a person "run", and this skill does
|
||||
NOT claim it delivers a notification through the Go comment handler — there is
|
||||
no such code path in that handler (see the source map). What is verified is the
|
||||
contract above: only `agent` and `squad` mentions enqueue work.
|
||||
|
||||
## @all is the broadcast type
|
||||
|
||||
`@all` uses the literal `all`, never a UUID:
|
||||
|
||||
[@all](mention://all/all)
|
||||
|
||||
It addresses everyone on the issue. It does NOT make any specific agent run.
|
||||
And it is special at trigger time: in `commentMentionsOthersButNotAssignee`
|
||||
(`server/internal/handler/comment.go`), a comment that carries an `@all`
|
||||
mention is treated as a broadcast that SUPPRESSES the issue assignee's
|
||||
automatic on-comment trigger. Use `@all` to announce, not to request work from
|
||||
the assignee.
|
||||
|
||||
## What does NOT happen (so the result doesn't surprise you)
|
||||
|
||||
These are all silent no-ops — no error, no run:
|
||||
|
||||
- **A name where a UUID belongs.** `mention://member/Alice` is dead. The id
|
||||
group accepts only hex+dashes or `all`; the non-hex letters in a typical name
|
||||
make the whole pattern fail to match, so the parser returns nothing.
|
||||
- **A hex-ish but wrong UUID.** A well-formed-looking UUID that no entity owns
|
||||
DOES parse, then no-ops at lookup: the workspace-scoped query finds no agent
|
||||
and the loop `continue`s. Same agent-visible result (nothing fires), but the
|
||||
mechanism is the lookup miss, not a parse failure.
|
||||
- **An already-pending task.** Even a correct `@agent`/`@squad` is skipped when
|
||||
the target already has a pending task on this issue
|
||||
(`HasPendingTaskForIssueAndAgent` → `continue`). This is the loop guard — do
|
||||
not retry.
|
||||
- **An archived agent**, or a squad whose leader is archived: skipped
|
||||
(`RuntimeID` invalid or `ArchivedAt` set).
|
||||
- **A private agent you cannot access:** skipped — the mention path gates on
|
||||
`canAccessPrivateAgent` directly for both `@agent` and `@squad` (the
|
||||
`canEnqueueSquadLeader` wrapper is the assignment/child-done path, not this
|
||||
one).
|
||||
|
||||
## Incorrect → Correct
|
||||
|
||||
Incorrect: `@alice please review`
|
||||
→ plain text, no link, parses to nothing, nobody is reached.
|
||||
|
||||
Incorrect: `[@Alice](mention://member/Alice) please review`
|
||||
→ "Alice" is not a UUID; the id group rejects the non-hex letters, the
|
||||
pattern does not match, the link is silently dead.
|
||||
|
||||
Correct:
|
||||
1. `multica workspace member list --output json` → Alice's `user_id` = 7f3a…
|
||||
2. `[@Alice](mention://member/7f3a…) please review`
|
||||
→ a real `user_id` parses; the link renders and resolves to Alice.
|
||||
|
||||
@all broadcast: `[@all](mention://all/all) heads up` — addresses everyone,
|
||||
runs no specific agent, and suppresses the assignee auto-trigger.
|
||||
|
||||
These exact shapes are pinned by a Go behavior test
|
||||
(`TestMentioningSkillTeachesTheParserContract`) that feeds them through
|
||||
`util.ParseMentions`: the name form parses to nothing, the real-UUID form
|
||||
parses, `@all` parses to `{all, all}`, and a wrong `type` with a real UUID
|
||||
still parses (which is why the type must match the id source).
|
||||
|
||||
## References
|
||||
|
||||
`references/mentioning-source-map.md` — file:line evidence for the regex, the
|
||||
enqueue branches, the @all suppression, and the CLI id-source mapping, plus the
|
||||
explicit note that no member-notification delivery path exists in the Go
|
||||
comment handler.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Mentioning — source map
|
||||
|
||||
Every claim in `SKILL.md` traces to a line below. Re-derive against the current
|
||||
tree before trusting any line number; the behavior is the contract, the line is
|
||||
a pointer. Branch where verified: `feat/builtin-skills`.
|
||||
|
||||
## The mention grammar (what parses)
|
||||
|
||||
| Fact | Source |
|
||||
| --- | --- |
|
||||
| `MentionRe` — the only recognizer of a mention link | `server/internal/util/mention.go:16` |
|
||||
| Pattern: `` `\[@?(.+?)\]\(mention://(member\|agent\|squad\|issue\|all)/([0-9a-fA-F-]+\|all)\)` `` | `server/internal/util/mention.go:16` |
|
||||
| `<type>` group = `member \| agent \| squad \| issue \| all` | `server/internal/util/mention.go:16` |
|
||||
| `<id>` group = `[0-9a-fA-F-]+` (hex + dashes) **or** the literal `all` — so a typical name with non-hex letters never matches | `server/internal/util/mention.go:16` |
|
||||
| `ParseMentions` extracts and dedups `{Type, ID}` from `m[2]`/`m[3]` | `server/internal/util/mention.go:24-37` |
|
||||
| `Mention.Type` doc enum = "member", "agent", "issue", or "all" (squad added in regex) | `server/internal/util/mention.go:7` |
|
||||
| `HasMentionAll` reports whether any parsed mention is `all` | `server/internal/util/mention.go:40-47` |
|
||||
|
||||
### Parser behavior tests (pin the example shapes the skill uses)
|
||||
|
||||
| Case proven | Source |
|
||||
| --- | --- |
|
||||
| `mention://member/<real-uuid>` parses to `{member, uuid}` | `server/internal/util/mention_test.go:42-45` |
|
||||
| `mention://all/all` parses to `{all, all}` | `server/internal/util/mention_test.go:47-50` |
|
||||
| `mention://agent/<uuid>` parses; label may contain `[brackets]` | `server/internal/util/mention_test.go:13-35` |
|
||||
| plain text with no `mention://` parses to `nil` | `server/internal/util/mention_test.go:57-60` |
|
||||
| Skill eval: a name where a UUID belongs (`mention://member/Alice`) parses to `nil`; a bare `@name` parses to `nil`; a real UUID parses; `@all` → `{all, all}`; a **wrong** type with a real UUID still parses (points at the wrong entity) | `server/internal/service/builtin_skills_test.go:101-157` |
|
||||
|
||||
## What each mention type enqueues
|
||||
|
||||
| Fact | Source |
|
||||
| --- | --- |
|
||||
| `enqueueMentionedAgentTasks` is the dispatch loop over parsed mentions | `server/internal/handler/comment.go:1082` |
|
||||
| It is called for every comment via `triggerTasksForComment` | `server/internal/handler/comment.go:944` |
|
||||
| `squad` branch: resolve squad in workspace, read `LeaderID`, enqueue the leader | `server/internal/handler/comment.go:1089-1128` |
|
||||
| `squad` → `EnqueueTaskForSquadLeader` | `server/internal/handler/comment.go:1128` |
|
||||
| Everything not `agent` after the squad branch is skipped: `if m.Type != "agent" { continue }` | `server/internal/handler/comment.go:1133` |
|
||||
| `agent` branch: load agent in workspace, then enqueue | `server/internal/handler/comment.go:1143-1167` |
|
||||
| `agent` → `EnqueueTaskForMention` (a run for that agent) | `server/internal/handler/comment.go:1165` |
|
||||
| **`member` and `issue` mentions reach neither branch — they enqueue NOTHING.** A `member` mention fails the `!= "agent"` skip at line 1133 (the squad branch above it only matches `squad`); an `issue` mention does the same. | `server/internal/handler/comment.go:1089,1133` |
|
||||
|
||||
## Guards that make a valid mention a silent no-op
|
||||
|
||||
| Guard | Source |
|
||||
| --- | --- |
|
||||
| agent archived / no runtime → `continue` (`RuntimeID` invalid or `ArchivedAt` set) | `server/internal/handler/comment.go:1147` |
|
||||
| squad leader archived / no runtime → `continue` | `server/internal/handler/comment.go:1113` |
|
||||
| private agent the actor cannot access → `continue` (`canAccessPrivateAgent`) | `server/internal/handler/comment.go:1152` |
|
||||
| private squad leader the actor cannot trigger → `continue` (`canAccessPrivateAgent` via squad gate) | `server/internal/handler/comment.go:1117` |
|
||||
| already-pending dedup (agent) → `HasPendingTaskForIssueAndAgent` → `continue` | `server/internal/handler/comment.go:1156-1162` |
|
||||
| already-pending dedup (squad leader) → `continue` | `server/internal/handler/comment.go:1121-1127` |
|
||||
| `canAccessPrivateAgent` definition | `server/internal/handler/agent_access.go` (search `func (h *Handler) canAccessPrivateAgent`) |
|
||||
| `canEnqueueSquadLeader` (loads leader, delegates to `canAccessPrivateAgent`) | `server/internal/handler/agent_access.go:82-91` |
|
||||
|
||||
## @all broadcast and assignee-trigger suppression
|
||||
|
||||
| Fact | Source |
|
||||
| --- | --- |
|
||||
| `commentMentionsOthersButNotAssignee` — decides whether to suppress the assignee's on-comment trigger | `server/internal/handler/comment.go:953` |
|
||||
| `@all` is treated as a broadcast → returns true → assignee auto-trigger suppressed | `server/internal/handler/comment.go:967-969` |
|
||||
| Comment-flow call site that consults it | `server/internal/handler/comment.go:933` |
|
||||
| `@all` never enqueues a specific agent: it is neither `squad` nor `agent`, so it is skipped in the enqueue loop | `server/internal/handler/comment.go:1133` |
|
||||
|
||||
## CLI id sources (where the UUID comes from)
|
||||
|
||||
| List command | Field used as mention id | Source |
|
||||
| --- | --- | --- |
|
||||
| `workspace member list` | `user_id` (NOT the membership-row id) | `server/cmd/multica/cmd_workspace.go:465` |
|
||||
| `agent list` | `id` | `server/cmd/multica/cmd_agent.go:365` |
|
||||
| `squad list` | `id` | `server/cmd/multica/cmd_squad.go:57` |
|
||||
| Member mention uses `user_id`, confirmed by the backend roster formatter: `formatMention(user.Name, "member", userID)` where `userID = UUIDToString(m.MemberID)` | `server/internal/handler/squad_briefing.go:189-190` |
|
||||
| `formatMention` emits `[@<name>](mention://<type>/<id>)` | `server/internal/handler/squad_briefing.go:216-218` |
|
||||
|
||||
## Explicit non-claim: no member-notification path in the Go comment handler
|
||||
|
||||
The skill deliberately does **not** assert that a `member` mention "sends a
|
||||
notification." `server/internal/handler/comment.go` has no notification
|
||||
delivery path for member (or issue) mentions: `enqueueMentionedAgentTasks`
|
||||
branches only on `squad` and `agent`
|
||||
(`server/internal/handler/comment.go:1089,1133`), and a grep of the file for
|
||||
`notif` returns only an unrelated comment about avoiding "log spam" on
|
||||
unchanged threads — no member-notification call. The verified contract is
|
||||
narrow: a `member` or `issue` mention renders as a link and enqueues no agent
|
||||
run; only `agent` and `squad` mentions enqueue work. If a notification UX
|
||||
exists, it is not in this handler, so this skill makes no claim about it.
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: multica-projects-and-resources
|
||||
description: Use when creating, inspecting, updating, or debugging Multica projects and project resources. Covers durable project context, github_repo and local_directory resources, how resources affect future agent task context, when to bind repos, and when not to mutate resources.
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(multica *)
|
||||
---
|
||||
|
||||
# Multica Projects and Resources
|
||||
|
||||
## Quick start
|
||||
|
||||
Projects are durable context containers. Resources attached to a project can affect future agent tasks.
|
||||
|
||||
```bash
|
||||
multica project list --output json
|
||||
multica project get <project-id> --output json
|
||||
multica project resource list <project-id> --output json
|
||||
```
|
||||
|
||||
Project resources are mutated through project resource commands/endpoints. Issue
|
||||
comments do not create durable project resources.
|
||||
|
||||
## Core model
|
||||
|
||||
A project groups work and carries durable resources. A resource is not just display metadata; it is context later injected into task briefs and `.multica/project/resources.json`.
|
||||
|
||||
Common resource types:
|
||||
|
||||
- `github_repo` — durable GitHub repo context, with `resource_ref.url` and optional `default_branch_hint`;
|
||||
- `local_directory` — daemon-local path context, with `resource_ref.local_path`, `daemon_id`, and optional label.
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
multica project list --output json
|
||||
multica project get <project-id> --output json
|
||||
multica project create --title "<title>" --repo <github-url> --output json
|
||||
multica project update <project-id> --title "<title>" --output json
|
||||
multica project status <project-id> in_progress --output json
|
||||
multica project resource list <project-id> --output json
|
||||
multica project resource add <project-id> --type github_repo --url <github-url> --output json
|
||||
multica project resource add <project-id> --type local_directory --local-path <abs-path> --daemon-id <daemon-id> --output json
|
||||
multica project resource update <project-id> <resource-id> --url <new-github-url> --output json
|
||||
multica project resource remove <project-id> <resource-id> --output json
|
||||
```
|
||||
|
||||
Use `--ref '<json>'` only for resource types or payloads not covered by shortcuts.
|
||||
|
||||
## When to add a resource
|
||||
|
||||
Add/update a project resource when the user asks for durable project context: "把这个 GitHub repo 绑到项目上", "以后都用这个 repo", "agent 总是拿不到这个项目的仓库", or "这个项目要在我的本地目录里跑".
|
||||
|
||||
Project resources are durable and affect future tasks. `multica repo checkout`
|
||||
is task-local checkout state.
|
||||
|
||||
## Debugging wrong context
|
||||
|
||||
1. `multica project get <project-id> --output json`.
|
||||
2. `multica project resource list <project-id> --output json`.
|
||||
3. Check `github_repo.resource_ref.url`, `default_branch_hint`, and `local_directory.resource_ref.daemon_id`.
|
||||
4. Updating resources is a durable mutation. After an update, listing the
|
||||
resource is the verification path.
|
||||
5. If resources match the expected task context, inspect runtime/repo checkout
|
||||
path next.
|
||||
|
||||
## Side effects
|
||||
|
||||
Project create/update/delete/status and project resource add/update/remove mutate durable workspace state and affect future tasks. Ask before changing `local_directory` unless the user explicitly requested that exact local path.
|
||||
|
||||
More source-backed details: `references/projects-and-resources-source-map.md`.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Projects and resources source map
|
||||
|
||||
- `server/cmd/multica/cmd_project.go` registers project `list`, `get`, `create`, `update`, `delete`, and `status`.
|
||||
- The same file registers `project resource list/add/update/remove`.
|
||||
- `project create --repo` attaches `github_repo` resources during project creation.
|
||||
- `project resource add` supports shortcuts for `github_repo` (`--url`, `--default-branch-hint`) and `local_directory` (`--local-path`, `--daemon-id`, `--ref-label`), or generic `--ref '<json>'`.
|
||||
- `project resource update` merges shortcut edits with existing `resource_ref` so a partial edit does not clobber required fields.
|
||||
- `server/cmd/server/router.go` exposes `/api/projects` plus `/api/projects/{projectId}/resources` routes.
|
||||
- `server/pkg/db/queries/project_resource.sql` is the CRUD query surface for `project_resource` rows.
|
||||
- Project resources are written into `.multica/project/resources.json` for agent workdirs.
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: multica-runtimes-and-repos
|
||||
description: Use when inspecting or debugging Multica runtimes, daemon task claiming, agent not running, workdir/session reuse, or repository checkout. Covers runtime online/offline state, daemon heartbeat/claim chain, task-scoped repo checkout, project repo context, local_directory caveats, and safe diagnostic commands.
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(multica *)
|
||||
---
|
||||
|
||||
# Multica Runtimes and Repos
|
||||
|
||||
## Quick start
|
||||
|
||||
For "agent did not run" or "repo checkout failed", read the chain before changing anything:
|
||||
|
||||
```bash
|
||||
multica agent get <agent-id> --output json
|
||||
multica runtime list --output json
|
||||
multica repo checkout <repo-url>
|
||||
```
|
||||
|
||||
Runtime and repo commands affect active agent execution. Do not restart daemons, update runtimes, or check out arbitrary repos just to test.
|
||||
|
||||
## Core model
|
||||
|
||||
A runtime is the execution target behind an agent. A daemon owns local runtime processes and claims queued tasks from the server.
|
||||
|
||||
The chain is:
|
||||
|
||||
1. user action creates or updates an `agent_task_queue` row;
|
||||
2. the task points at an agent and runtime;
|
||||
3. server wakes the runtime over daemon websocket when possible;
|
||||
4. daemon polls/claims the task;
|
||||
5. server returns task context, repos, project resources, prior session/workdir hints, and task token;
|
||||
6. daemon prepares a workdir and launches the provider CLI;
|
||||
7. `multica repo checkout` talks to the local daemon, not directly to GitHub.
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
multica runtime list --output json
|
||||
multica runtime usage <runtime-id> --output json
|
||||
multica runtime activity <runtime-id> --output json
|
||||
multica runtime update <runtime-id> --target-version <version> --output json
|
||||
multica repo checkout <url>
|
||||
multica repo checkout <url> --ref <branch-or-sha>
|
||||
```
|
||||
|
||||
`runtime update` is a write. `repo checkout` creates a git worktree in the task working directory.
|
||||
|
||||
`repo checkout` requires `MULTICA_DAEMON_PORT`; it is intended to run inside a daemon task. If absent, you are not in the normal agent checkout path.
|
||||
|
||||
## Debugging an agent that did not run
|
||||
|
||||
Check in this order:
|
||||
|
||||
1. Was a task supposed to be created? Inspect issue/comment/autopilot context.
|
||||
2. Is the assignee an agent or squad? A squad routes to its leader.
|
||||
3. Is the agent archived or bound to a runtime the actor cannot use?
|
||||
4. Is the runtime online? `multica runtime list --output json`.
|
||||
5. Did the daemon heartbeat recently? Runtime `last_seen_at` is the visible clue.
|
||||
6. Did the task get claimed or is it stuck pending/running/waiting for local directory?
|
||||
7. If repo checkout failed, classify it after checking whether repo context was
|
||||
present in the task/project context.
|
||||
|
||||
## Repos
|
||||
|
||||
The runtime brief lists repos available to this task. Treat that list as the authority for agent checkout unless the user explicitly asks to bind a new project resource.
|
||||
|
||||
Workspace repos and project resources are not the same thing:
|
||||
|
||||
- workspace repo metadata can appear in workspace context;
|
||||
- `github_repo` project resources are durable project context and can affect future tasks;
|
||||
- `local_directory` resources point at a path owned by a daemon and carry local-machine assumptions.
|
||||
|
||||
Do not add a project resource just because `repo checkout` failed. First determine whether the user asked for durable project context or just a task checkout.
|
||||
|
||||
More source-backed details: `references/runtimes-and-repos-source-map.md`.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Runtimes and repos source map
|
||||
|
||||
- `server/cmd/multica/cmd_runtime.go` registers `runtime list`, `usage`, `activity`, and `update`.
|
||||
- `runtime list` reads `/api/runtimes` and prints `id`, `name`, `runtime_mode`, `provider`, `status`, and `last_seen_at`.
|
||||
- `runtime update` posts to `/api/runtimes/{runtime-id}/update`; with `--wait` it polls update status.
|
||||
- `server/cmd/multica/cmd_repo.go` registers `repo checkout <url> [--ref]`.
|
||||
- `repo checkout` requires `MULTICA_DAEMON_PORT`, sends `workspace_id`, `workdir`, `ref`, `agent_name`, and `task_id` to local daemon `/repo/checkout`, then prints the checked-out path.
|
||||
- `server/cmd/server/router.go` registers daemon APIs under `/api/daemon`, including workspace repos and task claim.
|
||||
- `server/internal/daemon/daemon.go` claims tasks, prepares workdirs, launches provider CLIs, and reports completion.
|
||||
- `server/internal/daemon/execenv/runtime_config.go` injects task/project/repo context into agent workdirs.
|
||||
@@ -0,0 +1,191 @@
|
||||
---
|
||||
name: multica-skill-importing
|
||||
description: Use when a user provides a skill URL, slug, or clear intent to import/install a specific skill into the current Multica workspace. Teaches the workspace import API/CLI path (POST /api/skills/import), the supported URL source families, the SkillWithFilesResponse shape returned on success, duplicate 409 handling with the existing_skill body, additive agent binding vs replace-all, and the reserved SKILL.md supporting-file rule. Do not use it to decide which skill the user needs, and never treat an external local installer like npx skills add as the final Multica install.
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(multica *)
|
||||
---
|
||||
|
||||
# Importing skills into Multica
|
||||
|
||||
Use this skill when the user already provided a skill URL, slug, or a clear intent
|
||||
to import a specific skill into the current Multica workspace.
|
||||
|
||||
Do not use this skill to decide which skill the user needs. If the user only
|
||||
describes a capability and no URL is known, external search may produce candidate
|
||||
URLs, but this import skill starts only once a URL or concrete import target is
|
||||
known.
|
||||
|
||||
Every claim below is traced to source in
|
||||
`references/skill-importing-source-map.md`. When in doubt, read that file.
|
||||
|
||||
## The invariant
|
||||
|
||||
A skill is installed for Multica only when it exists in the current workspace's
|
||||
skill database. The single supported path that puts it there is the workspace
|
||||
import endpoint, driven by this CLI:
|
||||
|
||||
```bash
|
||||
multica skill import --url <url> --output json
|
||||
```
|
||||
|
||||
That CLI sends:
|
||||
|
||||
```text
|
||||
POST /api/skills/import
|
||||
body: { "url": "<url>" }
|
||||
```
|
||||
|
||||
Do not finish with `npx skills add`. That installs into an external/local skill
|
||||
environment, not the Multica workspace DB, so Multica cannot manage or bind it.
|
||||
|
||||
## Supported URL source families
|
||||
|
||||
`detectImportSource` accepts these hosts (and `www.` variants). Pass any of these
|
||||
forms to `multica skill import --url <url> --output json`:
|
||||
|
||||
```bash
|
||||
multica skill import --url clawhub.ai/owner/skill --output json
|
||||
multica skill import --url skills.sh/owner/repo/skill --output json
|
||||
multica skill import --url github.com/owner/repo --output json
|
||||
multica skill import --url github.com/owner/repo/tree/main/path/to/skill --output json
|
||||
multica skill import --url github.com/owner/repo/blob/main/path/to/SKILL.md --output json
|
||||
```
|
||||
|
||||
- `clawhub.ai`, `skills.sh`, `github.com` are the recognized hosts.
|
||||
- A GitHub URL may be a bare `owner/repo`, a `/tree/{ref}/...` directory, or a
|
||||
`/blob/{ref}/.../SKILL.md` file.
|
||||
- A bare ClawHub slug (no host) is accepted and routed to ClawHub.
|
||||
- Any other host is rejected with a 400 naming the supported sources.
|
||||
|
||||
## Direct URL flow
|
||||
|
||||
1. When the request contains a concrete URL, the import endpoint can be called
|
||||
directly; search is not required by the API:
|
||||
|
||||
```bash
|
||||
multica skill import --url <url> --output json
|
||||
```
|
||||
|
||||
2. Treat a successful response as the source of truth. The body is a workspace
|
||||
`SkillWithFilesResponse` — it embeds the standard `SkillResponse` and adds the
|
||||
supporting `files` array. Report the relevant fields:
|
||||
|
||||
- `id`
|
||||
- `name`
|
||||
- `description`
|
||||
- `config.origin` (provenance: which source the skill was imported from — set
|
||||
only when the source supplied an origin, so treat it as possibly absent)
|
||||
- `files` / files count
|
||||
- `created_at` / `updated_at`
|
||||
|
||||
Because the response is structured, read these returned fields instead of guessing
|
||||
whether the import succeeded.
|
||||
|
||||
3. Agent-skill binding is a separate mutable operation. `add` preserves existing
|
||||
assignments and appends the new id:
|
||||
|
||||
```bash
|
||||
multica agent skills add <agent-id> --skill-ids <skill-id> --output json
|
||||
multica agent skills list <agent-id> --output json
|
||||
```
|
||||
|
||||
After the final `multica agent skills list <agent-id> --output json`, verify the
|
||||
target skill id is present before claiming the skill is available to that agent.
|
||||
|
||||
## Additive add vs replace-all set
|
||||
|
||||
`multica agent skills add` is additive: the server inserts the assignments without
|
||||
clearing existing ones (`AddAgentSkills`).
|
||||
|
||||
`multica agent skills set` is replace-all: the server clears every current
|
||||
assignment, then re-adds exactly the ids you pass (`SetAgentSkills`).
|
||||
`set` is the replacement path. Passing only one id to `set` leaves the agent with
|
||||
only that one skill and drops every previous assignment.
|
||||
|
||||
## Reserved SKILL.md supporting file
|
||||
|
||||
A skill's primary content is its `SKILL.md`. That filename is reserved: the daemon
|
||||
writes the primary content to `SKILL.md` itself when preparing the execution
|
||||
environment, so a *supporting* file may not also be named `SKILL.md`
|
||||
(`IsReservedContentPath`; the check cleans the path and is case-insensitive, so
|
||||
`./SKILL.md` and `sub/../SKILL.md` are caught too).
|
||||
|
||||
Practical effect when importing or creating a skill: if the manifest lists a
|
||||
supporting file named `SKILL.md`, the server silently drops it — the import still
|
||||
succeeds, but that entry will be absent from the returned `files`. So if a
|
||||
supporting file you expected is missing, check whether it was named `SKILL.md`;
|
||||
rename it to a non-reserved path. (The hard `400` rejection — "SKILL.md is reserved
|
||||
for the primary skill content" — only fires on the dedicated single-file endpoint
|
||||
`PUT /api/skills/{id}/files`, not on import.)
|
||||
|
||||
## Duplicate imports (409)
|
||||
|
||||
A duplicate import returns `409`. On current servers the body carries the existing
|
||||
workspace skill identity:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "a skill with this name already exists",
|
||||
"existing_skill": {
|
||||
"id": "<skill-id>",
|
||||
"name": "<skill-name>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`multica skill import --url <url> --output json` prints that structured conflict
|
||||
body and exits successfully (exit 0) for this duplicate case. Treat
|
||||
`existing_skill.id` and `existing_skill.name` as the source of truth, then fetch
|
||||
details if needed:
|
||||
|
||||
```bash
|
||||
multica skill get <skill-id> --output json
|
||||
```
|
||||
|
||||
Legacy fallback: a legacy server or old CLI may return a `409` whose body is only a
|
||||
string like `a skill with this name already exists`, with no `existing_skill` key.
|
||||
The CLI cannot recognize that as the duplicate case, so it exits non-zero. Recover
|
||||
by finding the existing workspace skill yourself:
|
||||
|
||||
```bash
|
||||
multica skill list --output json
|
||||
multica skill get <skill-id> --output json
|
||||
```
|
||||
|
||||
Then report that the skill already exists and include its `id` / `name`. Do not
|
||||
retry in a loop, and do not create a second skill under a different name just to
|
||||
dodge the conflict.
|
||||
|
||||
## Incorrect → correct
|
||||
|
||||
Incorrect (bypasses Multica):
|
||||
|
||||
```bash
|
||||
npx skills add https://skills.sh/owner/repo/skill
|
||||
```
|
||||
|
||||
The skill may exist locally, but Multica cannot manage it as a workspace skill.
|
||||
|
||||
Incorrect agent binding for a normal add (replaces every existing assignment):
|
||||
|
||||
Using `set` with only the new skill id wipes the agent's other skills. For an add,
|
||||
use `add`.
|
||||
|
||||
Correct import:
|
||||
|
||||
```bash
|
||||
multica skill import --url https://skills.sh/owner/repo/skill --output json
|
||||
```
|
||||
|
||||
Agent binding after import, when the caller intentionally wants to mutate that
|
||||
agent's skill assignments:
|
||||
|
||||
```bash
|
||||
multica agent skills add <agent-id> --skill-ids <skill-id> --output json
|
||||
multica agent skills list <agent-id> --output json
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- `references/skill-importing-source-map.md` — every behavior above mapped to
|
||||
`file:line` in `server/`, plus the verification command to re-derive the lines.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Skill-importing source map
|
||||
|
||||
Evidence layer for `multica-skill-importing`. Every behavioral claim in `SKILL.md`
|
||||
maps to a real code path below with `file:line`. Paths are relative to the repo
|
||||
root (`multica/`).
|
||||
|
||||
Re-derive before trusting: line numbers drift. To re-verify a single anchor,
|
||||
`grep` the symbol and read its surroundings, e.g.:
|
||||
|
||||
```bash
|
||||
grep -n "func (h \*Handler) ImportSkill" server/internal/handler/skill.go
|
||||
grep -n "func runSkillImport" server/cmd/multica/cmd_skill.go
|
||||
grep -n "func IsReservedContentPath" server/internal/skill/reserved.go
|
||||
```
|
||||
|
||||
## Import endpoint and route
|
||||
|
||||
| Behavior | File:line |
|
||||
|---|---|
|
||||
| `ImportSkill` handler (`POST /api/skills/import`) | `server/internal/handler/skill.go:1724` |
|
||||
| Decodes `ImportSkillRequest` (`{ "url": ... }`) | `server/internal/handler/skill.go:1737-1741`, struct at `:523` |
|
||||
| Detects source family + normalizes URL | `server/internal/handler/skill.go:1743` (calls `detectImportSource`) |
|
||||
| Persists provenance into `config.origin` | `server/internal/handler/skill.go:1776-1781` — set at `:1780` **only when** `imported.origin != nil` (`:1779`); otherwise `config` stays `{}` and `origin` is absent |
|
||||
| Builds skill + files via `createSkillWithFiles` (def `server/internal/handler/skill_create.go:72`, tx body `:27`) | call site `server/internal/handler/skill.go:1783` |
|
||||
| Success: `201 Created` with the response body | `server/internal/handler/skill.go:1806` |
|
||||
| Route registration `r.Post("/import", h.ImportSkill)` | `server/cmd/server/router.go:621` (under `/api/skills`, `:617`) |
|
||||
|
||||
## CLI: `multica skill import --url`
|
||||
|
||||
| Behavior | File:line |
|
||||
|---|---|
|
||||
| `skill import` command def | `server/cmd/multica/cmd_skill.go:60-64` |
|
||||
| `--url` flag | `server/cmd/multica/cmd_skill.go:143` |
|
||||
| `--output` flag (default `json`) | `server/cmd/multica/cmd_skill.go:144` |
|
||||
| `runSkillImport` | `server/cmd/multica/cmd_skill.go:412` |
|
||||
| Requires `--url` | `server/cmd/multica/cmd_skill.go:418-421` |
|
||||
| `POST /api/skills/import` | `server/cmd/multica/cmd_skill.go:431` |
|
||||
| On error, tries conflict handler; returns `nil` (exit 0) when handled | `server/cmd/multica/cmd_skill.go:432-434` |
|
||||
|
||||
## Duplicate 409 handling
|
||||
|
||||
| Behavior | File:line |
|
||||
|---|---|
|
||||
| `ImportSkill` unique-violation branch | `server/internal/handler/skill.go:1793-1800` |
|
||||
| Structured 409 via `writeSkillImportDuplicateConflict` (looks up existing skill) | `server/internal/handler/skill.go:1794-1795` |
|
||||
| `writeSkillImportDuplicateConflict` writes `{error, existing_skill}` at status 409 | `server/internal/handler/skill.go:109-114` |
|
||||
| `ExistingSkillIdentity` (`{id,name}`) | `server/internal/handler/skill.go:104-107` |
|
||||
| `existingSkillIdentityByName` (GetSkillByWorkspaceAndName) | `server/internal/handler/skill.go:130-142` |
|
||||
| Defensive fallback: plain-string 409 when lookup misses (delete-race) | `server/internal/handler/skill.go:1796-1798` |
|
||||
| CLI `handleSkillImportConflict` | `server/cmd/multica/cmd_skill.go:447` |
|
||||
| Requires HTTP 409 + non-empty body | `server/cmd/multica/cmd_skill.go:449-451` |
|
||||
| Requires `existing_skill` key (else `false` → non-zero exit) | `server/cmd/multica/cmd_skill.go:457-459` |
|
||||
| Under `--output json`: prints body, returns `true` (caller exits 0) | `server/cmd/multica/cmd_skill.go:461-465` |
|
||||
|
||||
A legacy plain-string 409 (no `existing_skill` key) fails the `:457-459` guard,
|
||||
so `handleSkillImportConflict` returns `false`, `runSkillImport` falls through to
|
||||
`return fmt.Errorf("import skill: ...")` at `:435` → non-zero exit.
|
||||
|
||||
## Response shape: `SkillWithFilesResponse`
|
||||
|
||||
| Behavior | File:line |
|
||||
|---|---|
|
||||
| `SkillWithFilesResponse` = embedded `SkillResponse` + `Files []SkillFileResponse` | `server/internal/handler/skill.go:99-102` |
|
||||
| `SkillResponse` fields (`id, workspace_id, name, description, content, config, created_by, created_at, updated_at`) | `server/internal/handler/skill.go:41-51` |
|
||||
| `SkillFileResponse` fields | `server/internal/handler/skill.go:80-87` |
|
||||
| `createSkillWithFilesInTx` returns `SkillWithFilesResponse{SkillResponse, Files}` | `server/internal/handler/skill_create.go:66-69` |
|
||||
| `config.origin` set on import | `server/internal/handler/skill.go:1780` |
|
||||
|
||||
The import response is a `SkillWithFilesResponse` (not a bare `SkillResponse`):
|
||||
it carries every `SkillResponse` field plus the `files` array.
|
||||
|
||||
## URL source families (`detectImportSource`)
|
||||
|
||||
| Behavior | File:line |
|
||||
|---|---|
|
||||
| `detectImportSource` | `server/internal/handler/skill.go:723-756` |
|
||||
| `skills.sh` / `www.skills.sh` | `server/internal/handler/skill.go:743-744` |
|
||||
| `clawhub.ai` / `www.clawhub.ai` | `server/internal/handler/skill.go:745-746` |
|
||||
| `github.com` / `www.github.com` | `server/internal/handler/skill.go:747-748` |
|
||||
| Bare slug (no host) defaults to ClawHub | `server/internal/handler/skill.go:750-753` |
|
||||
| `parseGitHubURL` handles `/tree/{ref}/...` and `/blob/{ref}/.../SKILL.md` | `server/internal/handler/skill.go:1402-1455` (tree/blob check `:1415-1432`) |
|
||||
|
||||
## Additive add vs replace-all set
|
||||
|
||||
| Behavior | File:line |
|
||||
|---|---|
|
||||
| `AddAgentSkills` (additive: AddAgentSkill loop, no RemoveAll) | `server/internal/handler/skill.go:1978`; loop `:2009-2017` |
|
||||
| Route `POST /api/agents/{id}/skills/add` | `server/cmd/server/router.go:598` |
|
||||
| `SetAgentSkills` (replace-all: RemoveAllAgentSkills then re-add) | `server/internal/handler/skill.go:1923`; `RemoveAllAgentSkills` `:1955`; re-add `:1960-1968` |
|
||||
| Route `PUT /api/agents/{id}/skills` | `server/cmd/server/router.go:597` |
|
||||
| CLI `agent skills add` def ("without replacing existing assignments") | `server/cmd/multica/cmd_agent.go:125-130` |
|
||||
| `runAgentSkillsAdd` → `POST .../skills/add` | `server/cmd/multica/cmd_agent.go:839`; POST `:860` |
|
||||
| CLI `agent skills set` def ("replaces all current assignments") | `server/cmd/multica/cmd_agent.go:118-123` |
|
||||
| `runAgentSkillsSet` → `PUT .../skills` | `server/cmd/multica/cmd_agent.go:814`; PUT `:832` |
|
||||
| CLI `agent skills list` | `server/cmd/multica/cmd_agent.go:782`; GET `:792` |
|
||||
|
||||
## Reserved primary-content filename (`SKILL.md`)
|
||||
|
||||
| Behavior | File:line |
|
||||
|---|---|
|
||||
| `ContentFilename = "SKILL.md"` | `server/internal/skill/reserved.go:12` |
|
||||
| `IsReservedContentPath` (cleans path, case-insensitive compare) | `server/internal/skill/reserved.go:25-27` |
|
||||
| Import/create path: reserved supporting file is **silently skipped** (`continue`) | `server/internal/handler/skill_create.go:50-54` |
|
||||
| `UpdateSkill` (PUT `/api/skills/{id}`) replace-files path: also silently skips | `server/internal/handler/skill.go:461-464` |
|
||||
| `UpsertSkillFile` (PUT `/api/skills/{id}/files`): **rejects 400** "SKILL.md is reserved for the primary skill content" | `server/internal/handler/skill.go:1851-1854` |
|
||||
|
||||
Reason `SKILL.md` is reserved: the daemon writes the skill's `Content` to that path
|
||||
itself when preparing the execution environment, so a supporting file may not also
|
||||
claim it (`server/internal/skill/reserved.go:8-24`).
|
||||
|
||||
Behavior is path-shape-dependent. On **import or create** a manifest's `SKILL.md`
|
||||
supporting file is dropped (it will not appear in the returned `files`), so the
|
||||
import still succeeds — it does not 400. The hard 400 rejection fires only on the
|
||||
dedicated single-file endpoint `PUT /api/skills/{id}/files`.
|
||||
250
server/internal/service/builtin_skills/multica-squads/SKILL.md
Normal file
250
server/internal/service/builtin_skills/multica-squads/SKILL.md
Normal file
@@ -0,0 +1,250 @@
|
||||
---
|
||||
name: multica-squads
|
||||
description: Use when creating, inspecting, updating, assigning, mentioning, or debugging Multica squads. Explains what squads are, squad/member fields, CLI commands, leader routing, issue assignment, comments, mentions, autopilot behavior, leader briefing, side effects, and product-gap handling.
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(multica *)
|
||||
---
|
||||
|
||||
# Multica Squads
|
||||
|
||||
## Quick start
|
||||
|
||||
If debugging why a squad did or did not run, inspect first:
|
||||
|
||||
```bash
|
||||
multica issue get <issue-id> --output json
|
||||
multica squad get <squad-id> --output json
|
||||
multica squad member list <squad-id> --output json
|
||||
multica issue comment list <issue-id> --recent 20 --output json
|
||||
```
|
||||
|
||||
If the command shape is unclear, check help instead of guessing:
|
||||
|
||||
```bash
|
||||
multica squad --help
|
||||
multica squad member --help
|
||||
multica issue update --help
|
||||
multica issue comment add --help
|
||||
```
|
||||
|
||||
Do not assign, comment, mention, update, delete, or record squad activity just
|
||||
to test. These can mutate workspace state or trigger agent runs.
|
||||
|
||||
## Core model
|
||||
|
||||
A Multica squad is a workspace routing and coordination object.
|
||||
|
||||
A squad is not an agent. It does not run work by itself. Current behavior:
|
||||
squad-routed work runs through the squad's `leader_id` agent.
|
||||
|
||||
Important consequences:
|
||||
|
||||
- assigning an issue to a squad routes to the leader;
|
||||
- mentioning a squad routes to the leader;
|
||||
- squad-assigned autopilot resolves to the leader;
|
||||
- squad members are not automatically fanned out;
|
||||
- squad `instructions` are leader briefing content, not member prompts.
|
||||
|
||||
## CLI
|
||||
|
||||
Squad commands:
|
||||
|
||||
```bash
|
||||
multica squad list --output json
|
||||
multica squad get <squad-id> --output json
|
||||
multica squad create --name <name> --leader <agent-name-or-id> --output json
|
||||
multica squad update <squad-id> --instructions "<leader coordination policy>" --output json
|
||||
multica squad delete <squad-id>
|
||||
```
|
||||
|
||||
Member commands:
|
||||
|
||||
```bash
|
||||
multica squad member list <squad-id> --output json
|
||||
multica squad member add <squad-id> --member-id <id> --type agent|member --role <role> --output json
|
||||
multica squad member remove <squad-id> --member-id <id> --type agent|member
|
||||
multica squad member set-role <squad-id> --member-id <id> --member-type agent|member --role <role> --output json
|
||||
```
|
||||
|
||||
Squad leader evaluation command:
|
||||
|
||||
```bash
|
||||
multica squad activity <issue-id> action|no_action|failed --reason "<why>" --output json
|
||||
```
|
||||
|
||||
`activity` is a write: it records the leader's evaluation decision on an issue.
|
||||
Use it only when acting as the squad leader after evaluating a trigger.
|
||||
|
||||
Issue/comment commands often needed with squads:
|
||||
|
||||
```bash
|
||||
multica issue get <issue-id> --output json
|
||||
multica issue update <issue-id> --help
|
||||
multica issue comment list <issue-id> --output json
|
||||
multica issue comment add <issue-id> --help
|
||||
```
|
||||
|
||||
Prefer `--output json` for reads. Use `--help` before writes.
|
||||
|
||||
## Squad fields
|
||||
|
||||
- `id` — squad UUID.
|
||||
- `workspace_id` — workspace the squad belongs to.
|
||||
- `name` — display name; unique per workspace.
|
||||
- `description` — human-facing metadata/display text. Do not assume runtime
|
||||
prompt impact unless source proves a consumer.
|
||||
- `instructions` — squad-level instructions added to the squad leader briefing.
|
||||
They are not directly injected into every squad member.
|
||||
- `avatar_url` — optional squad avatar URL.
|
||||
- `leader_id` — agent ID of the squad leader; the runtime target for
|
||||
squad-routed work.
|
||||
- `creator_id` — creator of the squad.
|
||||
- `archived_at` / `archived_by` — archive metadata. Archived squads are rejected
|
||||
by assignment/autopilot routing paths.
|
||||
- `member_count` — list response count of squad members.
|
||||
- `member_preview` — list response preview of squad members.
|
||||
|
||||
Use `instructions` for leader-facing coordination policy: squad responsibility,
|
||||
delegation expectations, when to ask humans, and review/handoff rules. Do not
|
||||
write it as if every member automatically receives it.
|
||||
|
||||
## Squad member fields
|
||||
|
||||
- `member_type` — `agent` or `member`.
|
||||
- `member_id` — ID of the agent or workspace member.
|
||||
- `role` — roster role label. Current behavior: non-empty `role` appears in the
|
||||
leader briefing roster. Do not assume it creates scheduling, permissions, or
|
||||
routing behavior.
|
||||
|
||||
## Creation and leader membership
|
||||
|
||||
Creating a squad requires `leader_id`. The leader must be a workspace agent.
|
||||
Create/update does not reject an archived leader: the lookup only checks the
|
||||
agent exists in the workspace. An archived leader fails closed later, at
|
||||
routing/dispatch — assignment, autopilot admission, and the comment/mention
|
||||
readiness gate all reject an archived leader before any task is enqueued.
|
||||
|
||||
On create, the backend attempts to add the leader as a squad member with role
|
||||
`leader`. When updating `leader_id`, if the new leader is not already a member,
|
||||
the backend adds the new leader as a squad member with role `leader`.
|
||||
|
||||
## Leader briefing
|
||||
|
||||
For squad leader tasks, Multica appends a squad leader briefing to the leader
|
||||
agent instructions. The briefing includes:
|
||||
|
||||
- Squad Operating Protocol;
|
||||
- Squad Roster;
|
||||
- Squad Instructions, only when `instructions` is non-empty.
|
||||
|
||||
Roster entries include member name, member type, mention markdown, and non-empty
|
||||
role. Archived agent members are skipped from the briefing roster.
|
||||
|
||||
## Issue assignment behavior
|
||||
|
||||
Issues can be assigned to squads with:
|
||||
|
||||
```text
|
||||
assignee_type = "squad"
|
||||
assignee_id = <squad-id>
|
||||
```
|
||||
|
||||
Current behavior:
|
||||
|
||||
- assignment routes work to `squad.leader_id`;
|
||||
- it does not enqueue every squad member;
|
||||
- assignment while status is `backlog` does not immediately start work;
|
||||
- moving a squad-assigned issue out of `backlog` can trigger the leader;
|
||||
- changing assignee cancels existing tasks for the issue before enqueueing the
|
||||
new assignee path.
|
||||
|
||||
Assignment validation rejects a missing type/id pair, non-existent squad,
|
||||
archived squad, archived leader, and private leader when the actor cannot access
|
||||
it.
|
||||
|
||||
## Comment and mention behavior
|
||||
|
||||
If an issue is assigned to a squad, a new comment can wake the squad leader. This
|
||||
is leader routing, not member fan-out.
|
||||
|
||||
Squad mention format:
|
||||
|
||||
```md
|
||||
[@Squad Name](mention://squad/<squad-id>)
|
||||
```
|
||||
|
||||
Current behavior: resolve the squad, read `leader_id`, enqueue a leader task,
|
||||
and use the current comment as the trigger comment. It does not enqueue every
|
||||
squad member.
|
||||
|
||||
## Autopilot behavior
|
||||
|
||||
Autopilots can be assigned to squads. For `assignee_type = "squad"`:
|
||||
|
||||
- executable agent resolves from `squad.leader_id`;
|
||||
- admission/readiness checks run against the leader;
|
||||
- archived squads fail closed / skip dispatch;
|
||||
- run attribution records squad id where applicable.
|
||||
|
||||
For `create_issue` autopilots, the created issue keeps `assignee_type = "squad"`
|
||||
and `assignee_id = <squad-id>`, while the actual executing agent is the resolved
|
||||
leader. For `run_only` autopilots, no issue is created; the task is created
|
||||
directly for the resolved leader agent.
|
||||
|
||||
## Handling complaints or product gaps
|
||||
|
||||
When the user says squad behavior is wrong, confusing, or disappointing, do not
|
||||
immediately assume code is broken and do not defend current behavior just because
|
||||
it exists. Classify first:
|
||||
|
||||
- expected current behavior;
|
||||
- configuration issue;
|
||||
- product limitation;
|
||||
- actual bug.
|
||||
|
||||
Explain the current source-backed behavior. If the behavior is technically
|
||||
correct but product-wise bad, say so and propose a scoped product/code change.
|
||||
|
||||
Do not silently change squad routing, member fan-out, leader briefing, autopilot
|
||||
behavior, or comment-trigger behavior without confirmation. These are product
|
||||
contract changes with side effects.
|
||||
|
||||
## Side effects
|
||||
|
||||
These actions can trigger agent work or mutate durable state:
|
||||
|
||||
- creating a squad;
|
||||
- updating squad fields;
|
||||
- changing `leader_id`;
|
||||
- adding/removing members;
|
||||
- changing member roles;
|
||||
- assigning an issue to a squad;
|
||||
- moving a squad-assigned issue out of backlog;
|
||||
- commenting on a squad-assigned issue;
|
||||
- mentioning a squad;
|
||||
- creating or triggering squad-assigned autopilots;
|
||||
- recording squad activity with `multica squad activity`;
|
||||
- deleting/archive squad.
|
||||
|
||||
Do not perform side-effecting actions as tests unless the user explicitly
|
||||
authorizes them.
|
||||
|
||||
## Common wrong assumptions
|
||||
|
||||
- A squad is not an agent.
|
||||
- Squad work routes to `leader_id`, not every member.
|
||||
- Squad mention routes to the leader, not every member.
|
||||
- Squad assignment routes to the leader, not every member.
|
||||
- Squad autopilot resolves to the leader as executable agent.
|
||||
- `instructions` are leader briefing content, not automatic member prompts.
|
||||
- `description` is not proven runtime prompt content.
|
||||
- `role` is roster context, not automatic scheduling.
|
||||
- Backlog assignment does not immediately start work.
|
||||
|
||||
## References
|
||||
|
||||
For source paths, tests, edge cases, and exact routing details, see:
|
||||
|
||||
```text
|
||||
references/squad-source-map.md
|
||||
```
|
||||
@@ -0,0 +1,222 @@
|
||||
# Squad Source Map
|
||||
|
||||
This file records source evidence for `multica-squads/SKILL.md`.
|
||||
|
||||
Use this when the task requires exact source paths, edge-case behavior, tests, or contract verification.
|
||||
|
||||
## Object Model
|
||||
|
||||
### DB shape
|
||||
|
||||
Source:
|
||||
|
||||
```text
|
||||
server/migrations/084_squad.up.sql # base table: name, description, leader_id, creator_id
|
||||
server/migrations/085_squad_archive.up.sql # archived_at, archived_by columns
|
||||
server/migrations/088_squad_instructions.up.sql # instructions column
|
||||
server/pkg/db/queries/squad.sql
|
||||
packages/core/types/squad.ts
|
||||
```
|
||||
|
||||
Key facts:
|
||||
|
||||
- `squad` stores `name`, `description`, `leader_id`, `creator_id` (084), archive
|
||||
metadata `archived_at`/`archived_by` (085), and `instructions` (088).
|
||||
- `squad_member` stores `member_type`, `member_id`, and `role`.
|
||||
- `member_type` is constrained to `agent` or `member`.
|
||||
- issue `assignee_type` supports `squad`.
|
||||
|
||||
## CLI
|
||||
|
||||
Source:
|
||||
|
||||
```text
|
||||
server/cmd/multica/cmd_squad.go
|
||||
```
|
||||
|
||||
Commands:
|
||||
|
||||
```bash
|
||||
multica squad list
|
||||
multica squad get <squad-id>
|
||||
multica squad create
|
||||
multica squad update <squad-id>
|
||||
multica squad delete <squad-id>
|
||||
multica squad activity <issue-id> <outcome>
|
||||
|
||||
multica squad member list <squad-id>
|
||||
multica squad member add <squad-id>
|
||||
multica squad member remove <squad-id>
|
||||
multica squad member set-role <squad-id>
|
||||
```
|
||||
|
||||
Use `--help` for exact flags before writes.
|
||||
|
||||
## Create / Update
|
||||
|
||||
Source:
|
||||
|
||||
```text
|
||||
server/internal/handler/squad.go # CreateSquad ~200-272, UpdateSquad ~287-364
|
||||
server/pkg/db/queries/agent.sql # GetAgentInWorkspace ~15-17
|
||||
server/pkg/db/generated/agent.sql.go # getAgentInWorkspace ~1261
|
||||
```
|
||||
|
||||
Contracts:
|
||||
|
||||
- create requires `leader_id` (squad.go:215-218);
|
||||
- leader must be a workspace agent — both create (squad.go:230-237) and update
|
||||
(squad.go:333-338) validate via `GetAgentInWorkspace`;
|
||||
- archived leader is NOT rejected at create/update: `GetAgentInWorkspace` is
|
||||
`WHERE id = $1 AND workspace_id = $2` (agent.sql:15-17) with no archived
|
||||
filter, so an archived agent can be set as leader here. Archived-leader fails
|
||||
closed later, at routing/dispatch — see the readiness gate (squad.go:945,
|
||||
isSquadLeaderReady → service.AgentReadiness at squad.go:1017), assignment
|
||||
validation (issue.go:2625-2627), and autopilot admission (autopilot.go:885-891);
|
||||
- leader is auto-added as member with role `leader` (squad.go:258-263);
|
||||
- updating `leader_id` auto-adds new leader as member if missing (squad.go:340-347).
|
||||
|
||||
## Leader Briefing
|
||||
|
||||
Source:
|
||||
|
||||
```text
|
||||
server/internal/handler/squad_briefing.go # buildSquadLeaderBriefing ~104, buildSquadRoster ~121, renderMemberRow ~169
|
||||
server/internal/handler/daemon.go # briefing injection ~1187, ~1530
|
||||
```
|
||||
|
||||
Contracts:
|
||||
|
||||
- squad leader tasks append briefing to leader agent instructions
|
||||
(daemon.go:1187, 1530);
|
||||
- briefing includes operating protocol, roster, and optional instructions
|
||||
(squad_briefing.go:104-117);
|
||||
- `instructions` section appears only when non-empty (squad_briefing.go:110-112);
|
||||
- archived agent members are skipped from roster (squad_briefing.go:178-179);
|
||||
- no traced behavior injects `instructions` into every squad member.
|
||||
|
||||
## Issue Assignment
|
||||
|
||||
Source:
|
||||
|
||||
```text
|
||||
server/internal/handler/issue.go # assignee validation ~2614-2632
|
||||
server/internal/handler/squad.go # shouldEnqueueSquadLeaderOnAssign ~990, enqueueSquadLeaderTask ~1027
|
||||
server/internal/service/task.go
|
||||
```
|
||||
|
||||
Contracts:
|
||||
|
||||
- `assignee_type="squad"` routes to `squad.leader_id` (squad.go:1028-1050);
|
||||
- backlog assignment does not immediately enqueue (squad.go:991-993);
|
||||
- moving out of backlog can enqueue leader (squad.go:990-994 → isSquadLeaderReady);
|
||||
- assignee change cancels existing issue tasks first;
|
||||
- private leader access is checked at assign-time (issue.go:2629-2632) and at
|
||||
enqueue-time via `canEnqueueSquadLeader` (squad.go:1037);
|
||||
- archived squad / archived leader rejected at assign-time (issue.go:2622-2627);
|
||||
- pending task dedup is applied (squad.go:1042-1048).
|
||||
|
||||
## Comment / Mention
|
||||
|
||||
Source:
|
||||
|
||||
```text
|
||||
server/internal/handler/comment.go # comment-trigger ~940-941, squad mention ~1089
|
||||
server/internal/handler/squad.go # shouldEnqueueSquadLeaderOnComment ~909, enqueueSquadLeaderTask ~1027
|
||||
server/internal/service/task.go # EnqueueTaskForSquadLeader
|
||||
```
|
||||
|
||||
Contracts:
|
||||
|
||||
- commenting on a squad-assigned issue can wake the leader
|
||||
(comment.go:940-941 → shouldEnqueueSquadLeaderOnComment at squad.go:909);
|
||||
- explicit `mention://squad/<id>` resolves squad and enqueues leader
|
||||
(comment.go:1089);
|
||||
- squad mention does not fan out to members — enqueue targets `squad.LeaderID`
|
||||
only (squad.go:1050);
|
||||
- leader task uses `is_leader_task=true` (via `EnqueueTaskForSquadLeader`);
|
||||
- leader self-trigger loops are guarded — same-leader / last-task-was-leader
|
||||
guards (squad.go:929-932, lastTaskWasLeader at squad.go:959) and member
|
||||
explicit-mention skip (squad.go:939-941).
|
||||
|
||||
## Autopilot
|
||||
|
||||
Source:
|
||||
|
||||
```text
|
||||
server/internal/service/autopilot.go # resolveAutopilotLeader ~617-655, dispatch ~88-111
|
||||
server/internal/handler/autopilot.go # save-time validateAutopilotAssignee ~845-893
|
||||
```
|
||||
|
||||
Contracts:
|
||||
|
||||
- squad autopilot resolves executable agent from `squad.leader_id` —
|
||||
`resolveAutopilotLeader` squad branch (autopilot.go:639-651);
|
||||
- readiness/admission checks target the leader: save-time validation rejects an
|
||||
archived squad/leader (handler/autopilot.go:881-891), and dispatch re-runs
|
||||
`resolveAutopilotLeader` + `AgentReadiness`;
|
||||
- archived squad fails closed / skips dispatch — `errSquadArchived`
|
||||
(autopilot.go:644-645);
|
||||
- `create_issue` keeps the issue assigned to the squad (autopilot.go:88-97);
|
||||
- `run_only` creates task directly for leader (autopilot.go:99-106, dispatch via
|
||||
`resolveAutopilotLeader` at autopilot.go:284).
|
||||
|
||||
## Child-done Parent Trigger
|
||||
|
||||
Source:
|
||||
|
||||
```text
|
||||
server/internal/handler/issue_child_done.go # dispatchParentAssigneeTrigger ~246, triggerChildDoneSquad ~304
|
||||
```
|
||||
|
||||
Contracts:
|
||||
|
||||
- when child issue completes and parent is assigned to squad, parent squad
|
||||
leader can be triggered (triggerChildDoneSquad at issue_child_done.go:304);
|
||||
- routing is leader-only — one `EnqueueTaskForSquadLeader` on the leader, no
|
||||
member fan-out (issue_child_done.go:214-216, 344);
|
||||
- loop guards skip same squad, same effective leader, and shared-leader
|
||||
cross-squad cases (issue_child_done.go:229-235, effectiveChildAgentOwner ~367,
|
||||
childAssigneeIsSquad ~387).
|
||||
|
||||
## Private Leader Access
|
||||
|
||||
Source:
|
||||
|
||||
```text
|
||||
server/internal/handler/agent_access.go # canAccessPrivateAgent ~25-40, canEnqueueSquadLeader ~82-91
|
||||
server/internal/handler/squad.go # enqueueSquadLeaderTask gate ~1037
|
||||
```
|
||||
|
||||
Contracts:
|
||||
|
||||
- public leaders pass — `canAccessPrivateAgent` returns true when
|
||||
`agent.Visibility != "private"` (agent_access.go:26-28);
|
||||
- agent-to-agent traffic is allowed — `actorType == "agent"` short-circuits
|
||||
(agent_access.go:29-31);
|
||||
- private leader access for members is limited to owner/admin or agent owner
|
||||
(agent_access.go:32-39);
|
||||
- system triggers are treated like agent triggers for squad leader enqueue:
|
||||
`canEnqueueSquadLeader` remaps `actorType == "system"` to `"agent"` before
|
||||
delegating to `canAccessPrivateAgent` (agent_access.go:87-90). This is wired
|
||||
into `enqueueSquadLeaderTask`, which denies the enqueue when the actor cannot
|
||||
access the leader (squad.go:1037).
|
||||
|
||||
## Tests
|
||||
|
||||
Relevant test groups:
|
||||
|
||||
```text
|
||||
server/internal/handler/squad_assign_trigger_test.go
|
||||
server/internal/handler/squad_comment_trigger_test.go
|
||||
server/internal/handler/squad_briefing_test.go
|
||||
server/internal/handler/squad_private_leader_test.go
|
||||
server/internal/handler/autopilot_private_leader_test.go
|
||||
server/internal/handler/squad_no_action_test.go
|
||||
```
|
||||
|
||||
Verification command:
|
||||
|
||||
```bash
|
||||
go test ./internal/handler -run 'Test.*Squad|Test.*squad|Test.*Autopilot.*Squad|Test.*ChildDone.*Squad'
|
||||
```
|
||||
@@ -0,0 +1,172 @@
|
||||
---
|
||||
name: multica-working-on-issues
|
||||
description: Use when working on a Multica issue after the runtime has provided the trigger context — to apply the product contracts the runtime brief does not encode: how PR linking differs from close intent, how to read a linked PR's real state via the pull-requests CLI, which metadata keys are high-signal, what status changes trigger on the server, and how sub-issue create status (todo vs backlog) controls whether assigned agents start immediately.
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(multica *), Bash(git *), Bash(gh *)
|
||||
---
|
||||
|
||||
# Working on Multica issues
|
||||
|
||||
Product contracts the runtime brief does not fully encode: PR linking vs close
|
||||
intent, reading linked-PR state, metadata keys, status side effects, and
|
||||
sub-issue enqueue behavior.
|
||||
|
||||
For building mention links, load `multica-mentioning` instead — not this skill.
|
||||
|
||||
Every contract below is traced to source in
|
||||
`references/working-on-issues-source-map.md`.
|
||||
|
||||
## PR linking and close intent are two distinct contracts
|
||||
|
||||
The GitHub webhook runs two separate scans over an incoming PR. They are not the
|
||||
same gate and they read different fields.
|
||||
|
||||
**Linking** scans the PR **title, body, OR branch** for a routable issue key
|
||||
(`PREFIX-NUMBER`, e.g. `MUL-2759`). Each match writes an issue ↔ PR link row.
|
||||
This is the link that `multica issue pull-requests` reads back.
|
||||
|
||||
```text
|
||||
MUL-2759: add built-in issue working skill # title prefix → links
|
||||
agent/matt/mul-2759-working-on-issues # branch ref → links
|
||||
```
|
||||
|
||||
**Close intent** is stricter and is a separate scan over **title or body only —
|
||||
never the branch**. It fires only for a key placed immediately after a closing
|
||||
keyword (`Closes` / `Fixes` / `Resolves`, optional `:` then whitespace). That
|
||||
adjacency is what sets the link row's close-intent flag, the gate that
|
||||
auto-advances the issue to `done` when the PR merges.
|
||||
|
||||
```text
|
||||
Closes MUL-2759 # links AND records close intent
|
||||
Fixes MUL-2759
|
||||
Resolves MUL-2759
|
||||
Fix login MUL-2759 # links only — keyword not adjacent
|
||||
```
|
||||
|
||||
Consequence: a bare title prefix or a branch reference links the PR but does not
|
||||
close the issue on merge. A closing keyword immediately adjacent to the issue key
|
||||
records close intent; on merge, that close intent can move the linked issue to
|
||||
`done`.
|
||||
|
||||
## Reading a linked PR's real state
|
||||
|
||||
When a step depends on PR state, query Multica's link table — do not infer it
|
||||
from branch names, GitHub search, memory, or `pr_url` metadata (which can be
|
||||
stale).
|
||||
|
||||
```bash
|
||||
multica issue pull-requests <issue-id> --output json
|
||||
```
|
||||
|
||||
Returns `{"pull_requests": [...]}`. Each element exposes:
|
||||
|
||||
- `number`, `html_url`, `title`
|
||||
- `state` — the PR lifecycle as a **single enum**, one of `merged`, `closed`,
|
||||
`draft`, `open`. There is no separate `draft` or `merged` boolean in the
|
||||
response; the server folds them into `state` (merged wins, then closed, then
|
||||
draft, else open).
|
||||
- `merged_at` — non-null once merged; a second confirmation of `state: merged`.
|
||||
- `mergeable_state` — mirrors GitHub (`clean` / `dirty` surfaced; other values
|
||||
round-trip as unknown).
|
||||
- `checks_conclusion` — aggregated CI: `passed`, `failed`, `pending`, or `null`
|
||||
when no check suite has been observed. Backed by `checks_passed`,
|
||||
`checks_failed`, `checks_pending` counts.
|
||||
|
||||
So "is it merged?" is `state == "merged"` (or `merged_at != null`); "is it still
|
||||
a draft?" is `state == "draft"`; CI status is `checks_conclusion`.
|
||||
|
||||
If the command returns no linked PRs after a PR was opened, the link scanner did
|
||||
not observe a routable issue key in the PR title/body/branch.
|
||||
|
||||
## Metadata: high-signal keys only
|
||||
|
||||
Metadata is durable issue state. Reading metadata is safe. Writing a metadata key
|
||||
is a state mutation and should be tied to an explicit task requirement to record
|
||||
that state for later readers or runs.
|
||||
|
||||
High-signal keys (reuse these names so queries stay consistent):
|
||||
|
||||
- `pr_url`
|
||||
- `pr_number`
|
||||
- `pipeline_status`
|
||||
- `deploy_url`
|
||||
- `external_issue_url`
|
||||
- `waiting_on`
|
||||
- `blocked_reason`
|
||||
- `decision`
|
||||
|
||||
Not metadata: logs, summaries, files touched, timestamps, attempt counts,
|
||||
investigation notes. Those belong in the result comment.
|
||||
|
||||
```bash
|
||||
multica issue metadata set <issue-id> --key pr_url --value <url>
|
||||
multica issue metadata delete <issue-id> --key <stale-key>
|
||||
```
|
||||
|
||||
`--value` is JSON-parsed by default (bool/number are sniffed); pass `--type
|
||||
string|number|bool` to force a type.
|
||||
|
||||
## Status changes have server side effects
|
||||
|
||||
A status change is not cosmetic — the server enqueues or skips agent work based
|
||||
on it. These are the contracts, not advice:
|
||||
|
||||
- **`backlog`** parks an agent-assigned issue: the assignee is set but no task
|
||||
fires. Moving `backlog → todo` (or any non-done/non-cancelled status) enqueues
|
||||
the assigned agent then.
|
||||
- **`in_review`** is an accepted issue status. Some workflows use it while a PR
|
||||
is open and awaiting review; moving to it is an explicit mutation.
|
||||
- **`done`** on a child issue posts a system comment on its parent. If a PR
|
||||
carries close intent (`Closes MUL-XXXX`), it advances the issue to `done`
|
||||
itself on merge — you do not also need to flip it manually.
|
||||
- **`cancelled`** stops outstanding work; treat it as a user-driven decision.
|
||||
|
||||
## Sub-issues: `todo` starts work now, `backlog` parks it
|
||||
|
||||
On an agent-assigned issue, create status decides whether the assignee fires
|
||||
immediately. A non-backlog status (e.g. `todo`) enqueues the agent at create
|
||||
time; `backlog` sets the assignee without triggering.
|
||||
|
||||
Parallel children — all start now:
|
||||
|
||||
```bash
|
||||
multica issue create --title "..." --parent <issue-id> --assignee <agent> --status todo
|
||||
```
|
||||
|
||||
Strictly serial children — park later steps, promote one at a time:
|
||||
|
||||
```bash
|
||||
multica issue create --title "Step 2: ..." --parent <issue-id> --assignee <agent> --status backlog
|
||||
multica issue status <child-id> todo # promote when the previous step is truly done
|
||||
```
|
||||
|
||||
Creating every serial step as `todo` enqueues the whole chain at once.
|
||||
|
||||
## Incorrect → correct
|
||||
|
||||
PR title (link the issue):
|
||||
|
||||
```text
|
||||
Fix login redirect # incorrect — no issue key, won't link
|
||||
MUL-2759: fix login redirect # correct — links the PR
|
||||
```
|
||||
|
||||
Serial sub-issues (don't start the whole chain):
|
||||
|
||||
```bash
|
||||
# incorrect — both fire immediately
|
||||
multica issue create --title "Step 2" --parent <issue-id> --assignee <agent> --status todo
|
||||
multica issue create --title "Step 3" --parent <issue-id> --assignee <agent> --status todo
|
||||
|
||||
# correct — parked, promote in turn
|
||||
multica issue create --title "Step 2" --parent <issue-id> --assignee <agent> --status backlog
|
||||
multica issue create --title "Step 3" --parent <issue-id> --assignee <agent> --status backlog
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
`references/working-on-issues-source-map.md` — accurate `file:line` for every
|
||||
contract above: the `pull-requests` CLI and route, the PR response field list,
|
||||
`derivePRState`, the two-path link (`extractIdentifiers`) vs close-intent
|
||||
(`extractClosingIdentifiers`) proof, the backlog enqueue lines, child-done
|
||||
notify, and the metadata CLI. Re-derive before depending on an exact line.
|
||||
@@ -0,0 +1,137 @@
|
||||
# working-on-issues source map
|
||||
|
||||
Evidence layer for `SKILL.md`. Every contract the skill states is traced to a
|
||||
current `file:line` here. Lines were re-derived against `feat/builtin-skills`
|
||||
after the latest `main` merge; the prior skill cited pre-merge lines that have
|
||||
since moved (see the "drifted" column). Re-confirm with the verification command
|
||||
at the bottom before relying on an exact line.
|
||||
|
||||
## `multica issue pull-requests` — read PR links from Multica
|
||||
|
||||
| Behavior | File:line | Drifted from |
|
||||
|---|---|---|
|
||||
| CLI command `pull-requests <id>` (alias `prs`) | `server/cmd/multica/cmd_issue.go:105` | `:104` |
|
||||
| `runIssuePullRequests` handler | `server/cmd/multica/cmd_issue.go:507` | new citation |
|
||||
| Calls `GET /api/issues/<id>/pull-requests` | `server/cmd/multica/cmd_issue.go:522` | `:522` (unchanged) |
|
||||
| API route registration | `server/cmd/server/router.go:480` | `:480` (unchanged) |
|
||||
| Handler `ListPullRequestsForIssue` → `Queries.ListPullRequestsByIssue` | `server/internal/handler/github.go:466,471` | `:466` (unchanged) |
|
||||
| Row → response mapper `issuePullRequestRowToResponse` | `server/internal/handler/github.go:149` | new citation |
|
||||
|
||||
The CLI resolves the issue ref, GETs the endpoint, and (for `--output json`)
|
||||
prints the raw `{"pull_requests": [...]}` body. Only `--output` is accepted; the
|
||||
default `table` shows `NUMBER STATE TITLE URL`.
|
||||
|
||||
## PR response shape
|
||||
|
||||
`GitHubPullRequestResponse` struct: `server/internal/handler/github.go:51`. JSON
|
||||
fields the agent can read off each element of `pull_requests`:
|
||||
|
||||
- `number` (`json:"number"`, line 56)
|
||||
- `html_url` (`json:"html_url"`, line 59)
|
||||
- `title` (`json:"title"`, line 57)
|
||||
- `state` (`json:"state"`, line 58) — the folded lifecycle enum (see below)
|
||||
- `merged_at` (`json:"merged_at"`, line 63), `closed_at` (line 64)
|
||||
- `mergeable_state` (`json:"mergeable_state"`, line 70) — mirrors GitHub; UI only
|
||||
surfaces `clean`/`dirty`, other values round-trip as unknown
|
||||
- `checks_conclusion` (`json:"checks_conclusion"`, line 74) — aggregated
|
||||
`"passed"`/`"failed"`/`"pending"` or `null` (no observed suite)
|
||||
- `checks_passed` / `checks_failed` / `checks_pending` (lines 78-80) — per-suite
|
||||
counts; `aggregateChecksConclusion` (line 183) folds them into
|
||||
`checks_conclusion`
|
||||
|
||||
There is **no** standalone `draft` or `merged` boolean in the response. The
|
||||
PR lifecycle is encoded in the single `state` string by `derivePRState`
|
||||
(`server/internal/handler/github.go:994`):
|
||||
|
||||
```
|
||||
merged → if PullRequest.Merged
|
||||
closed → else if PullRequest.State == "closed"
|
||||
draft → else if PullRequest.Draft
|
||||
open → otherwise
|
||||
```
|
||||
|
||||
`derivePRState` is called when the webhook upserts the row
|
||||
(`server/internal/handler/github.go:682`), so `state` is what the list endpoint
|
||||
returns. "Is it merged?" = `state == "merged"` (or `merged_at != null`); "is it a
|
||||
draft?" = `state == "draft"`. Combine with `checks_conclusion` for CI status.
|
||||
|
||||
## Two distinct webhook paths: link vs close-intent
|
||||
|
||||
Both run inside the `pull_request` webhook handler, gated by the workspace
|
||||
auto-link flag (`workspaceAutoLinkPRsEnabled`, `github.go:1074`).
|
||||
|
||||
### Path 1 — link (title OR body OR branch)
|
||||
|
||||
- `extractIdentifiers` regex helper: `server/internal/handler/github.go:1028`
|
||||
- driving regex `identifierRe` (`\b([a-z][a-z0-9]{1,9})-(\d+)\b`, case-insensitive):
|
||||
`server/internal/handler/github.go:490`
|
||||
- call site: `server/internal/handler/github.go:727` —
|
||||
`extractIdentifiers(p.PullRequest.Title, p.PullRequest.Body, p.PullRequest.Head.Ref)`
|
||||
|
||||
Every `PREFIX-NUMBER` mention in **title, body, or branch** resolves to an issue
|
||||
in the workspace and writes a link row (`LinkIssueToPullRequest`, ~`github.go:762`).
|
||||
This is what `multica issue pull-requests` later reads back.
|
||||
|
||||
Drifted from the prior skill's `github.go:727` citation, which pointed at the old
|
||||
call-site location for the link logic.
|
||||
|
||||
### Path 2 — close intent (title OR body only, keyword-adjacent)
|
||||
|
||||
- `extractClosingIdentifiers` regex helper: `server/internal/handler/github.go:1051`
|
||||
- driving regex `closingIdentifierRe`
|
||||
(`\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)[:\s]+([a-z][a-z0-9]{1,9})-(\d+)\b`):
|
||||
`server/internal/handler/github.go:501`
|
||||
- call site: `server/internal/handler/github.go:736` —
|
||||
`extractClosingIdentifiers(p.PullRequest.Title, p.PullRequest.Body)` (no branch arg)
|
||||
|
||||
Only a `PREFIX-NUMBER` immediately after a closing keyword
|
||||
(`Closes`/`Fixes`/`Resolves`, optional `:` then whitespace) sets the link row's
|
||||
`close_intent` flag — the gate that auto-advances the issue to `done` on merge.
|
||||
`Fix MUL-1` closes; `Fix login MUL-1` does not (adjacency). Branch names are
|
||||
deliberately excluded (function doc, `github.go:1044-1050`): a branch like
|
||||
`mul-1/fix-login` links but must never declare close intent.
|
||||
|
||||
Drifted from the prior skill's `github.go:736` citation.
|
||||
|
||||
Net: a bare title prefix (`MUL-2759: ...`) or a branch ref links only;
|
||||
`Closes MUL-2759` links **and** records close intent.
|
||||
|
||||
## Status side effects (enqueue contracts)
|
||||
|
||||
| Behavior | File:line | Drifted from |
|
||||
|---|---|---|
|
||||
| Create-time: agent-assigned, non-backlog issue enqueues immediately | `server/internal/handler/issue.go:2263-2264` | new citation |
|
||||
| `shouldEnqueueAgentTask` returns false for `backlog` (parking lot) | `server/internal/handler/issue.go:2644-2648` | new citation |
|
||||
| Backlog → non-backlog (not done/cancelled) enqueues on update | `server/internal/handler/issue.go:2537-2540` | `:2523` |
|
||||
| Same contract in batch update | `server/internal/handler/issue.go:3021-3024` | new citation |
|
||||
| Child → `done` posts a system comment on the parent | `server/internal/handler/issue_child_done.go:51` (`notifyParentOfChildDone`; doc comment at `:15`) | func def `:51` |
|
||||
|
||||
Creation with `--status todo` (or any non-backlog status) on an agent-assigned
|
||||
issue fires the agent immediately; `--status backlog` parks it with the assignee
|
||||
set but no trigger. Promoting `backlog → todo` later fires it then (update path,
|
||||
line 2537).
|
||||
|
||||
## Metadata CLI
|
||||
|
||||
| Behavior | File:line |
|
||||
|---|---|
|
||||
| `multica issue metadata set <issue-id> --key --value [--type]` | `server/cmd/multica/cmd_issue_metadata.go:80,109-111` |
|
||||
| `multica issue metadata delete <issue-id> --key` | `server/cmd/multica/cmd_issue_metadata.go:93,113` |
|
||||
| API routes (PUT/DELETE `/metadata/{key}`) | `server/cmd/server/router.go:478-479` |
|
||||
|
||||
`--value` is JSON-parsed by default (bool/number sniff); `--type` forces
|
||||
`string`/`number`/`bool`.
|
||||
|
||||
## Verification command
|
||||
|
||||
Re-derive any line above before depending on it:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
grep -n 'pull-requests <id>' cmd/multica/cmd_issue.go
|
||||
grep -n 'ListPullRequestsForIssue' cmd/server/router.go internal/handler/github.go
|
||||
grep -n 'func issuePullRequestRowToResponse\|type GitHubPullRequestResponse struct\|func derivePRState\|func extractIdentifiers\|func extractClosingIdentifiers\|closingIdentifierRe' internal/handler/github.go
|
||||
grep -n 'extractIdentifiers(\|extractClosingIdentifiers(\|derivePRState(' internal/handler/github.go
|
||||
grep -n 'prevIssue.Status == "backlog"\|func (h \*Handler) shouldEnqueueAgentTask' internal/handler/issue.go
|
||||
grep -n 'func notifyParentOfChildDone' internal/handler/issue_child_done.go
|
||||
```
|
||||
509
server/internal/service/builtin_skills_test.go
Normal file
509
server/internal/service/builtin_skills_test.go
Normal file
@@ -0,0 +1,509 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
)
|
||||
|
||||
// Built-in skills are the platform's standard "template" skills. These evals
|
||||
// pin the template every skill must follow and — crucially — couple each
|
||||
// skill's documented contract to the real backend behavior it describes, so a
|
||||
// drift in the source-of-truth (e.g. the mention regex) breaks CI instead of
|
||||
// silently turning the skill into a lie agents act on.
|
||||
//
|
||||
// The evals live in a _test.go file on purpose: anything *inside* a skill
|
||||
// directory is walked into AgentSkillData.Files and shipped to agent machines
|
||||
// (see loadBuiltinSkill). Tests must stay out of that payload.
|
||||
|
||||
const (
|
||||
// maxSkillBodyLines is Anthropic's L2 budget for a SKILL.md body
|
||||
// (~5k tokens). Past this, content belongs in one-level-deep supporting
|
||||
// files, not the always-loaded body.
|
||||
maxSkillBodyLines = 500
|
||||
// maxDescriptionChars is the frontmatter description cap — it is the only
|
||||
// thing an agent sees when deciding whether to load the skill.
|
||||
maxDescriptionChars = 1024
|
||||
)
|
||||
|
||||
// TestBuiltinSkillsConformToTemplate enforces the standard-template invariants
|
||||
// on every built-in skill, current and future. A new skill that violates the
|
||||
// shape fails here without anyone having to remember the rules.
|
||||
func TestBuiltinSkillsConformToTemplate(t *testing.T) {
|
||||
skills := loadBuiltinSkills()
|
||||
if len(skills) == 0 {
|
||||
t.Fatal("no built-in skills loaded; embed or layout is broken")
|
||||
}
|
||||
|
||||
for _, skill := range skills {
|
||||
t.Run(skill.Name, func(t *testing.T) {
|
||||
// The multica- prefix keeps the on-disk slug from colliding with a
|
||||
// user-authored workspace skill.
|
||||
if !strings.HasPrefix(skill.Name, "multica-") {
|
||||
t.Errorf("skill name %q must carry the multica- prefix", skill.Name)
|
||||
}
|
||||
|
||||
fm, body, ok := splitFrontmatter(skill.Content)
|
||||
if !ok {
|
||||
t.Fatalf("SKILL.md must lead with a --- frontmatter block")
|
||||
}
|
||||
if strings.TrimSpace(fm["name"]) == "" {
|
||||
t.Errorf("frontmatter is missing a non-empty name")
|
||||
}
|
||||
desc := strings.TrimSpace(fm["description"])
|
||||
if desc == "" {
|
||||
t.Errorf("frontmatter is missing a description (the only thing an agent sees when deciding to load the skill)")
|
||||
}
|
||||
if len(desc) > maxDescriptionChars {
|
||||
t.Errorf("description is %d chars, over the %d cap", len(desc), maxDescriptionChars)
|
||||
}
|
||||
if n := strings.Count(body, "\n") + 1; n > maxSkillBodyLines {
|
||||
t.Errorf("SKILL.md body is %d lines, over the %d-line L2 budget; move detail into one-level-deep supporting files", n, maxSkillBodyLines)
|
||||
}
|
||||
|
||||
// Evals must never ride along to agent machines as supporting files.
|
||||
for _, f := range skill.Files {
|
||||
lower := strings.ToLower(f.Path)
|
||||
if strings.Contains(lower, "eval") || strings.HasSuffix(lower, "_test.go") || strings.HasSuffix(lower, "_test.md") {
|
||||
t.Errorf("supporting file %q looks like an eval/test; evals belong in _test.go, not the shipped skill payload", f.Path)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMentioningSkillFollowsContractFrontmatter locks the reference template:
|
||||
// the mentioning skill is a context-triggered platform-contract skill, so it
|
||||
// must declare user-invocable:false and fence itself to the multica CLI. New
|
||||
// contract skills should copy this shape.
|
||||
func TestMentioningSkillFollowsContractFrontmatter(t *testing.T) {
|
||||
skill, ok := findSkill(t, "multica-mentioning")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fm, _, _ := splitFrontmatter(skill.Content)
|
||||
|
||||
if got := strings.TrimSpace(fm["user-invocable"]); got != "false" {
|
||||
t.Errorf("user-invocable = %q, want false (a platform-contract skill triggers from context, not a slash command)", got)
|
||||
}
|
||||
if got := strings.TrimSpace(fm["allowed-tools"]); got != "Bash(multica *)" {
|
||||
t.Errorf("allowed-tools = %q, want Bash(multica *) (fence the skill to the CLI it teaches)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMentioningSkillTeachesTheParserContract is the eval that gives the skill
|
||||
// its value: it proves the skill teaches exactly what util.ParseMentions
|
||||
// enforces. The skill's "Incorrect" examples must parse to nothing (the
|
||||
// @gpt-boy class of bug: a name where a UUID belongs fails silently), and its
|
||||
// "Correct" example must parse. If mention.go:16 drifts, this breaks and the
|
||||
// skill's claims must be re-checked.
|
||||
func TestMentioningSkillTeachesTheParserContract(t *testing.T) {
|
||||
const uuid = "7f3a1b2c-0000-4000-8000-000000000abc"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
content string
|
||||
want []util.Mention
|
||||
}{
|
||||
{
|
||||
// Skill: "Writing [@Alice](mention://member/Alice) does NOTHING."
|
||||
// 'l'/'i' are not hex, so the id fails to parse — link is dead.
|
||||
name: "name where a uuid belongs is silently dead",
|
||||
content: "[@Alice](mention://member/Alice) please review",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
// Skill: a bare @name is plain text, nobody is notified.
|
||||
name: "bare @name is plain text",
|
||||
content: "@alice please review",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
// Skill Step 2: type and id source matched → fires.
|
||||
name: "real uuid with matching type fires",
|
||||
content: "[@Alice](mention://member/" + uuid + ") please review",
|
||||
want: []util.Mention{{Type: "member", ID: uuid}},
|
||||
},
|
||||
{
|
||||
// Skill: @all uses the literal `all`, never a UUID.
|
||||
name: "all uses the literal all",
|
||||
content: "[@all](mention://all/all) heads up",
|
||||
want: []util.Mention{{Type: "all", ID: "all"}},
|
||||
},
|
||||
{
|
||||
// Skill: "Using the wrong type for an id points at the wrong
|
||||
// entity." The link still parses — it just resolves wrong — which
|
||||
// is exactly why the skill stresses matching type to id source.
|
||||
name: "wrong type still parses (points at wrong entity)",
|
||||
content: "[@Bot](mention://member/" + uuid + ")",
|
||||
want: []util.Mention{{Type: "member", ID: uuid}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := util.ParseMentions(tc.content)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("ParseMentions(%q) = %+v, want %+v", tc.content, got, tc.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("mention[%d] = %+v, want %+v", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkingOnIssuesSkillCoversIssueLoopContracts(t *testing.T) {
|
||||
skill, ok := findSkill(t, "multica-working-on-issues")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fm, body, _ := splitFrontmatter(skill.Content)
|
||||
|
||||
if got := strings.TrimSpace(fm["user-invocable"]); got != "false" {
|
||||
t.Errorf("user-invocable = %q, want false (issue workflow guidance triggers from context)", got)
|
||||
}
|
||||
if got := strings.TrimSpace(fm["allowed-tools"]); !strings.Contains(got, "Bash(multica *)") {
|
||||
t.Errorf("allowed-tools = %q, want access to the Multica CLI", got)
|
||||
}
|
||||
|
||||
// Contract anchors only — exact file:line citations live in the skill's
|
||||
// references/source-map.md, not here, so a downstream main merge that
|
||||
// shifts a line cannot rot this test into pinning a stale lie.
|
||||
mustContain := []string{
|
||||
"multica issue pull-requests <issue-id> --output json",
|
||||
"Closes MUL-2759",
|
||||
"--status backlog",
|
||||
"pr_url",
|
||||
"references/working-on-issues-source-map.md",
|
||||
}
|
||||
for _, want := range mustContain {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("working-on-issues skill missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
mustNotContain := []string{
|
||||
"Start from the trigger, not from memory",
|
||||
"multica issue get <issue-id> --output json",
|
||||
"multica issue metadata list <issue-id> --output json",
|
||||
"multica issue comment list <issue-id> --thread <trigger-comment-id>",
|
||||
"multica issue comment add <issue-id> --parent <trigger-comment-id>",
|
||||
}
|
||||
for _, forbidden := range mustNotContain {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Errorf("working-on-issues skill duplicates runtime prompt contract %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
if !skillHasFile(skill, "references/working-on-issues-source-map.md") {
|
||||
t.Errorf("working-on-issues skill missing supporting file references/working-on-issues-source-map.md")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillImportingSkillCoversWorkspaceImportContracts(t *testing.T) {
|
||||
skill, ok := findSkill(t, "multica-skill-importing")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fm, body, _ := splitFrontmatter(skill.Content)
|
||||
|
||||
if got := strings.TrimSpace(fm["user-invocable"]); got != "false" {
|
||||
t.Errorf("user-invocable = %q, want false (skill import guidance triggers from context)", got)
|
||||
}
|
||||
if got := strings.TrimSpace(fm["allowed-tools"]); !strings.Contains(got, "Bash(multica *)") {
|
||||
t.Errorf("allowed-tools = %q, want access to the Multica CLI", got)
|
||||
}
|
||||
|
||||
mustContain := []string{
|
||||
"multica skill import --url <url> --output json",
|
||||
"/api/skills/import",
|
||||
"clawhub.ai",
|
||||
"skills.sh",
|
||||
"github.com",
|
||||
"config.origin",
|
||||
"409",
|
||||
"existing_skill",
|
||||
"id",
|
||||
"name",
|
||||
"legacy",
|
||||
"multica skill list --output json",
|
||||
"npx skills add",
|
||||
"multica agent skills add <agent-id> --skill-ids <skill-id> --output json",
|
||||
"multica agent skills list <agent-id> --output json",
|
||||
"replace-all",
|
||||
"`set` is the replacement path",
|
||||
"references/skill-importing-source-map.md",
|
||||
}
|
||||
for _, want := range mustContain {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("skill-importing skill missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
mustNotContain := []string{
|
||||
"multica agent skills set <agent-id> --skill-ids <skill-id>",
|
||||
"merge the new skill id with the existing ids",
|
||||
}
|
||||
for _, forbidden := range mustNotContain {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Errorf("skill-importing skill should not teach stale or destructive binding command %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
if !skillHasFile(skill, "references/skill-importing-source-map.md") {
|
||||
t.Errorf("skill-importing skill missing supporting file references/skill-importing-source-map.md")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatingAgentsSkillCoversAgentCreationContracts(t *testing.T) {
|
||||
skill, ok := findSkill(t, "multica-creating-agents")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fm, body, _ := splitFrontmatter(skill.Content)
|
||||
|
||||
if got := strings.TrimSpace(fm["user-invocable"]); got != "false" {
|
||||
t.Errorf("user-invocable = %q, want false (agent creation guidance triggers from context)", got)
|
||||
}
|
||||
if got := strings.TrimSpace(fm["allowed-tools"]); !strings.Contains(got, "Bash(multica *)") {
|
||||
t.Errorf("allowed-tools = %q, want access to the Multica CLI", got)
|
||||
}
|
||||
|
||||
mustContain := []string{
|
||||
"not a parameter manual",
|
||||
"`description` is a catalog summary",
|
||||
"`instructions` is the runtime behavior contract",
|
||||
"multica agent create --name <name> --runtime-id <runtime-id>",
|
||||
"`model` is a first-class persisted column",
|
||||
"custom_env",
|
||||
"--custom-env-stdin",
|
||||
"--custom-env-file",
|
||||
"multica agent skills add <agent-id> --skill-ids <skill-id> --output json",
|
||||
"multica agent skills list <agent-id> --output json",
|
||||
"multica agent get <agent-id> --output json",
|
||||
"255",
|
||||
"references/creating-agents-source-map.md",
|
||||
}
|
||||
for _, want := range mustContain {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("creating-agents skill missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
mustNotContain := []string{
|
||||
"--from-template",
|
||||
"/api/agent-templates",
|
||||
"template_slug",
|
||||
"curated template",
|
||||
"copy this parameter list",
|
||||
// De-coaching: this skill states source-backed contracts, it does not
|
||||
// teach a generic how-to methodology.
|
||||
"Define the job first",
|
||||
"Run a low-risk task",
|
||||
"Decision flow",
|
||||
}
|
||||
for _, forbidden := range mustNotContain {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Errorf("creating-agents skill should not teach immature template content or generic how-to coaching %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
if !skillHasFile(skill, "references/creating-agents-source-map.md") {
|
||||
t.Errorf("creating-agents skill missing supporting file references/creating-agents-source-map.md")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSquadsSkillCoversLeaderRoutingContract(t *testing.T) {
|
||||
skill, ok := findSkill(t, "multica-squads")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fm, body, _ := splitFrontmatter(skill.Content)
|
||||
|
||||
if got := strings.TrimSpace(fm["user-invocable"]); got != "false" {
|
||||
t.Errorf("user-invocable = %q, want false (squad guidance triggers from context)", got)
|
||||
}
|
||||
if got := strings.TrimSpace(fm["allowed-tools"]); !strings.Contains(got, "Bash(multica *)") {
|
||||
t.Errorf("allowed-tools = %q, want access to the Multica CLI", got)
|
||||
}
|
||||
|
||||
mustContain := []string{
|
||||
"A squad is not an agent",
|
||||
"squad's `leader_id` agent",
|
||||
"squad members are not automatically fanned out",
|
||||
"multica squad member set-role",
|
||||
"mention://squad/<squad-id>",
|
||||
"recording squad activity",
|
||||
"references/squad-source-map.md",
|
||||
}
|
||||
for _, want := range mustContain {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("squads skill missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
if !skillHasFile(skill, "references/squad-source-map.md") {
|
||||
t.Errorf("squads skill missing supporting file references/squad-source-map.md")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutopilotsSkillCoversDispatchAndSideEffects(t *testing.T) {
|
||||
skill, ok := findSkill(t, "multica-autopilots")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fm, body, _ := splitFrontmatter(skill.Content)
|
||||
|
||||
if got := strings.TrimSpace(fm["user-invocable"]); got != "false" {
|
||||
t.Errorf("user-invocable = %q, want false", got)
|
||||
}
|
||||
if got := strings.TrimSpace(fm["allowed-tools"]); !strings.Contains(got, "Bash(multica *)") {
|
||||
t.Errorf("allowed-tools = %q, want access to the Multica CLI", got)
|
||||
}
|
||||
|
||||
mustContain := []string{
|
||||
"An autopilot is not an agent",
|
||||
"create_issue",
|
||||
"run_only",
|
||||
"multica autopilot trigger-add <autopilot-id> --kind schedule",
|
||||
"multica autopilot trigger <autopilot-id> --output json",
|
||||
"Do not run `trigger`",
|
||||
"webhook tokens",
|
||||
"{{date}}",
|
||||
"squad's leader agent",
|
||||
"references/autopilots-source-map.md",
|
||||
}
|
||||
for _, want := range mustContain {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("autopilots skill missing %q", want)
|
||||
}
|
||||
}
|
||||
if !skillHasFile(skill, "references/autopilots-source-map.md") {
|
||||
t.Errorf("autopilots skill missing supporting file references/autopilots-source-map.md")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimesAndReposSkillCoversClaimAndCheckoutChain(t *testing.T) {
|
||||
skill, ok := findSkill(t, "multica-runtimes-and-repos")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fm, body, _ := splitFrontmatter(skill.Content)
|
||||
|
||||
if got := strings.TrimSpace(fm["user-invocable"]); got != "false" {
|
||||
t.Errorf("user-invocable = %q, want false", got)
|
||||
}
|
||||
if got := strings.TrimSpace(fm["allowed-tools"]); !strings.Contains(got, "Bash(multica *)") {
|
||||
t.Errorf("allowed-tools = %q, want access to the Multica CLI", got)
|
||||
}
|
||||
|
||||
mustContain := []string{
|
||||
"agent_task_queue",
|
||||
"daemon polls/claims the task",
|
||||
"multica runtime list --output json",
|
||||
"multica repo checkout <url>",
|
||||
"MULTICA_DAEMON_PORT",
|
||||
"github_repo",
|
||||
"local_directory",
|
||||
"Runtime and repo commands affect active agent execution",
|
||||
"references/runtimes-and-repos-source-map.md",
|
||||
}
|
||||
for _, want := range mustContain {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("runtimes-and-repos skill missing %q", want)
|
||||
}
|
||||
}
|
||||
if !skillHasFile(skill, "references/runtimes-and-repos-source-map.md") {
|
||||
t.Errorf("runtimes-and-repos skill missing supporting file references/runtimes-and-repos-source-map.md")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectsAndResourcesSkillCoversDurableContext(t *testing.T) {
|
||||
skill, ok := findSkill(t, "multica-projects-and-resources")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fm, body, _ := splitFrontmatter(skill.Content)
|
||||
|
||||
if got := strings.TrimSpace(fm["user-invocable"]); got != "false" {
|
||||
t.Errorf("user-invocable = %q, want false", got)
|
||||
}
|
||||
if got := strings.TrimSpace(fm["allowed-tools"]); !strings.Contains(got, "Bash(multica *)") {
|
||||
t.Errorf("allowed-tools = %q, want access to the Multica CLI", got)
|
||||
}
|
||||
|
||||
mustContain := []string{
|
||||
"Projects are durable context containers",
|
||||
".multica/project/resources.json",
|
||||
"multica project resource list <project-id> --output json",
|
||||
"multica project resource add <project-id> --type github_repo --url <github-url> --output json",
|
||||
"multica project resource add <project-id> --type local_directory",
|
||||
"Project resources are durable and affect future tasks",
|
||||
"github_repo.resource_ref.url",
|
||||
"references/projects-and-resources-source-map.md",
|
||||
}
|
||||
for _, want := range mustContain {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("projects-and-resources skill missing %q", want)
|
||||
}
|
||||
}
|
||||
if !skillHasFile(skill, "references/projects-and-resources-source-map.md") {
|
||||
t.Errorf("projects-and-resources skill missing supporting file references/projects-and-resources-source-map.md")
|
||||
}
|
||||
}
|
||||
|
||||
func findSkill(t *testing.T, name string) (AgentSkillData, bool) {
|
||||
t.Helper()
|
||||
for _, s := range loadBuiltinSkills() {
|
||||
if s.Name == name {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
t.Errorf("built-in skill %q not found", name)
|
||||
return AgentSkillData{}, false
|
||||
}
|
||||
|
||||
func skillHasFile(skill AgentSkillData, path string) bool {
|
||||
for _, f := range skill.Files {
|
||||
if f.Path == path {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// splitFrontmatter returns the top-level scalar keys of a leading YAML
|
||||
// frontmatter block, the body after it, and whether a block was found. It only
|
||||
// understands flat `key: value` lines — enough for the template's frontmatter.
|
||||
func splitFrontmatter(content string) (map[string]string, string, bool) {
|
||||
if !strings.HasPrefix(content, "---\n") {
|
||||
return nil, content, false
|
||||
}
|
||||
rest := content[len("---\n"):]
|
||||
end := strings.Index(rest, "\n---")
|
||||
if end < 0 {
|
||||
return nil, content, false
|
||||
}
|
||||
block := rest[:end]
|
||||
body := rest[end:]
|
||||
if nl := strings.Index(body, "\n"); nl >= 0 {
|
||||
body = body[nl+1:] // drop the closing --- line
|
||||
}
|
||||
|
||||
fm := make(map[string]string)
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
|
||||
continue // nested value; the template uses only flat scalars
|
||||
}
|
||||
key, val, found := strings.Cut(line, ":")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
fm[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(val), `"'`)
|
||||
}
|
||||
return fm, body, true
|
||||
}
|
||||
Reference in New Issue
Block a user