mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-06 19:00:01 +02:00
* refactor(skills): make the brief's skill list a names-only index (MUL-5529)
Step 3 of MUL-5529. Every runtime CLI discovers the SKILL.md files the daemon
writes and builds its own listing from their frontmatter — verified against 11
locally installed CLIs plus official docs for 5 more. The brief's copy of those
descriptions was therefore the same routing signal paid for twice: measured on
a real task, `## Skills` was 13,295 chars, 40% of the entire brief, against a
16,304-char CLI listing of the same 28 skills.
Now 850 chars for that same set — roughly 3,100 tokens back per brief.
The index itself stays. It is the one skill listing Multica controls; each
CLI's own listing is theirs, and its format — or its existence — can change
with any release.
Three changes:
- Descriptions dropped from the `## Skills` entries.
- The per-provider branch is gone. Its fallback told providers outside a
hardcoded list to read `.agent_context/skills/`, but the only providers
that ever reached it were grok and traecli, whose files are written to
`.grok/skills` and `.traecli/skills` and which discover natively. The
pointer was wrong for everyone it addressed, so removing the branch
deletes the bug rather than relocating it. This closes MUL-5537.
- issue_context.md and its quick-create / autopilot variants no longer render
`## Agent Skills`. That copy duplicated the brief once both were
names-only, and nothing ever read it: no prompt references the path, and
grepping the server finds only the writer. `.agent_context/skills/` had the
same fate for hermes (issue #5242). Quick-create, previously skipped in the
brief and served only by that unread copy, now gets the brief section like
every other kind — one index, one place.
Not included: skills carrying `disable-model-invocation` are still written to
disk for every provider. The plan assumed that key needed provider-specific
handling for everything except claude; probing the installed CLIs shows 9 of 11
honor it, and only opencode and hermes do not. The remaining question is
narrow and a genuine product tradeoff — withholding the file honors the
author's intent but also removes explicit invocation — so it is left to a
separate decision rather than folded in here.
Co-authored-by: multica-agent <github@multica.ai>
* docs(skills): align stale comments with the names-only brief contract (MUL-5529)
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Steve Jobs (Multica Agent) <agent-steve-jobs@multica.ai>
1060 lines
44 KiB
Go
1060 lines
44 KiB
Go
package execenv
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
skillpkg "github.com/multica-ai/multica/server/internal/skill"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// TaskContextMarkerRelPath is a non-secret marker the daemon writes under the
|
|
// task workdir. The CLI uses it as a fallback daemon-task signal when a child
|
|
// sandbox strips all MULTICA_* env vars before invoking `multica`.
|
|
const TaskContextMarkerRelPath = ".multica/daemon_task_context.json"
|
|
|
|
// TaskContextMarkerManagedBy is the marker discriminator the CLI checks before
|
|
// treating TaskContextMarkerRelPath as daemon-owned.
|
|
const TaskContextMarkerManagedBy = "multica-daemon-task"
|
|
|
|
type taskContextMarkerFile struct {
|
|
ManagedBy string `json:"managed_by"`
|
|
AgentID string `json:"agent_id,omitempty"`
|
|
IssueID string `json:"issue_id,omitempty"`
|
|
}
|
|
|
|
// EnsureWorkspacesRootMarker writes a persistent daemon-task marker at
|
|
// {workspacesRoot}/.multica/daemon_task_context.json.
|
|
//
|
|
// The per-workdir marker only protects `multica` invocations whose cwd is
|
|
// inside the workdir, because the CLI discovers markers by walking *up* from
|
|
// cwd. A sandboxed subprocess that lost every MULTICA_* env var and escaped
|
|
// to the workdir's parent directory sits above that marker, finds no daemon
|
|
// signal, and would fall back to the user's config PAT — a confirmed
|
|
// impersonation path. Every directory under workspacesRoot is daemon-owned,
|
|
// so a marker at the root puts the entire tree back under the fail-closed
|
|
// guard without touching any directory a user works in.
|
|
//
|
|
// A pre-existing marker owned by the daemon is left untouched, so the shared,
|
|
// node-wide file is not rewritten on every task start. A truncated or otherwise
|
|
// unparseable file — the signature of a torn os.WriteFile from a daemon killed
|
|
// mid-write — is reclaimed and rewritten, so a crash can never brick the guard
|
|
// for the whole node; only a *parseable* marker owned by something else is
|
|
// treated as genuinely foreign and refused. The (re)write is atomic
|
|
// (temp file + rename): a concurrent CLI walking up from an escaped cwd sees
|
|
// either the old bytes or the complete new file, never a half-written marker it
|
|
// would misread as "no signal" and fall open on.
|
|
func EnsureWorkspacesRootMarker(workspacesRoot string) error {
|
|
if strings.TrimSpace(workspacesRoot) == "" {
|
|
return errors.New("execenv: workspaces root is required")
|
|
}
|
|
path := filepath.Join(workspacesRoot, TaskContextMarkerRelPath)
|
|
if existing, err := os.ReadFile(path); err == nil {
|
|
var marker taskContextMarkerFile
|
|
if json.Unmarshal(existing, &marker) == nil {
|
|
if marker.ManagedBy == TaskContextMarkerManagedBy {
|
|
return nil
|
|
}
|
|
// Parseable but owned by something else: never clobber it.
|
|
return fmt.Errorf("foreign file at workspaces root marker path %s; refusing to overwrite", path)
|
|
}
|
|
// Unparseable content is almost certainly a torn write of our own
|
|
// marker; fall through to reclaim it below.
|
|
} else if !os.IsNotExist(err) {
|
|
// A real read error (e.g. a directory at the path) is not a signal we
|
|
// can safely overwrite; surface it. Callers degrade non-fatally.
|
|
return fmt.Errorf("read workspaces root marker %s: %w", path, err)
|
|
}
|
|
payload := taskContextMarkerFile{ManagedBy: TaskContextMarkerManagedBy}
|
|
data, err := json.MarshalIndent(payload, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal workspaces root marker: %w", err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return fmt.Errorf("create workspaces root marker dir: %w", err)
|
|
}
|
|
if err := writeWorkspacesRootMarkerAtomic(path, data); err != nil {
|
|
return fmt.Errorf("write workspaces root marker: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// writeWorkspacesRootMarkerAtomic writes data to path via a same-directory temp
|
|
// file plus a rename, so a concurrent reader observes either the old file or the
|
|
// complete new one — never a partial write. Mirrors the daemon-id / CLI-config
|
|
// write idiom (see writeDaemonIDFile). Perm is 0644 because the CLI's upward
|
|
// walk must be able to read the marker from a subprocess that may run under a
|
|
// different uid; the payload is non-secret.
|
|
func writeWorkspacesRootMarkerAtomic(path string, data []byte) error {
|
|
tmp, err := os.CreateTemp(filepath.Dir(path), ".daemon_task_context-*.json.tmp")
|
|
if err != nil {
|
|
return fmt.Errorf("create temp workspaces root marker: %w", err)
|
|
}
|
|
tmpPath := tmp.Name()
|
|
if _, err := tmp.Write(data); err != nil {
|
|
tmp.Close()
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("write temp workspaces root marker: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("close temp workspaces root marker: %w", err)
|
|
}
|
|
if err := os.Chmod(tmpPath, 0o644); err != nil {
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("chmod temp workspaces root marker: %w", err)
|
|
}
|
|
if err := os.Rename(tmpPath, path); err != nil {
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("rename workspaces root marker: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// writeContextFiles renders and writes .agent_context/issue_context.md and
|
|
// skills into the appropriate provider-native location.
|
|
//
|
|
// Claude: skills → {workDir}/.claude/skills/{name}/SKILL.md (native discovery)
|
|
// CodeBuddy: skills → {workDir}/.codebuddy/skills/{name}/SKILL.md (native discovery — CodeBuddy is a Claude Code fork but uses its own config directory, not .claude/; see https://www.codebuddy.ai/docs/cli/skills)
|
|
// Codex: skills → handled separately in Prepare via codex-home
|
|
// Hermes: skills → handled separately in Prepare via hermes-home (HERMES_HOME/skills; Hermes has no workspace-relative discovery, see hermes_home.go)
|
|
// Copilot: skills → {workDir}/.github/skills/{name}/SKILL.md (native project-level discovery)
|
|
// OpenCode: skills → {workDir}/.opencode/skills/{name}/SKILL.md (native discovery)
|
|
// OpenClaw: skills → {workDir}/skills/{name}/SKILL.md (native discovery — paired with a per-task synthesized openclaw-config.json that pins agents.defaults.workspace to workDir; see openclaw_config.go)
|
|
// Pi: skills → {workDir}/.pi/skills/{name}/SKILL.md (native discovery)
|
|
// Cursor: skills → {workDir}/.cursor/skills/{name}/SKILL.md (native discovery)
|
|
// Kimi: skills → {workDir}/.kimi/skills/{name}/SKILL.md (native discovery)
|
|
// Kiro: skills → {workDir}/.kiro/skills/{name}/SKILL.md (native discovery)
|
|
// Qoder: skills → {workDir}/.qoder/skills/{name}/SKILL.md (project-level; see docs.qoder.com/cli/Skills.md)
|
|
// Qwen Code: skills → {workDir}/.qwen/skills/{name}/SKILL.md (native project-level discovery)
|
|
// Antigravity: skills → {workDir}/.agents/skills/{name}/SKILL.md (native discovery — see https://antigravity.google/docs/gcli-migration "Workspace skills")
|
|
// Default: skills → {workDir}/.agent_context/skills/{name}/SKILL.md
|
|
//
|
|
// manifest, when non-nil, is populated with every file we created and every
|
|
// intermediate directory we had to MkdirAll (skipping any that pre-existed).
|
|
// CleanupSidecars uses it to roll the workdir back to its pre-Prepare
|
|
// state for local_directory tasks. Callers that don't need cleanup —
|
|
// cloud-mode tasks whose envRoot is wiped wholesale by the GC loop — may
|
|
// pass nil to skip the bookkeeping entirely.
|
|
func writeContextFiles(workDir, provider string, ctx TaskContextForEnv, manifest *sidecarManifest) error {
|
|
if err := writeTaskContextMarker(workDir, ctx, manifest); err != nil {
|
|
return err
|
|
}
|
|
|
|
contextDir := filepath.Join(workDir, ".agent_context")
|
|
if err := recordMkdirAll(contextDir, 0o755, manifest); err != nil {
|
|
return fmt.Errorf("create .agent_context dir: %w", err)
|
|
}
|
|
|
|
content := renderIssueContext(provider, ctx)
|
|
path := filepath.Join(contextDir, "issue_context.md")
|
|
if err := recordWriteFile(path, []byte(content), 0o644, manifest); err != nil {
|
|
// A pre-existing path means the user already owns
|
|
// .agent_context/issue_context.md — either they created it
|
|
// themselves or it survived from a crashed prior run we can't
|
|
// safely distinguish from intentional content. Refusing the
|
|
// write is the correct call: the runtime brief (CLAUDE.md /
|
|
// AGENTS.md) already carries every fact this file
|
|
// would, so the agent runs fine without the sidecar copy.
|
|
// Anything else is a real failure.
|
|
if !errors.Is(err, errPathPreExists) {
|
|
return fmt.Errorf("write issue_context.md: %w", err)
|
|
}
|
|
}
|
|
|
|
if len(ctx.AgentSkills) > 0 {
|
|
// Hermes materializes skills into its per-task HERMES_HOME/skills during
|
|
// Prepare (Hermes has no workspace-relative discovery), so it needs no
|
|
// workdir-local skills dir at all — skip the resolve too, to avoid
|
|
// leaving an empty .agent_context/skills/ behind.
|
|
if provider != "hermes" {
|
|
skillsDir, err := resolveSkillsDir(workDir, provider, manifest)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve skills dir: %w", err)
|
|
}
|
|
// Codex skills are written to codex-home in Prepare; skip here.
|
|
if provider != "codex" {
|
|
if err := writeSkillFiles(skillsDir, ctx.AgentSkills, manifest); err != nil {
|
|
return fmt.Errorf("write skill files: %w", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Project resources are best-effort: a write failure logs but does not
|
|
// block task startup. Missing resources surface as the agent simply not
|
|
// seeing the file, which matches the "scoped, not dumped" design (the
|
|
// meta skill content always lists what the agent should expect).
|
|
if err := writeProjectResources(workDir, ctx, manifest); err != nil {
|
|
// Caller logs warnings; avoid noisy returns for non-fatal context.
|
|
return fmt.Errorf("write project resources: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func writeTaskContextMarker(workDir string, ctx TaskContextForEnv, manifest *sidecarManifest) error {
|
|
dir := filepath.Dir(filepath.Join(workDir, TaskContextMarkerRelPath))
|
|
if err := recordMkdirAll(dir, 0o755, manifest); err != nil {
|
|
return fmt.Errorf("create .multica dir: %w", err)
|
|
}
|
|
// The sidecar manifest removes this marker on normal local_directory
|
|
// cleanup. If a crash leaves it behind, the CLI intentionally treats it
|
|
// as daemon context and fails closed instead of using a user PAT.
|
|
payload := taskContextMarkerFile{
|
|
ManagedBy: TaskContextMarkerManagedBy,
|
|
AgentID: ctx.AgentID,
|
|
IssueID: ctx.IssueID,
|
|
}
|
|
data, err := json.MarshalIndent(payload, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal task context marker: %w", err)
|
|
}
|
|
if err := recordWriteFile(filepath.Join(workDir, TaskContextMarkerRelPath), data, 0o644, manifest); err != nil {
|
|
if errors.Is(err, errPathPreExists) {
|
|
path := filepath.Join(workDir, TaskContextMarkerRelPath)
|
|
existing, readErr := os.ReadFile(path)
|
|
if readErr != nil {
|
|
return fmt.Errorf("read existing task context marker: %w", readErr)
|
|
}
|
|
var marker taskContextMarkerFile
|
|
if json.Unmarshal(existing, &marker) != nil || marker.ManagedBy != TaskContextMarkerManagedBy {
|
|
return fmt.Errorf("write task context marker: %w", err)
|
|
}
|
|
if writeErr := os.WriteFile(path, data, 0o644); writeErr != nil {
|
|
return fmt.Errorf("refresh task context marker: %w", writeErr)
|
|
}
|
|
if manifest != nil {
|
|
manifest.Files = append(manifest.Files, path)
|
|
}
|
|
return nil
|
|
}
|
|
return fmt.Errorf("write task context marker: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// projectResourceFile is the on-disk JSON written into the agent's working
|
|
// directory. Schema is intentionally a thin pass-through of the API response
|
|
// so consumers (skills, future tooling) don't need a separate parser.
|
|
type projectResourceFile struct {
|
|
ProjectID string `json:"project_id,omitempty"`
|
|
ProjectTitle string `json:"project_title,omitempty"`
|
|
ProjectDescription string `json:"project_description,omitempty"`
|
|
Resources []ProjectResourceForEnv `json:"resources"`
|
|
}
|
|
|
|
// MarshalJSON renders the resource_ref field as raw JSON instead of a base64
|
|
// blob. The struct's other fields are simple strings.
|
|
func (p ProjectResourceForEnv) MarshalJSON() ([]byte, error) {
|
|
type alias struct {
|
|
ID string `json:"id"`
|
|
ResourceType string `json:"resource_type"`
|
|
ResourceRef json.RawMessage `json:"resource_ref"`
|
|
Label string `json:"label,omitempty"`
|
|
}
|
|
ref := p.ResourceRef
|
|
if len(ref) == 0 {
|
|
ref = json.RawMessage("{}")
|
|
}
|
|
return json.Marshal(alias{
|
|
ID: p.ID,
|
|
ResourceType: p.ResourceType,
|
|
ResourceRef: ref,
|
|
Label: p.Label,
|
|
})
|
|
}
|
|
|
|
// writeProjectResources writes .multica/project/resources.json into the
|
|
// working directory when the task carries project context. The file is
|
|
// always written when a project is attached (even with zero resources) so
|
|
// agents can rely on its presence as a signal that a project exists.
|
|
//
|
|
// manifest, when non-nil, is populated with the .multica/project chain
|
|
// of created directories and the resources.json file so CleanupSidecars
|
|
// can undo them on local_directory teardown.
|
|
func writeProjectResources(workDir string, ctx TaskContextForEnv, manifest *sidecarManifest) error {
|
|
if ctx.ProjectID == "" && len(ctx.ProjectResources) == 0 {
|
|
return nil
|
|
}
|
|
dir := filepath.Join(workDir, ".multica", "project")
|
|
if err := recordMkdirAll(dir, 0o755, manifest); err != nil {
|
|
return err
|
|
}
|
|
resources := ctx.ProjectResources
|
|
if resources == nil {
|
|
resources = []ProjectResourceForEnv{}
|
|
}
|
|
payload := projectResourceFile{
|
|
ProjectID: ctx.ProjectID,
|
|
ProjectTitle: ctx.ProjectTitle,
|
|
ProjectDescription: ctx.ProjectDescription,
|
|
Resources: resources,
|
|
}
|
|
data, err := json.MarshalIndent(payload, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := recordWriteFile(filepath.Join(dir, "resources.json"), data, 0o644, manifest); err != nil {
|
|
// .multica/project/resources.json is Multica-owned and a
|
|
// pre-existing path is almost certainly user content the
|
|
// manifest must not destroy. The runtime brief already lists
|
|
// every project resource so the agent runs fine without the
|
|
// JSON sidecar — collision degrades to brief-only mode.
|
|
if !errors.Is(err, errPathPreExists) {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resolveSkillsDir returns the directory where skills should be written
|
|
// based on the agent provider, creating it. manifest, when non-nil, is
|
|
// populated with every intermediate directory we had to MkdirAll so
|
|
// CleanupSidecars can rmdir them on local_directory teardown.
|
|
func resolveSkillsDir(workDir, provider string, manifest *sidecarManifest) (string, error) {
|
|
skillsDir := skillsDirPath(workDir, provider)
|
|
if err := recordMkdirAll(skillsDir, 0o755, manifest); err != nil {
|
|
return "", err
|
|
}
|
|
return skillsDir, nil
|
|
}
|
|
|
|
// skillsDirPath returns the provider-native skills parent directory under
|
|
// workDir WITHOUT creating it or recording anything. resolveSkillsDir wraps
|
|
// this with the MkdirAll/manifest bookkeeping; the reuse-path skill rollback
|
|
// (removeReusedManagedSkillDirs) needs the bare path with no side effects so
|
|
// it can match the managed skill roots the prior manifest recorded.
|
|
func skillsDirPath(workDir, provider string) string {
|
|
switch provider {
|
|
case "claude":
|
|
// Claude Code natively discovers skills from .claude/skills/ in the workdir.
|
|
return filepath.Join(workDir, ".claude", "skills")
|
|
case "codebuddy":
|
|
// CodeBuddy Code is a Claude Code fork but uses its own native
|
|
// project-level skill directory .codebuddy/skills/, not
|
|
// .claude/skills/. See https://www.codebuddy.ai/docs/cli/skills.
|
|
return filepath.Join(workDir, ".codebuddy", "skills")
|
|
case "copilot":
|
|
// GitHub Copilot CLI natively discovers project-level skills from
|
|
// .github/skills/<name>/SKILL.md (takes precedence over user-level
|
|
// skills in ~/.copilot/skills/).
|
|
// See: https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference
|
|
return filepath.Join(workDir, ".github", "skills")
|
|
case "opencode":
|
|
// OpenCode natively discovers project skills from .opencode/skills/ in
|
|
// the workdir. ConfigPaths.directories() walks up from the discovery
|
|
// root looking for a bare `.opencode` directory (no opencode.json
|
|
// signal required), then skill/index.ts scans `{skill,skills}/**/SKILL.md`
|
|
// under each match. Discovery is anchored at the task workdir via
|
|
// `opencode run --dir <workDir>` + PWD override in opencodeBackend —
|
|
// without those, OpenCode walks from the daemon's inherited PWD and
|
|
// misses .opencode/skills + AGENTS.md entirely (MUL-2416).
|
|
return filepath.Join(workDir, ".opencode", "skills")
|
|
case "deveco":
|
|
// DevEco Code (Huawei's OpenCode fork) natively discovers project
|
|
// skills from .deveco/skills/ in the workdir, mirroring OpenCode's
|
|
// .opencode/skills layout under its DEVECO_-prefixed brand. Discovery
|
|
// is anchored at the task workdir via `deveco run --dir <workDir>` +
|
|
// the PWD override in devecoBackend, same anchor OpenCode uses.
|
|
return filepath.Join(workDir, ".deveco", "skills")
|
|
case "openclaw":
|
|
// OpenClaw's native skill scanner reads <workspaceDir>/skills/. The
|
|
// daemon pairs this with a per-task synthesized openclaw-config.json
|
|
// (see openclaw_config.go) that pins agents.defaults.workspace to
|
|
// workDir, so writing here is what the CLI actually scans. Before
|
|
// MUL-2219 this used to fall back to .agent_context/skills/, which
|
|
// no openclaw scan path ever inspected.
|
|
return filepath.Join(workDir, "skills")
|
|
case "pi":
|
|
// Pi natively discovers skills from .pi/skills/ in the workdir.
|
|
return filepath.Join(workDir, ".pi", "skills")
|
|
case "cursor":
|
|
// Cursor natively discovers skills from .cursor/skills/ in the workdir.
|
|
return filepath.Join(workDir, ".cursor", "skills")
|
|
case "kimi":
|
|
// Kimi Code CLI auto-discovers project-level skills from .kimi/skills/
|
|
// in the workdir. See https://moonshotai.github.io/kimi-cli/en/customization/skills.html
|
|
return filepath.Join(workDir, ".kimi", "skills")
|
|
case "kiro":
|
|
// Kiro CLI auto-discovers project-level skills from .kiro/skills/
|
|
// in the workdir.
|
|
return filepath.Join(workDir, ".kiro", "skills")
|
|
case "qoder":
|
|
// Qoder CLI discovers project-level skills under .qoder/skills/.
|
|
// See https://docs.qoder.com/cli/Skills.md
|
|
return filepath.Join(workDir, ".qoder", "skills")
|
|
case "qwen":
|
|
// Qwen Code discovers project-level skills from .qwen/skills/ in the workdir.
|
|
return filepath.Join(workDir, ".qwen", "skills")
|
|
case "traecli":
|
|
// Official TRAE CLI discovers project-level skills from .traecli/skills/
|
|
// in the workdir (global skills live in ~/.traecli/skills). See
|
|
// https://docs.trae.cn/cli_skills
|
|
return filepath.Join(workDir, ".traecli", "skills")
|
|
case "antigravity":
|
|
// Antigravity (`agy`) auto-discovers workspace-level skills from
|
|
// .agents/skills/ in the workdir. The CLI inherits Gemini CLI's
|
|
// workspace skill layout; see https://antigravity.google/docs/gcli-migration
|
|
// under "Workspace skills".
|
|
return filepath.Join(workDir, ".agents", "skills")
|
|
case "grok":
|
|
// Grok Build CLI discovers project-level skills from .grok/skills/
|
|
// (and also scans .agents/skills/). Prefer the native .grok tree.
|
|
// See Grok user-guide skills.md.
|
|
return filepath.Join(workDir, ".grok", "skills")
|
|
default:
|
|
// Fallback: write to .agent_context/skills/ (referenced by meta config).
|
|
return filepath.Join(workDir, ".agent_context", "skills")
|
|
}
|
|
}
|
|
|
|
var nonAlphaNum = regexp.MustCompile(`[^a-z0-9]+`)
|
|
|
|
// ensureSkillFrontmatter returns SKILL.md content guaranteed to lead with a
|
|
// YAML frontmatter block carrying a parseable, non-empty `name` key.
|
|
//
|
|
// Runtimes like OpenCode silently drop SKILL.md whose frontmatter is missing
|
|
// or whose `name` doesn't parse, so we handle three cases:
|
|
//
|
|
// - No frontmatter at all → synthesize one with `name: <slug>` (and the DB
|
|
// description when available).
|
|
// - Frontmatter present, has a non-empty `name`, AND parses as valid YAML →
|
|
// rewrite `name` to the slug and keep every other key verbatim. See below
|
|
// for why `name` specifically is not left alone.
|
|
// - Frontmatter present and has a non-empty `name` but YAML is invalid (e.g.
|
|
// unquoted colon in description) → strip and re-synthesize so runtimes like
|
|
// Codex don't discard the skill on parse errors.
|
|
// - Frontmatter present but missing `name` (e.g. an upstream skill whose
|
|
// YAML only set `description`, with the directory slug filling in for
|
|
// `name` at import time) → prepend `name: <slug>` as the first key of
|
|
// the existing block so OpenCode can still route the skill.
|
|
//
|
|
// `name` is the one key Multica must own. Runtimes disagree on which field
|
|
// identifies a skill — Claude routes on the directory name, OpenCode on the
|
|
// frontmatter `name` — so letting the two diverge gives a single skill two
|
|
// different invocable names depending on where it runs (MUL-5529). The slug is
|
|
// authoritative because it is what lands on disk and the only value carrying a
|
|
// uniqueness guarantee (allocateCollisionFreeSkillDir); a frontmatter `name` is
|
|
// author-supplied and two imported skills may both claim the same one. Every
|
|
// other key stays byte-identical, so deliberately shaped upstream frontmatter
|
|
// still survives the round-trip.
|
|
func ensureSkillFrontmatter(content, slug, description string) string {
|
|
fmStart, ok := frontmatterBodyStart(content)
|
|
if !ok {
|
|
return synthesizeFrontmatter(content, slug, description)
|
|
}
|
|
if isFrontmatterValidYAML(content) {
|
|
// The parser, not the spelling, decides whether a name exists. A
|
|
// lexical scan only recognizes a bare `name:` with a value on the same
|
|
// line, so `"name": x` and a valueless `name:` read as nameless and
|
|
// earn an injected second `name` — a duplicate mapping key that strict
|
|
// loaders reject outright, leaving the skill unloadable rather than
|
|
// merely misnamed.
|
|
if frontmatterHasNameKey(content) {
|
|
if rewritten, verified := setFrontmatterName(content, fmStart, slug); verified {
|
|
return rewritten
|
|
}
|
|
// The surgical rewrite could not be proven correct — an anchor on
|
|
// the name value is one way that happens, since replacing the line
|
|
// removes the anchor and strands any alias pointing at it. The
|
|
// block itself is still valid YAML, so rebuild it from the parsed
|
|
// node instead of re-synthesizing: that keeps every other key,
|
|
// including policy fields like disable-model-invocation whose loss
|
|
// would re-expose a skill the author hid from model invocation.
|
|
if rebuilt, ok := renameFrontmatterNameViaNode(content, slug); ok {
|
|
return rebuilt
|
|
}
|
|
_, body, _ := frontmatterParts(content)
|
|
return synthesizeFrontmatter(body, slug, description)
|
|
}
|
|
// No name key at all, confirmed by the parser rather than inferred.
|
|
// Inject one as the first key and keep the rest verbatim (including
|
|
// `description`, body, and any runtime-specific keys the import path
|
|
// preserved).
|
|
return content[:fmStart] + "name: " + slug + "\n" + content[fmStart:]
|
|
}
|
|
|
|
// Invalid YAML: there is no parse to consult, so fall back to the lexical
|
|
// scan. A block with a name is stripped and re-synthesized so runtimes like
|
|
// Codex don't hard-reject the whole skill at load time; frontmatterParts
|
|
// returns the full content as the body when it can't find a closing
|
|
// delimiter, so the malformed block is kept rather than silently dropped.
|
|
if hasFrontmatterName(content[fmStart:]) {
|
|
_, body, _ := frontmatterParts(content)
|
|
return synthesizeFrontmatter(body, slug, description)
|
|
}
|
|
return content[:fmStart] + "name: " + slug + "\n" + content[fmStart:]
|
|
}
|
|
|
|
// frontmatterHasNameKey reports whether content's frontmatter parses as a
|
|
// mapping carrying a top-level `name` key, whatever its spelling or value.
|
|
//
|
|
// Quoting is syntax, not identity: `"name":` and `name:` are the same key, and
|
|
// a key with an empty value is still the key. Only nesting makes a difference —
|
|
// `metadata:\n name: x` has no top-level name, so one still has to be added.
|
|
func frontmatterHasNameKey(content string) bool {
|
|
fmBody, _, ok := frontmatterParts(content)
|
|
if !ok {
|
|
return false
|
|
}
|
|
var doc yaml.Node
|
|
if err := yaml.Unmarshal([]byte(fmBody), &doc); err != nil {
|
|
return false
|
|
}
|
|
if len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode {
|
|
return false
|
|
}
|
|
mapping := doc.Content[0]
|
|
for i := 0; i+1 < len(mapping.Content); i += 2 {
|
|
if mapping.Content[i].Value == "name" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// synthesizeFrontmatter produces a SKILL.md body with a YAML frontmatter block
|
|
// carrying at least `name` and (when non-empty) `description`. The description
|
|
// is always escaped as a double-quoted YAML string so values containing colons,
|
|
// brackets, or other YAML-significant characters parse safely.
|
|
func synthesizeFrontmatter(body, slug, description string) string {
|
|
var b strings.Builder
|
|
b.WriteString("---\n")
|
|
fmt.Fprintf(&b, "name: %s\n", slug)
|
|
if d := strings.TrimSpace(description); d != "" {
|
|
fmt.Fprintf(&b, "description: %s\n", yamlEscapeInline(d))
|
|
}
|
|
b.WriteString("---\n\n")
|
|
b.WriteString(body)
|
|
return b.String()
|
|
}
|
|
|
|
// isFrontmatterValidYAML reports whether the opening YAML frontmatter block of
|
|
// content parses as a YAML mapping. Returns false when there is no frontmatter,
|
|
// the block has no closing delimiter, is empty, or unmarshalling fails.
|
|
func isFrontmatterValidYAML(content string) bool {
|
|
fmBody, _, ok := frontmatterParts(content)
|
|
if !ok || strings.TrimSpace(fmBody) == "" {
|
|
return false
|
|
}
|
|
var m map[string]any
|
|
return yaml.Unmarshal([]byte(fmBody), &m) == nil
|
|
}
|
|
|
|
// frontmatterParts splits content into the raw YAML frontmatter body (the text
|
|
// between the opening `---` line and the closing `---` line) and the document
|
|
// body that follows the closing delimiter. ok is false when content has no
|
|
// opening delimiter or no closing delimiter line; in that case body is the full
|
|
// content so callers can keep a malformed block instead of dropping it.
|
|
//
|
|
// A closing delimiter is a line whose only content is `---`, terminated by
|
|
// `\n`, `\r\n`, or end-of-file. Centralizing the rule here keeps the validity
|
|
// check and the re-synthesis path from disagreeing on where a block ends (e.g.
|
|
// for EOF- or CRLF-terminated frontmatter), which previously left a stale block
|
|
// behind when the two definitions diverged.
|
|
func frontmatterParts(content string) (fmBody, body string, ok bool) {
|
|
start, ok := frontmatterBodyStart(content)
|
|
if !ok {
|
|
return "", content, false
|
|
}
|
|
rest := content[start:]
|
|
for searchFrom := 0; ; {
|
|
nl := strings.Index(rest[searchFrom:], "\n---")
|
|
if nl < 0 {
|
|
return "", content, false
|
|
}
|
|
closeAt := searchFrom + nl
|
|
after := rest[closeAt+len("\n---"):]
|
|
switch {
|
|
case after == "" || after == "\r":
|
|
return rest[:closeAt], "", true
|
|
case strings.HasPrefix(after, "\n"):
|
|
return rest[:closeAt], after[len("\n"):], true
|
|
case strings.HasPrefix(after, "\r\n"):
|
|
return rest[:closeAt], after[len("\r\n"):], true
|
|
default:
|
|
// Not a standalone delimiter line (e.g. "----" or "--- text");
|
|
// keep scanning for the real close.
|
|
searchFrom = closeAt + len("\n---")
|
|
}
|
|
}
|
|
}
|
|
|
|
// frontmatterBodyStart returns the byte offset where the YAML body begins
|
|
// (just after the opening `---` line) and whether a valid opening delimiter
|
|
// was found.
|
|
func frontmatterBodyStart(content string) (int, bool) {
|
|
if strings.HasPrefix(content, "---\n") {
|
|
return 4, true
|
|
}
|
|
if strings.HasPrefix(content, "---\r\n") {
|
|
return 5, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// hasFrontmatterName reports whether the frontmatter body (the slice starting
|
|
// just after the opening `---` line) contains a top-level `name` key before
|
|
// the closing `---`, whatever its spelling or value.
|
|
//
|
|
// This is the malformed-block twin of frontmatterHasNameKey: it answers the
|
|
// same question for blocks that do not parse, where the caller is choosing
|
|
// between re-synthesizing (name present) and injecting a fresh `name` (name
|
|
// absent). The invariant is key presence, not value presence — `"name":`,
|
|
// `'name':`, and a valueless `name:` are all the key, and injecting a second
|
|
// one above any of them yields a duplicate mapping key that strict loaders
|
|
// reject, leaving the skill unloadable rather than healed (MUL-5529). Reading
|
|
// a plain scalar like `name:value` as the key over-detects, and deliberately
|
|
// so: the block is already unparseable, and re-synthesis is the route that
|
|
// repairs it.
|
|
//
|
|
// Only unindented keys count. An indented `name:` belongs to a nested mapping
|
|
// (`metadata:\n name: foo`), so the block still has no top-level name.
|
|
func hasFrontmatterName(fmBody string) bool {
|
|
closeIdx := strings.Index(fmBody, "\n---")
|
|
if closeIdx < 0 {
|
|
// Missing close — scan everything we have. The frontmatter is
|
|
// malformed and strict runtimes reject it anyway, but detecting an
|
|
// existing name keeps us from layering a second one on top.
|
|
closeIdx = len(fmBody)
|
|
}
|
|
for _, line := range strings.Split(fmBody[:closeIdx], "\n") {
|
|
line = strings.TrimSuffix(line, "\r")
|
|
if strings.HasPrefix(line, "name:") ||
|
|
strings.HasPrefix(line, `"name":`) ||
|
|
strings.HasPrefix(line, `'name':`) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// frontmatterNameValueSpan returns the byte range of the whole top-level `name`
|
|
// entry — key plus value, however many lines the value occupies — inside a
|
|
// frontmatter body that is already known to be valid YAML.
|
|
//
|
|
// The extent comes from yaml.v3's own line numbers rather than from
|
|
// indentation, because indentation does not bound a YAML value. A quoted scalar
|
|
// may wrap onto a line at the *same* indentation as its key, and so may a flow
|
|
// collection:
|
|
//
|
|
// name: "upstream
|
|
// continued"
|
|
// name: [a,
|
|
// b]
|
|
//
|
|
// Both parse; an indentation rule stops at the key's line and leaves the
|
|
// remainder stranded, turning a rewrite into invalid YAML. Asking the parser
|
|
// where the next top-level key starts is the only reliable boundary.
|
|
func frontmatterNameValueSpan(fmBody string) (start, end int, ok bool) {
|
|
closeIdx := strings.Index(fmBody, "\n---")
|
|
if closeIdx < 0 {
|
|
closeIdx = len(fmBody)
|
|
}
|
|
block := fmBody[:closeIdx]
|
|
|
|
var doc yaml.Node
|
|
if err := yaml.Unmarshal([]byte(block), &doc); err != nil {
|
|
return 0, 0, false
|
|
}
|
|
if len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode {
|
|
return 0, 0, false
|
|
}
|
|
mapping := doc.Content[0]
|
|
|
|
// yaml.Node lines are 1-based.
|
|
nameLine, nextKeyLine := 0, 0
|
|
for i := 0; i+1 < len(mapping.Content); i += 2 {
|
|
key := mapping.Content[i]
|
|
if key.Value != "name" {
|
|
continue
|
|
}
|
|
nameLine = key.Line
|
|
if i+2 < len(mapping.Content) {
|
|
nextKeyLine = mapping.Content[i+2].Line
|
|
}
|
|
break
|
|
}
|
|
if nameLine == 0 {
|
|
return 0, 0, false
|
|
}
|
|
|
|
lines := strings.Split(block, "\n")
|
|
lastLine := len(lines)
|
|
if nextKeyLine > 0 {
|
|
lastLine = nextKeyLine - 1
|
|
}
|
|
if lastLine > len(lines) {
|
|
lastLine = len(lines)
|
|
}
|
|
// Blank lines and unindented comments sitting between this entry and the
|
|
// next key are not part of the value: block scalar content must be
|
|
// indented, so an unindented `#` can only be a comment. Leave them in
|
|
// place instead of swallowing them into the replacement.
|
|
for lastLine > nameLine {
|
|
line := strings.TrimSuffix(lines[lastLine-1], "\r")
|
|
if strings.TrimSpace(line) == "" || strings.HasPrefix(line, "#") {
|
|
lastLine--
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
if lastLine < nameLine {
|
|
return 0, 0, false
|
|
}
|
|
|
|
offset := 0
|
|
for i, line := range lines {
|
|
if i == nameLine-1 {
|
|
start = offset
|
|
}
|
|
if i == lastLine-1 {
|
|
return start, offset + len(line), true
|
|
}
|
|
offset += len(line) + 1 // +1 for the newline Split consumed
|
|
}
|
|
return 0, 0, false
|
|
}
|
|
|
|
// setFrontmatterName replaces the top-level frontmatter `name` entry — key and
|
|
// full value, however many lines it spans — with a single-line `name: <slug>`,
|
|
// leaving every other byte of content untouched. fmStart is the offset where
|
|
// the YAML body begins (see frontmatterBodyStart).
|
|
//
|
|
// verified reports that the result was re-parsed and its `name` really is slug.
|
|
// Callers must treat false as "do not use this output": the directory ==
|
|
// frontmatter-name invariant is the entire reason this function exists, so a
|
|
// rewrite that cannot prove it is worse than no rewrite at all.
|
|
func setFrontmatterName(content string, fmStart int, slug string) (result string, verified bool) {
|
|
fmBody := content[fmStart:]
|
|
start, end, ok := frontmatterNameValueSpan(fmBody)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
replacement := "name: " + slug
|
|
// strings.Split on "\n" leaves the "\r" of a CRLF ending inside the line;
|
|
// carry it over so the block doesn't end up with mixed terminators.
|
|
if strings.HasSuffix(fmBody[start:end], "\r") {
|
|
replacement += "\r"
|
|
}
|
|
rewritten := content[:fmStart+start] + replacement + content[fmStart+end:]
|
|
if !frontmatterNameIs(rewritten, slug) {
|
|
return "", false
|
|
}
|
|
return rewritten, true
|
|
}
|
|
|
|
// renameFrontmatterNameViaNode rebuilds the frontmatter block from its parsed
|
|
// YAML node with `name` set to slug, and returns the reassembled document.
|
|
//
|
|
// This is the middle ground between the surgical byte rewrite and full
|
|
// re-synthesis. It gives up the original block's exact formatting — the
|
|
// re-marshal normalizes quoting and indentation — but keeps every key and its
|
|
// value, which re-synthesis does not. That difference matters beyond tidiness:
|
|
// synthesizeFrontmatter emits only name and description, so a block carrying
|
|
// `disable-model-invocation: true` would come back out without it and the
|
|
// skill, once on disk, would be advertised by the runtime's native discovery
|
|
// despite the author having hidden it.
|
|
//
|
|
// An anchor on the name value needs care in both directions. Simply dropping it
|
|
// strands any `*alias` that referenced it and invalidates the document. Simply
|
|
// keeping it is worse: every alias then resolves to the slug, so a document
|
|
// that expressed a setting by reusing the name's value silently acquires a
|
|
// different setting. `disable-model-invocation: *shared` against
|
|
// `name: &shared "true"` flips from true to false — the same skill-exposing
|
|
// regression as dropping the key outright, just by another route.
|
|
//
|
|
// So aliases are materialized to the value they resolved to *before* the
|
|
// rename, and the anchor is then removed as unreferenced. Every field keeps the
|
|
// value it had; only `name` changes.
|
|
func renameFrontmatterNameViaNode(content, slug string) (string, bool) {
|
|
fmBody, body, ok := frontmatterParts(content)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
var doc yaml.Node
|
|
if err := yaml.Unmarshal([]byte(fmBody), &doc); err != nil {
|
|
return "", false
|
|
}
|
|
if len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode {
|
|
return "", false
|
|
}
|
|
|
|
mapping := doc.Content[0]
|
|
var value *yaml.Node
|
|
for i := 0; i+1 < len(mapping.Content); i += 2 {
|
|
if mapping.Content[i].Value == "name" {
|
|
value = mapping.Content[i+1]
|
|
break
|
|
}
|
|
}
|
|
if value == nil {
|
|
return "", false
|
|
}
|
|
|
|
// Pin every alias to what it resolves to now, before the rename can change
|
|
// it out from under them. Done first so the clones capture the original
|
|
// value rather than the slug.
|
|
if value.Anchor != "" {
|
|
materializeAliasesOf(&doc, value)
|
|
}
|
|
|
|
// Collapse whatever the value was (scalar, flow sequence, …) to a plain
|
|
// string. The anchor goes with it: nothing references it any more.
|
|
value.Kind = yaml.ScalarNode
|
|
value.Tag = "!!str"
|
|
value.Style = 0
|
|
value.Value = slug
|
|
value.Content = nil
|
|
value.Anchor = ""
|
|
|
|
marshaled, err := yaml.Marshal(&doc)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
rebuilt := "---\n" + string(marshaled) + "---\n" + body
|
|
if !frontmatterNameIs(rebuilt, slug) {
|
|
return "", false
|
|
}
|
|
return rebuilt, true
|
|
}
|
|
|
|
// materializeAliasesOf replaces every alias node under root that points at
|
|
// target with an inline copy of target's current value, so those fields keep
|
|
// that value even after target is rewritten.
|
|
func materializeAliasesOf(root, target *yaml.Node) {
|
|
for _, child := range root.Content {
|
|
if child.Kind == yaml.AliasNode && child.Alias == target {
|
|
// Each alias gets its own copy; sharing one node would make the
|
|
// encoder re-introduce an anchor and alias pair.
|
|
*child = *cloneYAMLNode(target)
|
|
continue
|
|
}
|
|
materializeAliasesOf(child, target)
|
|
}
|
|
}
|
|
|
|
// cloneYAMLNode deep-copies a node, stripping anchor/alias identity so the copy
|
|
// is emitted inline rather than as a reference.
|
|
func cloneYAMLNode(n *yaml.Node) *yaml.Node {
|
|
clone := *n
|
|
clone.Anchor = ""
|
|
clone.Alias = nil
|
|
if len(n.Content) > 0 {
|
|
clone.Content = make([]*yaml.Node, len(n.Content))
|
|
for i, child := range n.Content {
|
|
clone.Content[i] = cloneYAMLNode(child)
|
|
}
|
|
}
|
|
return &clone
|
|
}
|
|
|
|
// frontmatterNameIs reports whether content's frontmatter parses and its
|
|
// top-level `name` is exactly want.
|
|
func frontmatterNameIs(content, want string) bool {
|
|
fmBody, _, ok := frontmatterParts(content)
|
|
if !ok {
|
|
return false
|
|
}
|
|
var m map[string]any
|
|
if err := yaml.Unmarshal([]byte(fmBody), &m); err != nil {
|
|
return false
|
|
}
|
|
name, _ := m["name"].(string)
|
|
return name == want
|
|
}
|
|
|
|
// yamlEscapeInline returns a double-quoted YAML scalar that always parses as
|
|
// a string. Plain scalars are deliberately avoided: values like `[foo]`,
|
|
// `{x: y}`, `false`, `null`, or `2024-01-01` would parse as flow sequences,
|
|
// flow mappings, booleans, nulls, or timestamps under YAML 1.2, and
|
|
// OpenCode's frontmatter check rejects non-string descriptions outright. We
|
|
// flatten newlines (frontmatter values are single-line per key) and escape
|
|
// `\` and `"` so any input is a safe inline string.
|
|
func yamlEscapeInline(s string) string {
|
|
flat := strings.ReplaceAll(s, "\r\n", " ")
|
|
flat = strings.ReplaceAll(flat, "\n", " ")
|
|
flat = strings.ReplaceAll(flat, "\r", " ")
|
|
escaped := strings.ReplaceAll(flat, `\`, `\\`)
|
|
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
|
|
return `"` + escaped + `"`
|
|
}
|
|
|
|
// sanitizeSkillName converts a skill name to a safe directory name.
|
|
func sanitizeSkillName(name string) string {
|
|
s := strings.ToLower(strings.TrimSpace(name))
|
|
s = nonAlphaNum.ReplaceAllString(s, "-")
|
|
s = strings.Trim(s, "-")
|
|
if s == "" {
|
|
s = "skill"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// writeSkillFiles writes skill directories into the given parent directory.
|
|
// Each skill gets its own subdirectory containing SKILL.md and supporting
|
|
// files. manifest, when non-nil, is populated with every newly-created
|
|
// directory and file so CleanupSidecars can remove them on
|
|
// local_directory teardown without touching user-owned skill directories
|
|
// that happen to live alongside ours under the same skills/ parent.
|
|
//
|
|
// When a Multica skill's natural slug collides with a user-installed
|
|
// skill at the same path, we allocate a collision-free sibling slug
|
|
// (e.g. `issue-review-multica`) and write there instead. Provider-native
|
|
// discovery still picks it up because every subdir under skillsDir is a
|
|
// distinct skill; the user's original directory stays bit-for-bit
|
|
// intact. Without this fallback writeSkillFiles would have to either
|
|
// overwrite user bytes (the bug PR #3444 review caught) or skip the
|
|
// skill entirely (which would silently drop a Multica skill the agent
|
|
// expects to see).
|
|
func writeSkillFiles(skillsDir string, skills []SkillContextForEnv, manifest *sidecarManifest) error {
|
|
if err := recordMkdirAll(skillsDir, 0o755, manifest); err != nil {
|
|
return fmt.Errorf("create skills dir: %w", err)
|
|
}
|
|
|
|
// resolveSkillSlugs deduplicates within the batch first, so two skills whose
|
|
// names sanitize alike ("A B" / "A-B") get distinct bases here instead of
|
|
// racing for the same directory. The listings derive from the same function,
|
|
// which is what keeps them naming the directories this loop creates.
|
|
// allocateCollisionFreeSkillDir still runs on top, for collisions against
|
|
// directories we did not write (user-installed skills).
|
|
batchSlugs := resolveSkillSlugs(skills)
|
|
|
|
for i, skill := range skills {
|
|
slug, dir, err := allocateCollisionFreeSkillDir(skillsDir, batchSlugs[i])
|
|
if err != nil {
|
|
return fmt.Errorf("allocate skill dir for %q: %w", skill.Name, err)
|
|
}
|
|
if err := recordMkdirAll(dir, 0o755, manifest); err != nil {
|
|
return err
|
|
}
|
|
|
|
// ensureSkillFrontmatter synthesises a `name:` value when the
|
|
// upstream skill is missing one. Use the chosen slug (which
|
|
// may differ from baseSlug on collision) so the YAML name
|
|
// matches the directory name; runtimes that key on either
|
|
// stay consistent.
|
|
body := ensureSkillFrontmatter(skill.Content, slug, skill.Description)
|
|
if err := recordWriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644, manifest); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write supporting files. The skill directory is collision-
|
|
// free by construction, so a recordWriteFile collision under
|
|
// it would mean the skill's bundled files list two entries
|
|
// at the same path — that's an upstream data bug, not a
|
|
// user-content collision, and we surface it.
|
|
//
|
|
// One common data bug is storing SKILL.md as both the primary
|
|
// content (skill.Content) and as a supporting file. Skip the
|
|
// duplicate so the agent still gets every unique file. The check
|
|
// is canonical (see skillpkg.IsReservedContentPath) so a
|
|
// non-canonical spelling like "./SKILL.md" — which filepath.Join
|
|
// resolves onto the same dir/SKILL.md we just wrote — is caught
|
|
// too, instead of colliding and failing prep with errPathPreExists.
|
|
for _, f := range skill.Files {
|
|
if skillpkg.IsReservedContentPath(f.Path) {
|
|
continue
|
|
}
|
|
fpath := filepath.Join(dir, f.Path)
|
|
if err := recordMkdirAll(filepath.Dir(fpath), 0o755, manifest); err != nil {
|
|
return err
|
|
}
|
|
if err := recordWriteFile(fpath, []byte(f.Content), 0o644, manifest); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// renderIssueContext builds the markdown content for issue_context.md.
|
|
func renderIssueContext(provider string, ctx TaskContextForEnv) string {
|
|
if ctx.AutopilotRunID != "" {
|
|
return renderAutopilotContext(ctx)
|
|
}
|
|
if ctx.QuickCreatePrompt != "" {
|
|
return renderQuickCreateContext(ctx)
|
|
}
|
|
|
|
var b strings.Builder
|
|
|
|
b.WriteString("# Task Assignment\n\n")
|
|
fmt.Fprintf(&b, "**Issue ID:** %s\n\n", ctx.IssueID)
|
|
|
|
if ctx.TriggerCommentID != "" {
|
|
b.WriteString("**Trigger:** Comment Reply\n")
|
|
b.WriteString("**Triggering comment ID:** `" + ctx.TriggerCommentID + "`\n\n")
|
|
} else {
|
|
b.WriteString("**Trigger:** New Assignment\n\n")
|
|
}
|
|
|
|
// Assignment handoff note (MUL-3375): the assigner's scoping instruction for
|
|
// this run. Distinct from a comment — there is no thread to reply to.
|
|
if ctx.HandoffNote != "" {
|
|
b.WriteString("## Handoff Note\n\n")
|
|
b.WriteString("The person who assigned this issue left this instruction for the run. Treat it as scope guidance and follow it before doing anything broader:\n\n")
|
|
fmt.Fprintf(&b, "> %s\n\n", ctx.HandoffNote)
|
|
}
|
|
|
|
b.WriteString("## Quick Start\n\n")
|
|
fmt.Fprintf(&b, "Run `multica issue get %s --output json` to fetch the full issue details.\n\n", ctx.IssueID)
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// renderQuickCreateContext renders issue_context.md for quick-create tasks.
|
|
// This file carries only task data (the user input). Behavioral rules and
|
|
// guardrails live in AGENTS.md (runtime config) and the per-turn prompt to
|
|
// avoid redundancy and conflicting instructions; the skill index lives in the
|
|
// runtime brief like every other kind (MUL-5529).
|
|
func renderQuickCreateContext(ctx TaskContextForEnv) string {
|
|
var b strings.Builder
|
|
b.WriteString("# Quick Create\n\n")
|
|
b.WriteString("**Trigger:** Quick-create modal\n\n")
|
|
b.WriteString("## User input\n\n")
|
|
b.WriteString("> ")
|
|
b.WriteString(ctx.QuickCreatePrompt)
|
|
b.WriteString("\n\n")
|
|
return b.String()
|
|
}
|
|
|
|
func renderAutopilotContext(ctx TaskContextForEnv) string {
|
|
var b strings.Builder
|
|
|
|
b.WriteString("# Autopilot Run\n\n")
|
|
fmt.Fprintf(&b, "**Autopilot run ID:** %s\n\n", ctx.AutopilotRunID)
|
|
if ctx.AutopilotID != "" {
|
|
fmt.Fprintf(&b, "**Autopilot ID:** %s\n\n", ctx.AutopilotID)
|
|
}
|
|
if ctx.AutopilotTitle != "" {
|
|
fmt.Fprintf(&b, "**Title:** %s\n\n", ctx.AutopilotTitle)
|
|
}
|
|
if ctx.AutopilotSource != "" {
|
|
fmt.Fprintf(&b, "**Trigger source:** %s\n\n", ctx.AutopilotSource)
|
|
}
|
|
if ctx.AutopilotTriggerPayload != "" {
|
|
fmt.Fprintf(&b, "## Trigger Payload\n\n```json\n%s\n```\n\n", ctx.AutopilotTriggerPayload)
|
|
}
|
|
|
|
b.WriteString("## Quick Start\n\n")
|
|
b.WriteString("This is a run-only autopilot task with no assigned issue. Do not run `multica issue get` unless the autopilot instructions explicitly ask you to create or update an issue.\n\n")
|
|
if ctx.AutopilotID != "" {
|
|
fmt.Fprintf(&b, "Run `multica autopilot get %s --output json` if you need the full autopilot configuration.\n\n", ctx.AutopilotID)
|
|
}
|
|
if strings.TrimSpace(ctx.AutopilotDescription) != "" {
|
|
b.WriteString("## Autopilot Instructions\n\n")
|
|
b.WriteString(ctx.AutopilotDescription)
|
|
b.WriteString("\n\n")
|
|
}
|
|
|
|
return b.String()
|
|
}
|