Compare commits

...

28 Commits

Author SHA1 Message Date
Naiyuan Qing
f8ed9f7250 docs(claude): remind to update built-in skills on CLI/field/behavior changes
Add a Coding Rule: when a change touches a CLI command/flag, API field, or
product behavior that a built-in skill documents, update that skill's SKILL.md
and source-map in the same PR. Lives in the repo dev-guide (read when working in
this repo), not the runtime brief — the runtime brief is injected into every
workspace, where most agents have no Multica skill to update. AGENTS.md is a
pointer to CLAUDE.md, so no mirror needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 17:24:48 +08:00
Naiyuan Qing
b2a43cf58a revert(runtime): drop the source-authority escape-hatch line
Reverts the brief addition from fdd5e82df and its follow-up cc67b2088. The
`--help` discovery fallback already in the Available Commands note is enough;
the extra trust-precedence sentence was unnecessary. runtime_config.go is now
identical to 6ca27ad74.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 17:16:40 +08:00
Naiyuan Qing
cc67b2088f fix(runtime): scope the source-authority escape hatch to the CLI
The previous version told agents the "checked-out source is the deeper
authority" for verifying behavior. That over-claims: the repos in a task's
brief come from GetWorkspaceRepos + project github_repo resources (per-workspace
config, see daemon.registerTaskRepos), not the Multica platform source. A
generic agent's checked-out source is its own app, not Multica's code, so it
cannot verify a Multica skill/brief claim against it. The only universally
available authority for Multica behavior is the live CLI (`--help` /
`--output json` / observed command behavior). Re-scope the line accordingly and
state plainly that the platform's source is not in the workdir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 17:13:06 +08:00
Naiyuan Qing
fdd5e82dfd feat(runtime): add source-authority escape hatch to the brief
The brief already tells agents to run `--help` for command discovery, but
nothing stated the trust precedence when a skill, the brief, or a doc seems to
contradict actual behavior. Add one line to the Available Commands escape-hatch
note: trust the live CLI (`--help`/`--output json`) and the checked-out source
over source-traced prose that can lag the code, and verify on any conflict or
confusion. Kept in the always-on brief (universal, needed before any skill
loads) rather than duplicated into each skill; per-skill source-map pointers
remain the specific layer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 17:05:58 +08:00
Naiyuan Qing
6ca27ad749 docs(skills): remove stale skill-necessity records
The per-skill necessity records had drifted to 3 of 8 shipped skills plus a
record for `multica-skill-authoring`, which is not a shipped built-in skill.
Per-skill "why it exists / when to use it" already lives co-located with each
skill (frontmatter `description` + `references/<skill>-source-map.md`) where it
cannot drift from the skill, and the doc's methodology duplicated the
workspace's inbuilt-skill-authoring protocol. Remove the file rather than keep a
parallel listing that every new skill has to remember to update.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 16:55:26 +08:00
Naiyuan Qing
496890011d refactor(skills): remove discovery guidance from built-ins 2026-06-03 16:38:40 +08:00
Naiyuan Qing
635f653f71 refactor(skills): drop CLI-only rule from working-on-issues
The "Platform data goes through the CLI" section duplicated the runtime
brief's `## Important: Always Use the multica CLI` section verbatim (and
the attachment-via-CLI note duplicated the brief's `## Attachments`). The
CLI-only rule is universal and must be known before any skill loads, so
the brief is its single source of truth; the skill copy was pure
redundancy and a drift risk. Remove it and the matching intro clause.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 14:39:24 +08:00
Naiyuan Qing
75250774af feat(runtime): list skill descriptions in the brief Skills index
The brief's `## Skills` section emitted bare skill names only, discarding
the one-line description that SkillContextForEnv already carries. For
Claude-family providers the frontmatter description is loaded natively;
for providers without native skill discovery (hermes/default) the brief's
list is the only signal they ever see, so a bare name gave them nothing
to decide when to load a skill. Emit `name — description` when a
description is present, falling back to the bare name when it is empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 14:39:24 +08:00
Naiyuan Qing
ca78c02ab9 Add built-in skills for autopilots runtimes and resources
Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 11:12:57 +08:00
Naiyuan Qing
e214b1332c docs(skills): close field-role and citation gaps found in review
Independent review of the rewritten built-in skills surfaced two real gaps and
some citation drift; this fixes them.

- creating-agents: add the three missing field rows (visibility,
  max_concurrent_tasks, mcp_config) to the field-contract table — mcp_config is
  runtime-consumed (TaskAgentData, daemon.go), visibility is access-control
  (default private), max_concurrent_tasks is a scheduler cap (default 6).
  Mark custom_args/runtime_config JSON validation as CLI-side (the server
  marshals as-is). Correct the CLI body-builder note (description/instructions
  use a non-empty check, the rest use Changed). Source-map: fix the env query
  name (UpdateAgentCustomEnv), the conformance test name, and add the new field
  defaults + the McpConfig runtime-payload line.
- mentioning: the @squad mention private gate is canAccessPrivateAgent, not
  canEnqueueSquadLeader (that wrapper is the assignment/child-done path).
- working-on-issues: cite notifyParentOfChildDone at its func def (:51), not the
  doc comment (:15).
- skill-importing: config.origin is set only when the source supplied an origin
  — note it may be absent; cite createSkillWithFiles at its definition
  (skill_create.go:72), not the call site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 11:12:03 +08:00
Naiyuan Qing
5dadc38e6d refactor(skills): rewrite built-in skills as source-traced contracts
Rewrite the built-in agent skills to the inbuilt-skill-authoring standard:
state source-traced product facts with the source-code link logic as the
core, not prescriptive how-to coaching.

- creating-agents: drop the Decision-flow / Do-don't-consequences
  methodology; replace with field/behavior contracts (validation, persisted
  shape, daemon claim-time consumption, env gating, skill binding).
- skill-discovery: stop teaching repo/github_stars as selection signals —
  searchClawHubSkills never populates them (always null); rank by
  install_count + source/url + description. Add file:line citations.
- mentioning: drop the unbacked "member mention sends a notification" claim
  (no such path in the comment handler); state that only agent/squad
  mentions enqueue work. Tighten the parser-failure wording.
- working-on-issues: refresh citations drifted by the main merge; describe
  the PR response `state` enum accurately; trim status coaching.
- skill-importing: correct response type to SkillWithFilesResponse; document
  the reserved SKILL.md supporting-file rule; add line-accurate citations.
- squads: correct the "leader cannot be archived" overstatement (not
  rejected at create/update; fails closed later at routing/dispatch);
  refresh source-map attributions and test list.

Each skill now ships references/<skill>-source-map.md as its evidence layer
(line-accurate citations live there, not pinned in the test, so a future
main merge cannot rot them into stale lies).

builtin_skills_test.go: replace coaching/line-number pins with drift-resistant
contract anchors, forbid the coaching phrasing, and require every skill to
ship its source-map. The ParseMentions behavior coupling is preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 10:30:59 +08:00
Naiyuan Qing
a9405d5eac Merge branch 'main' into feat/builtin-skills 2026-06-03 09:50:48 +08:00
Naiyuan Qing
8fea549eaf Add built-in squads skill
Co-authored-by: multica-agent <github@multica.ai>
2026-06-02 19:05:22 +08:00
Naiyuan Qing
923d895a39 feat(skills): add creating agents built-in skill
Co-authored-by: multica-agent <github@multica.ai>
2026-06-02 17:07:51 +08:00
Naiyuan Qing
181337f1ad fix(skills): align built-ins with additive skill binding
Co-authored-by: multica-agent <github@multica.ai>
2026-06-02 15:40:02 +08:00
Naiyuan Qing
fc0bb57d78 Merge remote-tracking branch 'origin/main' into feat/builtin-skills 2026-06-02 15:25:36 +08:00
Naiyuan Qing
fa7437dff0 fix(skills): make built-in skill bundle launch-ready
Co-authored-by: multica-agent <github@multica.ai>
2026-06-02 13:43:21 +08:00
Naiyuan Qing
47d716a939 docs(skills): use structured skill search
Co-authored-by: multica-agent <github@multica.ai>
2026-06-01 15:33:40 +08:00
Naiyuan Qing
fbbfa80181 Merge remote-tracking branch 'origin/main' into agent/matt/ed345a53 2026-06-01 15:31:45 +08:00
Naiyuan Qing
c46c381bc3 docs(skills): align builtin skill workflows
Co-authored-by: multica-agent <github@multica.ai>
2026-06-01 14:59:45 +08:00
Naiyuan Qing
29fafd0b06 Merge remote-tracking branch 'origin/main' into agent/matt/ed345a53 2026-06-01 14:52:47 +08:00
Naiyuan Qing
f08f1db219 feat(skills): add skill authoring built-in
Co-authored-by: multica-agent <github@multica.ai>
2026-06-01 14:16:48 +08:00
Naiyuan Qing
1a820cca67 feat(skills): add skill import and discovery built-ins
Co-authored-by: multica-agent <github@multica.ai>
2026-06-01 13:32:57 +08:00
Naiyuan Qing
532e1fa570 feat(skills): verify linked PRs in issue workflow skill
Co-authored-by: multica-agent <github@multica.ai>
2026-06-01 10:52:53 +08:00
Naiyuan Qing
5de237ffcc Merge remote-tracking branch 'origin/main' into agent/matt/ed345a53 2026-06-01 10:48:48 +08:00
Naiyuan Qing
afdaedc1c2 feat(skills): add working-on-issues built-in skill
Co-authored-by: multica-agent <github@multica.ai>
2026-06-01 09:36:57 +08:00
Naiyuan Qing
d782b5745e feat(skills): make multica-mentioning the standard template + add eval
Add the contract-skill frontmatter the other built-in skills will copy:
user-invocable:false (it triggers from context, not as a slash command)
and allowed-tools fencing it to the multica CLI it teaches. These keys
survive to agent machines untouched (ensureSkillFrontmatter only ever
adds a missing name).

Add a Go eval in builtin_skills_test.go (a _test.go so it never ships to
agent machines via the skill-files walk):
- Enforces the template invariants on every built-in skill, present and
  future: multica- prefix, name+description present, description within
  1024 chars, body within the 500-line L2 budget, no eval file leaking
  into the shipped payload.
- Couples the mentioning skill's documented contract to the real
  util.ParseMentions: its Incorrect examples must parse to nothing (a
  name where a UUID belongs fails silently) and its Correct example must
  fire. A drift in the mention regex now breaks CI instead of silently
  turning the skill into a lie agents act on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-29 14:24:55 +08:00
Naiyuan Qing
fa9919a969 feat(skills): introduce built-in agent skills (WIP)
Inject platform-authored, version-bundled skills into every agent on top of
its workspace-bound skills, so agents learn how to operate Multica correctly
without users needing to know the internals or agents needing to read source.

Mechanism: skills are embedded into the server binary and appended to the
agent payload at task-claim time (handler/daemon.go), reusing the existing
SkillData wire + daemon-side writeSkillFiles. The daemon needs no changes,
and because it travels over an existing wire field, older daemons pick the
skills up the moment the server ships.

First skill: multica-mentioning — how to build a working @mention (look up
the UUID, match type to id source, know what each mention type triggers).

WIP: injection mechanism + first skill only; more skills to follow in
dependency order (skill -> agent -> squad).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:07:57 +08:00
21 changed files with 2434 additions and 1 deletions

View File

@@ -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

View File

@@ -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")
}

View File

@@ -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 {

View 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
}

View File

@@ -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`.

View File

@@ -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}`.

View File

@@ -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.

View File

@@ -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` | 159162 | Registered create flags; `name`/`runtime-id` enforced in `runAgentCreate` | `multica agent create --help` |
| `runtime-config`, `model`, `custom-args` flags | 169171 | `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` | 172174 | `--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 409491 |
| Body assembly: description/instructions/runtime-config/custom-args/custom-env/model | 432474 | `resolveCustomEnv` (458) gates the three env channels; omitted flags are not sent | read 432474 |
| `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` | 3353 | Exposes only `has_custom_env` (52) and `custom_env_key_count` (53); comment cites MUL-2600 |
| `CreateAgentRequest` fields | 565585 | `description`, `instructions`, `runtime_config`, `custom_env`, `custom_args`, `model`, `thinking_level` (plus name/avatar/visibility/mcp_config/max_concurrent_tasks) |
| `name` required | 623625 | 400 "name is required" |
| `description` ≤ 255 code points | 627629 | `utf8.RuneCountInString(req.Description) > maxAgentDescriptionLength` → 400 |
| `runtime_id` required | 631633 | `if req.RuntimeID == ""` → 400 "runtime_id is required" |
| `runtime_id` must resolve in workspace | 642658 | parsed + `GetAgentRuntimeForWorkspace`; unknown → 400 "invalid runtime_id" |
| `thinking_level` provider-level validation | 673676 | `!agent.IsKnownThinkingValue(runtime.Provider, req.ThinkingLevel)` → 400; per-model gaps deferred to daemon (comment 669672, MUL-2339) |
| Defaults: `{}` config/env, `[]` args | 688701 | `RuntimeConfig``{}`, `CustomEnv``{}`, `CustomArgs``[]` when nil, before insert |
| `visibility` default | 635636 | `if req.Visibility == "" { req.Visibility = "private" }` — access-control field, not the runtime prompt |
| `max_concurrent_tasks` default | 638639 | `if req.MaxConcurrentTasks == 0 { req.MaxConcurrentTasks = 6 }` — scheduler cap |
| `mcp_config` null-skip on create | 704705 | raw JSON copied through unless the body value is the literal `null` |
| `mcp_config` redacted on read | 54, 848851 | `redactMcpConfig` sets `McpConfigRedacted=true`; a private agent read by a member also redacts (494, 509) |
| `CreateAgent` insert params | 708722 | persists runtime_config, instructions, custom_env, custom_args, model, thinking_level, mcp_config, visibility, max_concurrent_tasks |
| `UpdateAgent` rejects `custom_env` | 910913 | if `custom_env` present in body → 400 "use PUT /api/agents/{id}/env (or `multica agent env set`)" |
| `description` ≤ 255 on update too | 921924 | 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 | 8084 | `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 | 11091111 | `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 | 11301143 | `TaskAgentData` carries `Instructions`, `Skills`, `CustomEnv`, `CustomArgs`, `Model`, `ThinkingLevel`, `McpConfig` (11301131, 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` | 1011 | skills embedded at compile time |
| `loadBuiltinSkill` | 45 | reads `<name>/SKILL.md` (47) + walks sibling files into `Files` (5668) |
## Persisted columns — `server/pkg/db/generated/agent.sql.go`
| Contract | Line | Behavior |
|---|---|---|
| `CreateAgent` INSERT | 730736 | columns include `runtime_config, runtime_id, instructions, custom_env, custom_args, mcp_config, model, thinking_level` |
| `CreateAgentParams` | 739756 | typed params: `RuntimeConfig []byte`, `Instructions string`, `CustomEnv []byte`, `CustomArgs []byte`, `Model pgtype.Text`, `ThinkingLevel pgtype.Text` |
| `UpdateAgent` SET | 25522566 | 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 |

View File

@@ -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.

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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`.

View 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
```

View File

@@ -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'
```

View File

@@ -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.

View File

@@ -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
```

View 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
}