Files
multica/server/internal/daemon/execenv/context.go
Bohan Jiang ee4ec3b76d MUL-2784 fix(daemon): cleanup sidecar tree (.agent_context / .multica / provider skills) after local_directory tasks (#3444)
* fix(daemon): cleanup .agent_context / .multica / provider skill sidecars after local_directory tasks (MUL-2784)

PR #3438 (MUL-2753) only restored CLAUDE.md / AGENTS.md / GEMINI.md to
their pre-task bytes; the sidecar tree writeContextFiles seeds
(.agent_context/, .multica/, .claude/skills/, .github/skills/,
.opencode/skills/, skills/, .pi/skills/, .cursor/skills/,
.kimi/skills/, .kiro/skills/, .agents/skills/, fallback
.agent_context/skills/) was explicitly deferred to this follow-up. In
local_directory mode the agent's workdir is the user's repo, so each
task accumulates one more layer of those directories in the user's
tree.

Plan A: track every file/dir Prepare creates inside workDir in a
sidecarManifest written to envRoot/.multica_sidecar_manifest.json
(daemon scratch — never in the user's workdir). On local_directory
teardown CleanupSidecars walks the manifest, removes the recorded
files, then rmdir-iterates the recorded directories in reverse.
Pre-existing files and directories are deliberately NOT recorded, so
a user-installed .claude/skills/my-own-skill/ sibling — or any
unrelated file the user keeps under .claude/, .github/, etc. — is
preserved bit-for-bit. Non-empty rmdir fails ENOTEMPTY and is
silently skipped, which is the signal that the user owns the
directory.

Daemon wiring lives next to the existing CleanupRuntimeConfig defer
in runTask: runtime brief first, sidecars second. Cloud-mode runs
still write a manifest for symmetry but never trigger the cleanup
(the GC loop wipes envRoot wholesale).

Tests (sidecar_manifest_test.go) cover the round-trip invariant per
the issue's acceptance criteria:

- empty workdir → Prepare → Cleanup → empty workdir, byte-exact, for
  every file-based provider (claude, codex, copilot, opencode,
  openclaw, hermes, pi, cursor, kimi, kiro, antigravity, gemini),
- user's .claude/skills/my-own-skill/ (and equivalents per
  provider) survives Cleanup intact,
- unrelated user files under .claude/, .github/, etc. survive,
- three repeated cycles do not accumulate any orphan state,
- project_resources branch (.multica/project/resources.json) is
  also reversible,
- recordWriteFile refuses to record pre-existing files,
- recordMkdirAll refuses to record pre-existing dirs,
- Cleanup is a no-op when the manifest file is missing.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): refuse to overwrite pre-existing sidecar paths; pick collision-free skill slugs (MUL-2784 review)

Addresses PR #3444 review (Elon):

**Must-fix #1**: recordWriteFile used to overwrite pre-existing target
files unconditionally and only skip the manifest record. That destroys
user bytes at write time AND leaves the corrupted contents in place at
cleanup time — the byte-exact contract the issue requires is violated
on both halves. Fixed by making recordWriteFile detect any pre-existing
entry (regular file, symlink, directory) via Lstat and return a
sentinel errPathPreExists without touching the path. The user's bytes
are preserved verbatim.

For per-skill collisions (user's .claude/skills/issue-review/ vs
Multica's "Issue Review"), writeSkillFiles now allocates a
collision-free sibling slug via allocateCollisionFreeSkillDir: first
attempt is the natural slug, then `<base>-multica`,
`<base>-multica-2`, …, bounded at 64 attempts. Provider-native
discovery still picks the skill up (every subdir under skillsParent is
a distinct skill) and the user's path stays bit-for-bit intact.

For Multica-only namespace files (.agent_context/issue_context.md,
.multica/project/resources.json), the writer swallows errPathPreExists
and continues — the runtime brief already carries every fact those
files would, so a collision degrades to brief-only mode rather than
destroying user content.

**Must-fix #2**: Added byte-exact collision matrix tests covering
every file-based provider (claude / codex / copilot / opencode /
openclaw / hermes / pi / cursor / kimi / kiro / antigravity / gemini):

- TestPrepareThenCleanupSidecarsSameSlugCollisionPerProvider: seeds
  user's `<provider>/skills/issue-review/SKILL.md` plus a private
  notes.md sibling, runs Prepare → Inject → Cleanup, asserts
  workdir snapshot is byte-identical to seed.
- TestPrepareThenCleanupSidecarsIssueContextCollisionPerProvider:
  seeds user's `.agent_context/issue_context.md`, asserts round-trip
  preserves it.
- TestPrepareThenCleanupSidecarsProjectResourcesCollisionPerProvider:
  same for `.multica/project/resources.json`.
- TestPrepareThenCleanupSidecarsMultiSkillCollisionFreeAllocation:
  end-to-end check that the Multica skill lands at the
  collision-free sibling and Cleanup removes only the Multica side.
- TestAllocateCollisionFreeSkillDir: directed unit test pinning the
  slug-bumping sequence.
- TestRecordWriteFileRefusesToOverwritePreExistingFile (was
  TestRecordWriteFileSkipsPreExistingFile): flipped to assert the
  user's bytes survive and errPathPreExists is returned.
- TestRecordWriteFileRefusesToOverwriteSymlinkOrDir: covers the
  Lstat path for non-file entries.

**Should-fix**: CleanupSidecars used to swallow ANY non-ENOENT rmdir
error as "user content present," silently dropping real I/O failures
(EACCES, EPERM, EBUSY). Now it re-reads the directory after a failed
rmdir via the new dirHasEntries helper — non-empty → silently skip
(ENOTEMPTY, the intended branch); empty → genuine error, captured
into firstErr and surfaced. Plus directed tests:

- TestCleanupSidecarsSurfacesRealRmdirErrors
- TestDirHasEntries

Local verification:
- go test ./internal/daemon/execenv/... — all green
- go test ./internal/daemon/... — all green
- go vet ./... — clean

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): surface original rmdir error when post-rmdir ReadDir also fails (MUL-2784 review)

Addresses remaining PR #3444 review blocker (Elon): dirHasEntries used
to return true when ReadDir failed with anything other than ENOENT,
which made CleanupSidecars treat every locked / faulted directory as
ENOTEMPTY and silently drop the original rmdir error. The v1 fix from
the previous round closed the EACCES-on-empty-dir branch but missed
the case where the chmod also blocks ReadDir — exactly the failure
mode the review called out.

Helper change: dirHasEntries now returns (hasEntries, ok bool):

  - (false, true)  — dir exists and is empty (or missing, race-safe)
  - (true,  true)  — dir has user content (the ENOTEMPTY branch)
  - (_,     false) — ReadDir failed (EACCES, ENOTDIR, EIO, …); the
                     caller cannot tell ENOTEMPTY from a real error
                     and MUST surface the original rmdir error

CleanupSidecars switches on (ok, hasEntries):

  - !ok               → surface the ORIGINAL rmdir error (not the
                        ReadDir failure — that's diagnostic plumbing
                        and would distract from the root cause)
  - ok && hasEntries  → swallow silently (intended ENOTEMPTY branch;
                        preserve user content)
  - ok && !hasEntries → surface the rmdir error (empty dir + EACCES /
                        EPERM / EBUSY → genuine cleanup failure)

Tests:

  - TestDirHasEntries: extended with a regular-file sub-case (ReadDir
    returns ENOTDIR) asserting (false, false). The v1 helper returned
    (true) here, hiding the bug.
  - TestCleanupSidecarsSwallowsMissingAndNonEmptyDirs: renamed from
    TestCleanupSidecarsSurfacesRealRmdirErrors. The old name claimed
    to test the surfacing path but never actually exercised it.
  - TestCleanupSidecarsSurfacesEACCESOnEmptyRecordedDir: chmod parent
    to 0o555 so rmdir(recorded) fails EACCES while ReadDir(recorded)
    still succeeds (empty). Asserts firstErr is non-nil and references
    both the recorded path and the rmdir branch. Skipped when running
    as root (chmod is bypassed for uid 0).
  - TestCleanupSidecarsSurfacesEACCESWhenReadDirFailsToo: the must-fix
    case — chmod parent 0o555 AND chmod recorded 0o000 so BOTH rmdir
    and ReadDir fail. The surfaced error must be the ORIGINAL rmdir
    failure, not the ReadDir one. Skipped on uid 0.

Local verification:
- go test ./internal/daemon/execenv/... — all green
- go test ./internal/daemon/... — all green
- go vet ./... — clean

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 17:22:47 +08:00

480 lines
19 KiB
Go

package execenv
import (
"encoding/json"
"errors"
"fmt"
"path/filepath"
"regexp"
"strings"
)
// 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)
// Codex: skills → handled separately in Prepare via codex-home
// 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)
// 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 {
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 / GEMINI.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 {
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
}
// 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"`
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,
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. 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) {
var skillsDir string
switch provider {
case "claude":
// Claude Code natively discovers skills from .claude/skills/ in the workdir.
skillsDir = filepath.Join(workDir, ".claude", "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
skillsDir = 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).
skillsDir = filepath.Join(workDir, ".opencode", "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.
skillsDir = filepath.Join(workDir, "skills")
case "pi":
// Pi natively discovers skills from .pi/skills/ in the workdir.
skillsDir = filepath.Join(workDir, ".pi", "skills")
case "cursor":
// Cursor natively discovers skills from .cursor/skills/ in the workdir.
skillsDir = 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
skillsDir = filepath.Join(workDir, ".kimi", "skills")
case "kiro":
// Kiro CLI auto-discovers project-level skills from .kiro/skills/
// in the workdir.
skillsDir = filepath.Join(workDir, ".kiro", "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".
skillsDir = filepath.Join(workDir, ".agents", "skills")
default:
// Fallback: write to .agent_context/skills/ (referenced by meta config).
skillsDir = filepath.Join(workDir, ".agent_context", "skills")
}
if err := recordMkdirAll(skillsDir, 0o755, manifest); err != nil {
return "", err
}
return skillsDir, nil
}
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 and already has a non-empty `name` → leave it
// untouched. The upstream import may have shaped that block deliberately
// to match a specific runtime, and we don't want to clobber it.
// - 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.
func ensureSkillFrontmatter(content, slug, description string) string {
fmStart, ok := frontmatterBodyStart(content)
if !ok {
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(content)
return b.String()
}
if hasFrontmatterName(content[fmStart:]) {
return content
}
// Frontmatter exists but lacks a parseable `name`. Inject one as the
// first key of the existing block 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:]
}
// 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 parseable, non-empty `name:`
// scalar before the closing `---`.
func hasFrontmatterName(fmBody string) bool {
closeIdx := strings.Index(fmBody, "\n---")
if closeIdx < 0 {
// Missing close — scan everything we have and fall through. The
// frontmatter is malformed and OpenCode will 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.TrimSpace(line)
if !strings.HasPrefix(line, "name:") {
continue
}
v := strings.TrimSpace(strings.TrimPrefix(line, "name:"))
v = strings.Trim(v, `"'`)
if v != "" {
return true
}
}
return false
}
// 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)
}
for _, skill := range skills {
baseSlug := sanitizeSkillName(skill.Name)
slug, dir, err := allocateCollisionFreeSkillDir(skillsDir, baseSlug)
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.
for _, f := range skill.Files {
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")
}
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)
if len(ctx.AgentSkills) > 0 {
b.WriteString("## Agent Skills\n\n")
b.WriteString("The following skills are available to you:\n\n")
for _, skill := range ctx.AgentSkills {
fmt.Fprintf(&b, "- **%s**\n", skill.Name)
}
b.WriteString("\n")
}
return b.String()
}
// renderQuickCreateContext renders issue_context.md for quick-create tasks.
// This file carries only task data (user input, skills). Behavioral rules
// and guardrails live in AGENTS.md (runtime config) and the per-turn prompt
// to avoid redundancy and conflicting instructions.
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")
if len(ctx.AgentSkills) > 0 {
b.WriteString("## Agent Skills\n\n")
for _, skill := range ctx.AgentSkills {
fmt.Fprintf(&b, "- **%s**\n", skill.Name)
}
b.WriteString("\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")
}
if len(ctx.AgentSkills) > 0 {
b.WriteString("## Agent Skills\n\n")
b.WriteString("The following skills are available to you:\n\n")
for _, skill := range ctx.AgentSkills {
fmt.Fprintf(&b, "- **%s**\n", skill.Name)
}
b.WriteString("\n")
}
return b.String()
}