From c9063e1b0a4dc61a1029e0a2e19664251ce7f25e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 6 Jul 2026 22:19:52 +0100 Subject: [PATCH] feat: consolidate office system skills (#1604) --- .github/scripts/lint-harness-files.py | 2 + .../lifecycle/executor_sprites_operations.go | 36 +++- .../executor_sprites_operations_test.go | 13 ++ .../runtime/lifecycle/skill/deployer_test.go | 46 +++++ .../agent/runtime/lifecycle/skill/inject.go | 87 +++++++++ .../runtime/lifecycle/skill/inject_test.go | 61 +++++- .../agent/runtime/lifecycle/skill/paths.go | 15 ++ .../runtime/lifecycle/skill/paths_test.go | 33 ++++ .../agent/runtime/lifecycle/skill/types.go | 14 +- .../internal/office/configloader/bundled.go | 38 ++++ .../office/configloader/bundled_test.go | 10 + .../skills/kandev-agent-edit/SKILL.md | 47 ----- .../skills/kandev-approvals/SKILL.md | 4 +- .../skills/kandev-budget/SKILL.md | 41 ----- .../skills/kandev-config-export/SKILL.md | 42 ----- .../skills/kandev-config-import/SKILL.md | 44 ----- .../skills/kandev-config-sync/SKILL.md | 19 ++ .../kandev-config-sync/references/export.md | 7 + .../kandev-config-sync/references/import.md | 9 + .../skills/kandev-escalation/SKILL.md | 4 +- .../skills/kandev-hiring/SKILL.md | 50 ----- .../skills/kandev-protocol/SKILL.md | 11 +- .../skills/kandev-routines/SKILL.md | 2 +- .../skills/kandev-task-comment/SKILL.md | 40 ---- .../skills/kandev-task-ops/SKILL.md | 23 +++ .../kandev-task-ops/references/comments.md | 17 ++ .../kandev-task-ops/references/tasks.md | 33 ++++ .../configloader/skills/kandev-tasks/SKILL.md | 57 ------ .../skills/kandev-team-admin/SKILL.md | 34 ++++ .../references/agent-edit.md | 29 +++ .../kandev-team-admin/references/budget.md | 17 ++ .../kandev-team-admin/references/hiring.md | 27 +++ .../kandev-team-admin/references/team.md | 18 ++ .../configloader/skills/kandev-team/SKILL.md | 35 ---- .../configloader/skills/memory/SKILL.md | 2 +- .../internal/office/skills/runtime_adapter.go | 25 +++ .../office/skills/runtime_adapter_test.go | 38 ++++ .../internal/office/skills/system_sync.go | 174 +++++++++++++++++- .../office/skills/system_sync_parse_test.go | 35 ++++ .../office/skills/system_sync_test.go | 152 +++++++++++++++ .../e2e/tests/office/system-skills.spec.ts | 16 +- .../0031-office-skill-reference-files.md | 27 +++ docs/decisions/INDEX.md | 1 + docs/specs/office/agents.md | 4 +- docs/specs/office/costs.md | 2 +- 45 files changed, 1047 insertions(+), 394 deletions(-) create mode 100644 apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations_test.go create mode 100644 apps/backend/internal/agent/runtime/lifecycle/skill/paths_test.go delete mode 100644 apps/backend/internal/office/configloader/skills/kandev-agent-edit/SKILL.md delete mode 100644 apps/backend/internal/office/configloader/skills/kandev-budget/SKILL.md delete mode 100644 apps/backend/internal/office/configloader/skills/kandev-config-export/SKILL.md delete mode 100644 apps/backend/internal/office/configloader/skills/kandev-config-import/SKILL.md create mode 100644 apps/backend/internal/office/configloader/skills/kandev-config-sync/SKILL.md create mode 100644 apps/backend/internal/office/configloader/skills/kandev-config-sync/references/export.md create mode 100644 apps/backend/internal/office/configloader/skills/kandev-config-sync/references/import.md delete mode 100644 apps/backend/internal/office/configloader/skills/kandev-hiring/SKILL.md delete mode 100644 apps/backend/internal/office/configloader/skills/kandev-task-comment/SKILL.md create mode 100644 apps/backend/internal/office/configloader/skills/kandev-task-ops/SKILL.md create mode 100644 apps/backend/internal/office/configloader/skills/kandev-task-ops/references/comments.md create mode 100644 apps/backend/internal/office/configloader/skills/kandev-task-ops/references/tasks.md delete mode 100644 apps/backend/internal/office/configloader/skills/kandev-tasks/SKILL.md create mode 100644 apps/backend/internal/office/configloader/skills/kandev-team-admin/SKILL.md create mode 100644 apps/backend/internal/office/configloader/skills/kandev-team-admin/references/agent-edit.md create mode 100644 apps/backend/internal/office/configloader/skills/kandev-team-admin/references/budget.md create mode 100644 apps/backend/internal/office/configloader/skills/kandev-team-admin/references/hiring.md create mode 100644 apps/backend/internal/office/configloader/skills/kandev-team-admin/references/team.md delete mode 100644 apps/backend/internal/office/configloader/skills/kandev-team/SKILL.md create mode 100644 apps/backend/internal/office/skills/runtime_adapter_test.go create mode 100644 docs/decisions/0031-office-skill-reference-files.md diff --git a/.github/scripts/lint-harness-files.py b/.github/scripts/lint-harness-files.py index 244255bd06..8264c5afd3 100644 --- a/.github/scripts/lint-harness-files.py +++ b/.github/scripts/lint-harness-files.py @@ -123,6 +123,8 @@ def main() -> int: try: for path in paths: + if not path.exists(): + continue kind = classify_path(path) if kind == "unknown": continue diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go index 2ebc3e139c..9ea0a6593a 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "path" "regexp" "strings" "sync" @@ -86,6 +87,10 @@ func (r *SpritesExecutor) uploadSkillFiles( Skills []struct { Slug string `json:"Slug"` Content string `json:"Content"` + Files []struct { + Path string `json:"Path"` + Content string `json:"Content"` + } `json:"Files"` } `json:"Skills"` Instructions []struct { Filename string `json:"Filename"` @@ -118,11 +123,24 @@ func (r *SpritesExecutor) uploadSkillFiles( if !validSlugRe.MatchString(sk.Slug) { continue } - path := fmt.Sprintf("/workspace/%s/kandev-%s/SKILL.md", projectSkillDir, sk.Slug) - if err := r.writeFileWithRetry(stepCtx, sprite, path, []byte(sk.Content), 0o644); err != nil { + skillRoot := fmt.Sprintf("/workspace/%s/kandev-%s", projectSkillDir, sk.Slug) + skillPath := skillRoot + "/SKILL.md" + if err := r.writeFileWithRetry(stepCtx, sprite, skillPath, []byte(sk.Content), 0o644); err != nil { r.logger.Warn("failed to upload skill file", zap.String("slug", sk.Slug), zap.Error(err)) } + for _, file := range sk.Files { + rel, ok := cleanSpriteSkillFilePath(file.Path) + if !ok { + continue + } + if err := r.writeFileWithRetry(stepCtx, sprite, skillRoot+"/"+rel, []byte(file.Content), 0o644); err != nil { + r.logger.Warn("failed to upload skill support file", + zap.String("slug", sk.Slug), + zap.String("path", rel), + zap.Error(err)) + } + } } r.ensureSpriteGitExclude(stepCtx, sprite, projectSkillDir) @@ -144,6 +162,20 @@ func (r *SpritesExecutor) uploadSkillFiles( return nil } +func cleanSpriteSkillFilePath(p string) (string, bool) { + p = strings.TrimSpace(p) + if p == "" || strings.Contains(p, "\\") { + return "", false + } + cleaned := path.Clean(p) + if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") || + strings.HasPrefix(cleaned, "/") || + cleaned == "SKILL.md" { + return "", false + } + return cleaned, true +} + // cleanSpriteKandevSkills removes any kandev-* directories left over from // a previous session in the sprite's worktree. Best-effort: failures are // logged at debug because a fresh sprite has no skills dir to clean. diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations_test.go new file mode 100644 index 0000000000..b420c4aa94 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations_test.go @@ -0,0 +1,13 @@ +package lifecycle + +import "testing" + +func TestCleanSpriteSkillFilePathRejectsTraversal(t *testing.T) { + for _, input := range []string{"..", "../x", "a/../..", "a/.."} { + t.Run(input, func(t *testing.T) { + if got, ok := cleanSpriteSkillFilePath(input); ok { + t.Fatalf("cleanSpriteSkillFilePath(%q) = %q, true; want reject", input, got) + } + }) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/skill/deployer_test.go b/apps/backend/internal/agent/runtime/lifecycle/skill/deployer_test.go index d0fed96c75..a9320ef999 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/skill/deployer_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/skill/deployer_test.go @@ -108,6 +108,52 @@ func TestDeploy_Local_WritesSkillsAndInstructions(t *testing.T) { } } +// TestDeploy_Local_WritesSkillSupportingFiles verifies progressive +// disclosure files travel with a skill instead of only SKILL.md being +// materialised. +func TestDeploy_Local_WritesSkillSupportingFiles(t *testing.T) { + base := t.TempDir() + worktree := t.TempDir() + reader := &fakeSkillReader{skills: map[string]*skill.Skill{ + "sk-office": { + Slug: "sk-office", + Content: "# office skill", + Files: []skill.SkillFile{ + {Path: "references/tasks.md", Content: "# Tasks\n"}, + {Path: "references/team.md", Content: "# Team\n"}, + }, + }, + }} + d := newDeployer(t, base, reader, &fakeInstructionLister{}) + + _, err := d.Deploy(context.Background(), skill.Request{ + Profile: &settingsmodels.AgentProfile{ + ID: "profile-1", + AgentID: "claude-acp", + SkillIDs: `["sk-office"]`, + }, + ExecutorType: "local_pc", + WorkspacePath: worktree, + }) + if err != nil { + t.Fatalf("Deploy: %v", err) + } + + root := filepath.Join(worktree, ".claude", "skills", "kandev-sk-office") + for path, want := range map[string]string{ + "references/tasks.md": "# Tasks\n", + "references/team.md": "# Team\n", + } { + data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path))) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + if string(data) != want { + t.Errorf("%s = %q, want %q", path, string(data), want) + } + } +} + // TestDeploy_Local_RewritesSiblingRefs verifies relative `./X.md` // references inside instruction file content are rewritten to // absolute paths under the materialised instructions dir, so the diff --git a/apps/backend/internal/agent/runtime/lifecycle/skill/inject.go b/apps/backend/internal/agent/runtime/lifecycle/skill/inject.go index 3b67894334..91061064d7 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/skill/inject.go +++ b/apps/backend/internal/agent/runtime/lifecycle/skill/inject.go @@ -55,6 +55,9 @@ func injectSkills(worktreePath, projectSkillDir string, skills []Skill) error { if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(renderSkillMarkdown(sk)), 0o644); err != nil { return fmt.Errorf("write SKILL.md for %s: %w", sk.Slug, err) } + if err := writeSkillFiles(dir, sk.Files); err != nil { + return fmt.Errorf("write support files for %s: %w", sk.Slug, err) + } } // git-exclude is best-effort: a worktree without .git (tests, fresh // dirs) just means the file won't be created. Never fail injection @@ -63,6 +66,90 @@ func injectSkills(worktreePath, projectSkillDir string, skills []Skill) error { return nil } +func writeSkillFiles(skillDir string, files []SkillFile) error { + absSkillDir, err := filepath.Abs(skillDir) + if err != nil { + return err + } + absSkillDir, err = filepath.EvalSymlinks(absSkillDir) + if err != nil { + return err + } + for _, file := range files { + rel, ok := cleanRelativeSkillFilePath(file.Path) + if !ok { + continue + } + target, ok, err := secureSkillFileDestination(absSkillDir, filepath.FromSlash(rel)) + if err != nil { + return err + } + if !ok { + continue + } + if err := os.WriteFile(target, []byte(file.Content), 0o644); err != nil { + return err + } + } + return nil +} + +func secureSkillFileDestination(absSkillDir, rel string) (string, bool, error) { + target, err := filepath.Abs(filepath.Join(absSkillDir, rel)) + if err != nil { + return "", false, err + } + if !pathStaysInside(absSkillDir, target) { + return "", false, nil + } + parent := filepath.Dir(target) + if !parentChainAllowsSkillWrite(absSkillDir, parent) { + return "", false, nil + } + if err := os.MkdirAll(parent, 0o755); err != nil { + return "", false, err + } + canonParent, err := filepath.EvalSymlinks(parent) + if err != nil { + return "", false, nil + } + final := filepath.Join(canonParent, filepath.Base(target)) + if !pathStaysInside(absSkillDir, final) { + return "", false, nil + } + return final, true, nil +} + +func pathStaysInside(absBase, target string) bool { + rel, err := filepath.Rel(absBase, target) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +func parentChainAllowsSkillWrite(absSkillDir, parent string) bool { + rel, err := filepath.Rel(absSkillDir, parent) + if err != nil { + return false + } + if rel == "." || rel == "" { + return true + } + cur := absSkillDir + for _, part := range strings.Split(rel, string(filepath.Separator)) { + cur = filepath.Join(cur, part) + info, err := os.Lstat(cur) + if err != nil { + return true + } + if info.Mode()&os.ModeSymlink != 0 { + return false + } + } + return true +} + // ensureGitExclude appends "/kandev-*" to the // worktree's .git/info/exclude file so injected skill directories // never appear as dirty files in git status. Idempotent. diff --git a/apps/backend/internal/agent/runtime/lifecycle/skill/inject_test.go b/apps/backend/internal/agent/runtime/lifecycle/skill/inject_test.go index 8d56c4ba89..c865ef1bf5 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/skill/inject_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/skill/inject_test.go @@ -35,22 +35,77 @@ func TestInjectSkills_WritesUnderProjectSkillDir(t *testing.T) { } } +func TestInjectSkills_SkipsSupportFilesUnderSymlinkParent(t *testing.T) { + worktree := t.TempDir() + ensureGit(t, worktree) + outside := t.TempDir() + + skillDir := filepath.Join(worktree, ".agents", "skills", "kandev-code-review") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(skillDir, "references")); err != nil { + t.Fatal(err) + } + + err := writeSkillFiles(skillDir, []SkillFile{{ + Path: "references/escape.md", + Content: "outside", + }}) + if err != nil { + t.Fatalf("writeSkillFiles: %v", err) + } + if _, err := os.Stat(filepath.Join(outside, "escape.md")); !os.IsNotExist(err) { + t.Fatalf("support file escaped through symlink parent: %v", err) + } +} + +func TestInjectSkills_WritesSupportFilesUnderSymlinkedSkillDir(t *testing.T) { + realSkillDir := filepath.Join(t.TempDir(), "real", "kandev-code-review") + if err := os.MkdirAll(realSkillDir, 0o755); err != nil { + t.Fatal(err) + } + linkParent := filepath.Join(t.TempDir(), "links") + if err := os.MkdirAll(linkParent, 0o755); err != nil { + t.Fatal(err) + } + symlinkSkillDir := filepath.Join(linkParent, "kandev-code-review") + if err := os.Symlink(realSkillDir, symlinkSkillDir); err != nil { + t.Fatal(err) + } + + err := writeSkillFiles(symlinkSkillDir, []SkillFile{{ + Path: "references/tasks.md", + Content: "tasks", + }}) + if err != nil { + t.Fatalf("writeSkillFiles: %v", err) + } + got, err := os.ReadFile(filepath.Join(realSkillDir, "references", "tasks.md")) + if err != nil { + t.Fatalf("read support file: %v", err) + } + if string(got) != "tasks" { + t.Fatalf("support file content = %q, want tasks", got) + } +} + func TestInjectSkills_AddsFrontmatterWhenMissing(t *testing.T) { worktree := t.TempDir() ensureGit(t, worktree) if err := injectSkills(worktree, ".agents/skills", []Skill{ - {Slug: "kandev-team", Content: "# Team\n\nUse the team commands."}, + {Slug: "kandev-team-admin", Content: "# Team\n\nUse the team commands."}, }); err != nil { t.Fatalf("injectSkills: %v", err) } - data, err := os.ReadFile(filepath.Join(worktree, ".agents", "skills", "kandev-kandev-team", "SKILL.md")) + data, err := os.ReadFile(filepath.Join(worktree, ".agents", "skills", "kandev-kandev-team-admin", "SKILL.md")) if err != nil { t.Fatalf("read SKILL.md: %v", err) } got := string(data) - if !strings.HasPrefix(got, "---\nname: kandev-team\ndescription: kandev-team\n---\n") { + if !strings.HasPrefix(got, "---\nname: kandev-team-admin\ndescription: kandev-team-admin\n---\n") { t.Fatalf("SKILL.md missing synthesized frontmatter:\n%s", got) } if !strings.Contains(got, "# Team\n\nUse the team commands.") { diff --git a/apps/backend/internal/agent/runtime/lifecycle/skill/paths.go b/apps/backend/internal/agent/runtime/lifecycle/skill/paths.go index 7289a17d10..d8e6d00183 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/skill/paths.go +++ b/apps/backend/internal/agent/runtime/lifecycle/skill/paths.go @@ -30,6 +30,21 @@ func isValidPathComponent(s string) bool { return true } +func cleanRelativeSkillFilePath(p string) (string, bool) { + p = strings.TrimSpace(p) + if p == "" || strings.Contains(p, "\\") { + return "", false + } + cleaned := filepath.ToSlash(filepath.Clean(p)) + if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") { + return "", false + } + if strings.HasPrefix(cleaned, "/") || cleaned == "SKILL.md" { + return "", false + } + return cleaned, true +} + // SpritesRuntimeBase is the on-sprite path where runtime instruction // files are uploaded. Skills no longer live under this tree; they go // directly into the sprite's worktree (/workspace/). diff --git a/apps/backend/internal/agent/runtime/lifecycle/skill/paths_test.go b/apps/backend/internal/agent/runtime/lifecycle/skill/paths_test.go new file mode 100644 index 0000000000..51a2394111 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/skill/paths_test.go @@ -0,0 +1,33 @@ +package skill + +import "testing" + +func TestCleanRelativeSkillFilePath(t *testing.T) { + tests := []struct { + name string + input string + want string + wantAllow bool + }{ + {name: "reject empty", input: ""}, + {name: "reject whitespace", input: " "}, + {name: "reject current dir", input: "."}, + {name: "reject cleaned current dir", input: "a/.."}, + {name: "reject backslash", input: `a\b`}, + {name: "reject parent", input: ".."}, + {name: "reject nested parent", input: "a/../.."}, + {name: "reject sibling", input: "../sibling"}, + {name: "reject absolute", input: "/absolute"}, + {name: "reject skill md", input: "SKILL.md"}, + {name: "allow reference", input: "references/tasks.md", want: "references/tasks.md", wantAllow: true}, + {name: "allow nested", input: "deep/nested/file.md", want: "deep/nested/file.md", wantAllow: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := cleanRelativeSkillFilePath(tt.input) + if ok != tt.wantAllow || got != tt.want { + t.Fatalf("cleanRelativeSkillFilePath(%q) = %q, %v; want %q, %v", tt.input, got, ok, tt.want, tt.wantAllow) + } + }) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/skill/types.go b/apps/backend/internal/agent/runtime/lifecycle/skill/types.go index 3d55555b4d..109739ac6b 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/skill/types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/skill/types.go @@ -15,12 +15,13 @@ package skill import "context" // Skill is the runtime-tier view of a skill record. It carries only -// what the deployer needs to materialise files; the upstream office -// model (with workspace, file inventory, created_at, ...) collapses -// to these three fields. +// what the deployer needs to materialise files; upstream office +// metadata such as workspace IDs and timestamps is resolved before the +// deployer receives the skill. type Skill struct { Slug string Content string + Files []SkillFile // SourceType discriminates how Content was produced. Today the // deployer only writes inline content; other source types are // resolved upstream by the SkillReader before reaching the @@ -28,6 +29,13 @@ type Skill struct { SourceType string } +// SkillFile is a supporting file inside a skill package. Paths are +// validated during delivery and must stay relative to the skill root. +type SkillFile struct { + Path string + Content string +} + // InstructionFile is the runtime-tier view of an agent's instruction // file (e.g. AGENTS.md, HEARTBEAT.md). Offices store these per // agent_profile_id; the deployer writes them to a per-agent diff --git a/apps/backend/internal/office/configloader/bundled.go b/apps/backend/internal/office/configloader/bundled.go index fd26f7a366..50b8c33e7e 100644 --- a/apps/backend/internal/office/configloader/bundled.go +++ b/apps/backend/internal/office/configloader/bundled.go @@ -6,6 +6,7 @@ import ( "io/fs" "os" "path/filepath" + "sort" ) //go:embed skills/* @@ -67,3 +68,40 @@ func BundledSkillSlugs() ([]string, error) { func BundledSkillContent(slug string) ([]byte, error) { return bundledSkills.ReadFile(filepath.ToSlash(filepath.Join("skills", slug, "SKILL.md"))) } + +// BundledSkillFile is a non-SKILL.md file bundled next to a system skill. +type BundledSkillFile struct { + Path string + Content []byte +} + +// BundledSkillFiles returns supporting files embedded under a system skill +// directory. Paths are relative to the skill root and use slash separators. +func BundledSkillFiles(slug string) ([]BundledSkillFile, error) { + if slug == "" { + return nil, fmt.Errorf("empty slug") + } + root := filepath.ToSlash(filepath.Join("skills", slug)) + var files []BundledSkillFile + err := fs.WalkDir(bundledSkills, root, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + rel, relErr := filepath.Rel(root, path) + if relErr != nil { + return relErr + } + rel = filepath.ToSlash(rel) + if rel == "SKILL.md" { + return nil + } + content, readErr := bundledSkills.ReadFile(path) + if readErr != nil { + return readErr + } + files = append(files, BundledSkillFile{Path: rel, Content: content}) + return nil + }) + sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) + return files, err +} diff --git a/apps/backend/internal/office/configloader/bundled_test.go b/apps/backend/internal/office/configloader/bundled_test.go index e36a66a1a3..4605b76716 100644 --- a/apps/backend/internal/office/configloader/bundled_test.go +++ b/apps/backend/internal/office/configloader/bundled_test.go @@ -111,3 +111,13 @@ func TestBundledSkillSlugs(t *testing.T) { } } } + +func TestBundledSkillFilesRejectsEmptySlug(t *testing.T) { + files, err := BundledSkillFiles("") + if err == nil { + t.Fatalf("BundledSkillFiles(\"\") returned nil error") + } + if files != nil { + t.Fatalf("BundledSkillFiles(\"\") returned files: %#v", files) + } +} diff --git a/apps/backend/internal/office/configloader/skills/kandev-agent-edit/SKILL.md b/apps/backend/internal/office/configloader/skills/kandev-agent-edit/SKILL.md deleted file mode 100644 index 4d341207b1..0000000000 --- a/apps/backend/internal/office/configloader/skills/kandev-agent-edit/SKILL.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -name: kandev-agent-edit -description: Edit or remove existing agents — rename, adjust budget, retire -kandev: - system: true - version: "0.42.0" - default_for_roles: [ceo] ---- - -# Agent editing — adjust, rename, retire - -You can edit an existing agent's settings or remove an agent that's no longer needed. - -## Rename or re-icon - -```bash -$KANDEV_CLI kandev agents update --id A-1 --name "Senior Reviewer" -``` - -## Adjust the monthly budget - -```bash -$KANDEV_CLI kandev agents update --id A-1 --budget-monthly-cents 5000 -``` - -Pass `0` to mean "unlimited" (subject to workspace policy). Pass `-1` and the flag is ignored (allows scripted updates with optional fields). - -## Cap concurrent sessions - -```bash -$KANDEV_CLI kandev agents update --id A-1 --max-concurrent-sessions 1 -``` - -## Retire an agent - -```bash -$KANDEV_CLI kandev agents delete --id A-1 -``` - -The backend rejects deletes for agents that are currently `working`. Use `kandev agents list` to confirm `status: idle` first. - -## When to edit vs replace - -- Permission / role mistakes → edit (you cannot change `role` after create — re-hire). -- Budget overrun → edit `--budget-monthly-cents`. -- Skill mismatch → use the Skills page (this CLI does not toggle individual skills). -- Agent never picks up work → delete + re-hire with a different role. diff --git a/apps/backend/internal/office/configloader/skills/kandev-approvals/SKILL.md b/apps/backend/internal/office/configloader/skills/kandev-approvals/SKILL.md index a056956f3d..438b303957 100644 --- a/apps/backend/internal/office/configloader/skills/kandev-approvals/SKILL.md +++ b/apps/backend/internal/office/configloader/skills/kandev-approvals/SKILL.md @@ -1,6 +1,6 @@ --- name: kandev-approvals -description: List pending approvals and decide them (approve or reject) +description: Clear the CEO approval queue when hire requests, budget grants, or other sensitive Office mutations are waiting for approve or reject decisions. kandev: system: true version: "0.42.0" @@ -32,7 +32,7 @@ The note is visible on the approval row and surfaced to the requester on their n For `hire_agent`: - Does the role match the workload? (`agents list` to verify org composition first) -- Is the monthly budget available? (`kandev-budget`) +- Is the monthly budget available? (`kandev-team-admin`) - Is the reason concrete? For `budget_grant`: diff --git a/apps/backend/internal/office/configloader/skills/kandev-budget/SKILL.md b/apps/backend/internal/office/configloader/skills/kandev-budget/SKILL.md deleted file mode 100644 index 3db1cdf0b1..0000000000 --- a/apps/backend/internal/office/configloader/skills/kandev-budget/SKILL.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -name: kandev-budget -description: Check workspace and per-agent spend before expensive operations -kandev: - system: true - version: "0.42.0" - default_for_roles: [ceo] ---- - -# Budget — know the spend before you spend more - -Every model call costs tokens. The CEO is responsible for keeping the workspace within budget. - -## Workspace summary - -```bash -$KANDEV_CLI kandev budget get -``` - -Returns: total `input_tokens`, `output_tokens`, `cached_tokens`, `total_cost_cents` for the workspace. - -## Per-agent slice - -```bash -$KANDEV_CLI kandev budget get --agent-id A-1 -``` - -Returns the same breakdown scoped to a single agent. Useful when one agent burns the budget faster than others. - -## When to check - -- Before hiring (`kandev-hiring`) — confirm there's room for another monthly seat. -- Before approving a `budget_grant` (`kandev-approvals`). -- On a weekly heartbeat — surface spend in your routine output. -- When an agent looks stuck — check whether it's racking up cost without progress (`agent budget high, status working, last comment > 1h old` → investigate). - -## What you can't do here - -- This CLI is read-only. Adjust monthly caps with `agents update --budget-monthly-cents`. -- Workspace-level budget caps are set in the UI under Settings → Costs. -- Don't pause an over-budget agent silently — leave a comment first (`tasks message`), explain why, then `agents update --budget-monthly-cents 0` if needed. diff --git a/apps/backend/internal/office/configloader/skills/kandev-config-export/SKILL.md b/apps/backend/internal/office/configloader/skills/kandev-config-export/SKILL.md deleted file mode 100644 index 0b2be51510..0000000000 --- a/apps/backend/internal/office/configloader/skills/kandev-config-export/SKILL.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -name: kandev-config-export -description: Export the office workspace config to the .kandev/ folder for version control -kandev: - system: true - version: "0.42.0" - default_for_roles: [ceo] ---- - -# Config export — write office state to disk - -Office workspaces persist in SQLite, but the canonical, reviewable representation is the `.kandev/` folder. Export when you need to: - -- Commit a configuration change to git -- Diff the current state against what the team agreed on -- Hand the workspace off to another machine - -## Export everything - -The export action is exposed as an HTTP endpoint that the office config service consumes. There is no dedicated `agentctl kandev config export` command yet — the round-trip happens through the UI's Settings → Config page, or you can invoke the underlying API: - -```bash -$KANDEV_CLI kandev config export --workspace $KANDEV_WORKSPACE_ID -``` - -(Implementation pending: see [office-config spec](../docs/specs/office-config/spec.md). Until shipped, prefer the Settings UI.) - -## What gets exported - -- `workspaces//kandev.yml` — workspace settings -- `workspaces//agents/*.yml` — agent profiles -- `workspaces//projects/*.yml` — projects -- `workspaces//routines/*.yml` — routines + triggers -- `workspaces//skills/*` — user-imported skills - -System skills (`is_system: true`) are **not** exported — they ship with the kandev binary and are re-installed on every kandev release. - -## When to export - -- Before a major refactor of the org structure — keep a checkpoint. -- After provisioning a new workspace via UI — commit the initial state to your repo. -- Periodically, as a manual backup, when you can't trust the DB. diff --git a/apps/backend/internal/office/configloader/skills/kandev-config-import/SKILL.md b/apps/backend/internal/office/configloader/skills/kandev-config-import/SKILL.md deleted file mode 100644 index 6c350181ee..0000000000 --- a/apps/backend/internal/office/configloader/skills/kandev-config-import/SKILL.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -name: kandev-config-import -description: Apply config changes from the .kandev/ folder back into the office DB -kandev: - system: true - version: "0.42.0" - default_for_roles: [ceo] ---- - -# Config import — apply .kandev/ to the office DB - -The mirror of `kandev-config-export`. Use when: - -- A teammate committed a config change to git that should now take effect -- You're seeding a fresh workspace from an existing `.kandev/` folder -- You want to roll back a recent change by reapplying an older commit - -## How it works - -The office service scans `.kandev/workspaces//` and diffs the on-disk YAML against the DB. Any divergence becomes a `pending_import` entry the CEO (or a user) must approve before it lands. - -```bash -$KANDEV_CLI kandev config import --workspace $KANDEV_WORKSPACE_ID -``` - -(Implementation pending: see [office-config spec](../docs/specs/office-config/spec.md). Until shipped, prefer the Settings → Config page.) - -## What can be imported - -- Workspace settings -- Agents (excluding `is_system` flags — those are kandev-owned) -- Projects -- Routines + triggers -- User-imported skills - -## What can't - -- System skills (`is_system: true`) — those flow from the binary on every kandev start. Editing them in `.kandev/` is a no-op. -- Active sessions and run history — not config; not exportable, not importable. -- Approvals — operational state, never in `.kandev/`. - -## Safety - -The import flow always shows a diff first. If you see a destructive change you didn't expect (e.g. "delete agent A-1"), reject the diff and investigate before applying. diff --git a/apps/backend/internal/office/configloader/skills/kandev-config-sync/SKILL.md b/apps/backend/internal/office/configloader/skills/kandev-config-sync/SKILL.md new file mode 100644 index 0000000000..f62e3acc30 --- /dev/null +++ b/apps/backend/internal/office/configloader/skills/kandev-config-sync/SKILL.md @@ -0,0 +1,19 @@ +--- +name: kandev-config-sync +description: Synchronize Office workspace configuration with the .kandev folder when exporting reviewable config, importing committed config, seeding a workspace, or reviewing a config diff. +kandev: + system: true + version: "0.42.0" + default_for_roles: [ceo] +--- + +# Config Sync + +Use this skill when the Office database and the reviewable `.kandev/` configuration need to be compared or synchronized. + +## Operations + +- Export current workspace state to disk: read `references/export.md`. +- Import `.kandev/` changes back into the Office database: read `references/import.md`. + +System skills are bundled with the kandev binary and are not user-exported configuration. diff --git a/apps/backend/internal/office/configloader/skills/kandev-config-sync/references/export.md b/apps/backend/internal/office/configloader/skills/kandev-config-sync/references/export.md new file mode 100644 index 0000000000..6efbcfd407 --- /dev/null +++ b/apps/backend/internal/office/configloader/skills/kandev-config-sync/references/export.md @@ -0,0 +1,7 @@ +# Config Export + +Export when you need to commit configuration changes, diff current state against the agreed setup, hand a workspace to another machine, or keep a manual checkpoint. + +Exported config includes workspace settings, agent profiles, projects, routines and triggers, and user-imported skills. + +Use the UI at Settings > Config. There is no agentctl `kandev config export` command yet. diff --git a/apps/backend/internal/office/configloader/skills/kandev-config-sync/references/import.md b/apps/backend/internal/office/configloader/skills/kandev-config-sync/references/import.md new file mode 100644 index 0000000000..b642e3568b --- /dev/null +++ b/apps/backend/internal/office/configloader/skills/kandev-config-sync/references/import.md @@ -0,0 +1,9 @@ +# Config Import + +Import when a teammate committed `.kandev/` changes, when seeding a fresh workspace from disk, or when rolling back by applying an older commit. + +The Office service scans `.kandev/workspaces//`, diffs YAML against the database, and presents a pending import diff for approval before changes land. + +Use the UI at Settings > Config. There is no agentctl `kandev config import` command yet. + +Do not import unexpected destructive changes. Reject the diff and investigate first. diff --git a/apps/backend/internal/office/configloader/skills/kandev-escalation/SKILL.md b/apps/backend/internal/office/configloader/skills/kandev-escalation/SKILL.md index 7448ea0202..6f467a3dd1 100644 --- a/apps/backend/internal/office/configloader/skills/kandev-escalation/SKILL.md +++ b/apps/backend/internal/office/configloader/skills/kandev-escalation/SKILL.md @@ -1,6 +1,6 @@ --- name: kandev-escalation -description: How to create a human-actionable decision task when you are blocked +description: Escalate to a human when required information, access, credentials, or a product decision blocks completion and no reasonable default is available. kandev: system: true version: "0.42.0" @@ -57,7 +57,7 @@ $KANDEV_CLI kandev task update --id "$KANDEV_TASK_ID" \ ### Step 3: Post a comment and set blocked status ```bash -$KANDEV_CLI kandev comment add --body "Escalated to human: created task $HUMAN_TASK_ID. Waiting for decision on: " +$KANDEV_CLI kandev tasks message --prompt "Escalated to human: created task $HUMAN_TASK_ID. Waiting for decision on: " $KANDEV_CLI kandev task update --status blocked ``` diff --git a/apps/backend/internal/office/configloader/skills/kandev-hiring/SKILL.md b/apps/backend/internal/office/configloader/skills/kandev-hiring/SKILL.md deleted file mode 100644 index 6d5a7b9f2c..0000000000 --- a/apps/backend/internal/office/configloader/skills/kandev-hiring/SKILL.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -name: kandev-hiring -description: Hire new agents via agentctl, gated by the workspace approval policy -kandev: - system: true - version: "0.42.0" - default_for_roles: [ceo] ---- - -# Hiring — grow the team - -You can hire new agents to expand capacity. Hiring is **gated** by the workspace's approval policy: in most workspaces a new agent enters `pending_approval` status and only becomes `idle` after a human approves the `hire_agent` request. - -## Required inputs - -Before calling the API, decide: -- **Name** — what to call the agent (e.g. `Reviewer`, `Frontend-Worker`) -- **Role** — `worker`, `specialist`, `assistant`, `reviewer` (CEOs hire each of these; you do not hire other CEOs) -- **Reason** — one sentence justifying the hire; surfaced on the approval row - -## Hire a worker - -```bash -$KANDEV_CLI kandev agents create \ - --name "Frontend-Worker" \ - --role worker \ - --reason "Three frontend tasks queued and the existing worker is at capacity" -``` - -The response includes the new agent's `id` and `status`. If `status: pending_approval`, the agent is queued — do **not** assign work to it until it flips to `idle`. - -## Check whether the approval landed - -```bash -$KANDEV_CLI kandev approvals list --status pending -``` - -Or just re-list the team and look at the new agent's status: - -```bash -$KANDEV_CLI kandev agents list -``` - -## When NOT to hire - -- The task you're trying to delegate is one-off — use `agentctl kandev tasks message` to ask an existing agent. -- Budget is already tight (see `kandev-budget`). -- A specialist with matching skills already exists. - -Hiring without a clear reason will be rejected at the approval step. diff --git a/apps/backend/internal/office/configloader/skills/kandev-protocol/SKILL.md b/apps/backend/internal/office/configloader/skills/kandev-protocol/SKILL.md index bfde7570e7..ad29c4538d 100644 --- a/apps/backend/internal/office/configloader/skills/kandev-protocol/SKILL.md +++ b/apps/backend/internal/office/configloader/skills/kandev-protocol/SKILL.md @@ -1,6 +1,6 @@ --- name: kandev-protocol -description: How to interact with the kandev orchestrator via the CLI +description: Follow the core Office agent protocol on wakeup, including parsing KANDEV_* context, checking blockers, commenting progress, updating status, and using the CLI safely. kandev: system: true version: "0.42.0" @@ -85,7 +85,7 @@ If `task.blockedBy` is not empty, post a comment explaining you are blocked and Never work on blocked tasks -- the orchestrator will wake you when blockers clear. ```bash -$KANDEV_CLI kandev comment add --body "Blocked by tasks: KAN-43, KAN-44. Waiting for resolution." +$KANDEV_CLI kandev tasks message --prompt "Blocked by tasks: KAN-43, KAN-44. Waiting for resolution." ``` ### Step 4: Do the work @@ -99,13 +99,13 @@ Always post a comment before changing task status. This creates an audit trail and keeps other agents informed. ```bash -$KANDEV_CLI kandev comment add --body "Implemented OAuth2 login flow with Google provider. Tests pass." +$KANDEV_CLI kandev tasks message --prompt "Implemented OAuth2 login flow with Google provider. Tests pass." ``` For multiline comments, pipe via stdin: ```bash -cat <<'EOF' | $KANDEV_CLI kandev comment add --body - +cat <<'EOF' | $KANDEV_CLI kandev tasks message --prompt - Implementation summary: - Added Google OAuth2 provider with PKCE flow - Wrote integration tests covering token refresh @@ -162,7 +162,8 @@ task create --title T [--parent ID] Create a task or subtask ### comment ``` -comment add [--task ID] --body BODY Post a comment (--body - reads stdin) +tasks message [--id ID] --prompt P Post an agent-authored comment +comment add [--task ID] --body BODY Simulate a user comment for fixtures comment list [--task ID] [--limit N] List comments on a task ``` diff --git a/apps/backend/internal/office/configloader/skills/kandev-routines/SKILL.md b/apps/backend/internal/office/configloader/skills/kandev-routines/SKILL.md index d6df814169..f2d7843173 100644 --- a/apps/backend/internal/office/configloader/skills/kandev-routines/SKILL.md +++ b/apps/backend/internal/office/configloader/skills/kandev-routines/SKILL.md @@ -1,6 +1,6 @@ --- name: kandev-routines -description: Schedule recurring work via cron and webhook triggers +description: Create, inspect, pause, resume, or delete recurring Office routines when work should run on a cron schedule or webhook trigger. kandev: system: true version: "0.42.0" diff --git a/apps/backend/internal/office/configloader/skills/kandev-task-comment/SKILL.md b/apps/backend/internal/office/configloader/skills/kandev-task-comment/SKILL.md deleted file mode 100644 index 51765bd243..0000000000 --- a/apps/backend/internal/office/configloader/skills/kandev-task-comment/SKILL.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: kandev-task-comment -description: Post a comment on any task as the current agent -kandev: - system: true - version: "0.42.0" - default_for_roles: [ceo, worker, specialist, assistant, reviewer] ---- - -# Task comments — communicate without spawning subtasks - -You can drop a comment on any task without creating a new one. Use this when: - -- You need to surface a status update to the assignee -- You spotted a blocker on another agent's task -- You want to acknowledge a comment you just received without taking action yet - -## Comment on your current task - -`$KANDEV_TASK_ID` is auto-filled when omitted: - -```bash -$KANDEV_CLI kandev tasks message --prompt "Got it — starting now." -``` - -## Comment on a different task - -```bash -$KANDEV_CLI kandev tasks message --id T-42 --prompt "Blocked: the worktree has a dirty submodule. Owner: please investigate." -``` - -## Difference vs `comment add` - -`agentctl kandev comment add` writes as the **user**. `tasks message` writes as the **agent** (you). Most office work flows through agent comments — only use `comment add` when you're acting as a test fixture or impersonating user input. - -## What not to do - -- Don't reply with `"Done."` on every heartbeat — only when you actually made progress. -- Don't quote a long block of code in a comment — link to the file path/SHA instead. -- Don't comment to ask a question of the user; use `ask_user_question` (still an MCP tool, available in office mode). diff --git a/apps/backend/internal/office/configloader/skills/kandev-task-ops/SKILL.md b/apps/backend/internal/office/configloader/skills/kandev-task-ops/SKILL.md new file mode 100644 index 0000000000..372208f8c2 --- /dev/null +++ b/apps/backend/internal/office/configloader/skills/kandev-task-ops/SKILL.md @@ -0,0 +1,23 @@ +--- +name: kandev-task-ops +description: Operate Office tasks when you need to list workspace tasks, move or archive a task, read a conversation, or post an agent-authored comment. +kandev: + system: true + version: "0.42.0" + default_for_roles: [ceo, worker, specialist, assistant, reviewer] +--- + +# Task Ops + +Use this skill for task communication and task-level operations outside the singular task status flow in `kandev-protocol`. + +## Choose the operation + +- List, move, archive, or read task conversations: read `references/tasks.md`. +- Post an agent-authored comment to your current task or another task: read `references/comments.md`. + +## Rules + +- Prefer comments over spawning subtasks for small coordination updates. +- Keep comments concise and specific. +- Do not ask the human a decision question via task comment; use the available user-question tool or `kandev-escalation`. diff --git a/apps/backend/internal/office/configloader/skills/kandev-task-ops/references/comments.md b/apps/backend/internal/office/configloader/skills/kandev-task-ops/references/comments.md new file mode 100644 index 0000000000..5246584a46 --- /dev/null +++ b/apps/backend/internal/office/configloader/skills/kandev-task-ops/references/comments.md @@ -0,0 +1,17 @@ +# Agent Comments + +Comment on your current task: + +```bash +$KANDEV_CLI kandev tasks message --prompt "Got it - starting now." +``` + +Comment on another task: + +```bash +$KANDEV_CLI kandev tasks message --id T-42 --prompt "Blocked: the worktree has a dirty submodule. Owner: please investigate." +``` + +`tasks message` writes as the current agent. `comment add` writes as the user, so only use `comment add` when acting as a test fixture or intentionally simulating user input. + +Avoid empty acknowledgements such as `Done.` unless they include useful progress. Link to file paths or commits instead of quoting long code blocks. diff --git a/apps/backend/internal/office/configloader/skills/kandev-task-ops/references/tasks.md b/apps/backend/internal/office/configloader/skills/kandev-task-ops/references/tasks.md new file mode 100644 index 0000000000..61be8ea15c --- /dev/null +++ b/apps/backend/internal/office/configloader/skills/kandev-task-ops/references/tasks.md @@ -0,0 +1,33 @@ +# Workspace Task Operations + +List tasks: + +```bash +$KANDEV_CLI kandev tasks list +$KANDEV_CLI kandev tasks list --status todo +$KANDEV_CLI kandev tasks list --assignee A-1 +$KANDEV_CLI kandev tasks list --project P-12 +``` + +Move between workflow steps: + +```bash +$KANDEV_CLI kandev tasks move --id T-1 --step ws-review +$KANDEV_CLI kandev tasks move --id T-1 --step ws-review --prompt "Please review this PR; gating on QA." +``` + +The optional `--prompt` is queued as handoff context. + +Archive a task: + +```bash +$KANDEV_CLI kandev tasks archive --id T-1 +``` + +Read a task conversation: + +```bash +$KANDEV_CLI kandev tasks conversation --id T-1 +``` + +Use conversation reads sparingly because comments can be long. Prefer the wake payload when it contains the needed context. diff --git a/apps/backend/internal/office/configloader/skills/kandev-tasks/SKILL.md b/apps/backend/internal/office/configloader/skills/kandev-tasks/SKILL.md deleted file mode 100644 index c03298f5c6..0000000000 --- a/apps/backend/internal/office/configloader/skills/kandev-tasks/SKILL.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -name: kandev-tasks -description: List, move, archive, and message tasks via agentctl -kandev: - system: true - version: "0.42.0" - default_for_roles: [ceo, worker, specialist] ---- - -# Tasks — the bulk operations - -The singular `agentctl kandev task` group handles a *single* task (the one you're working on). This plural group handles tasks across the workspace. - -## List - -```bash -# Everything -$KANDEV_CLI kandev tasks list - -# Filtered -$KANDEV_CLI kandev tasks list --status todo -$KANDEV_CLI kandev tasks list --assignee A-1 -$KANDEV_CLI kandev tasks list --project P-12 -``` - -## Move between workflow steps - -```bash -$KANDEV_CLI kandev tasks move --id T-1 --step ws-review -$KANDEV_CLI kandev tasks move --id T-1 --step ws-review --prompt "Please review this PR; gating on QA." -``` - -The optional `--prompt` is queued as the first comment the destination step sees — use it for handoff context. - -## Archive - -```bash -$KANDEV_CLI kandev tasks archive --id T-1 -``` - -Idempotent: archiving an already-archived task is a no-op. - -## Send a message to a task (without claiming it) - -```bash -$KANDEV_CLI kandev tasks message --id T-1 --prompt "Heads up: design changed, see comment #42." -``` - -The message lands as an agent comment on the task. The assignee's next heartbeat will see it. - -## Read the conversation - -```bash -$KANDEV_CLI kandev tasks conversation --id T-1 -``` - -Use sparingly — comments can be long. Prefer the inline batch on comment-driven wakes. diff --git a/apps/backend/internal/office/configloader/skills/kandev-team-admin/SKILL.md b/apps/backend/internal/office/configloader/skills/kandev-team-admin/SKILL.md new file mode 100644 index 0000000000..466c41aad7 --- /dev/null +++ b/apps/backend/internal/office/configloader/skills/kandev-team-admin/SKILL.md @@ -0,0 +1,34 @@ +--- +name: kandev-team-admin +description: Manage the Office roster when delegating work, hiring agents, changing budgets or concurrency, retiring agents, or checking spend before approvals. +kandev: + system: true + version: "0.42.0" + default_for_roles: [ceo] +--- + +# Team Admin + +Use this skill when you need fresh organization state or need to change the agent roster. + +## First checks + +```bash +$KANDEV_CLI kandev agents list +$KANDEV_CLI kandev budget get +``` + +Before hiring, confirm no existing idle specialist or worker already fits. Before approving budget grants, check workspace and per-agent spend. + +## Common actions + +- List and inspect agents: read `references/team.md`. +- Hire a new worker, specialist, assistant, or reviewer: read `references/hiring.md`. +- Rename, cap, or retire an agent: read `references/agent-edit.md`. +- Review workspace or per-agent spend: read `references/budget.md`. + +## Guardrails + +- Do not hire for one-off work that an existing agent can handle. +- Do not assign work to agents in `pending_approval`. +- Do not silently pause or cap an over-budget agent. Leave a task comment explaining the reason first. diff --git a/apps/backend/internal/office/configloader/skills/kandev-team-admin/references/agent-edit.md b/apps/backend/internal/office/configloader/skills/kandev-team-admin/references/agent-edit.md new file mode 100644 index 0000000000..94995c5fb2 --- /dev/null +++ b/apps/backend/internal/office/configloader/skills/kandev-team-admin/references/agent-edit.md @@ -0,0 +1,29 @@ +# Agent Editing + +Rename or re-icon: + +```bash +$KANDEV_CLI kandev agents update --id A-1 --name "Senior Reviewer" +``` + +Adjust monthly budget: + +```bash +$KANDEV_CLI kandev agents update --id A-1 --budget-monthly-cents 5000 +``` + +Pass `0` for unlimited subject to workspace policy. Pass `-1` to ignore the flag in scripted optional updates. + +Cap concurrent sessions: + +```bash +$KANDEV_CLI kandev agents update --id A-1 --max-concurrent-sessions 1 +``` + +Retire an idle agent: + +```bash +$KANDEV_CLI kandev agents delete --id A-1 +``` + +The backend rejects deletes for agents that are currently `working`. You cannot change an agent role after creation; retire and re-hire for role mistakes. diff --git a/apps/backend/internal/office/configloader/skills/kandev-team-admin/references/budget.md b/apps/backend/internal/office/configloader/skills/kandev-team-admin/references/budget.md new file mode 100644 index 0000000000..75c5d8fa15 --- /dev/null +++ b/apps/backend/internal/office/configloader/skills/kandev-team-admin/references/budget.md @@ -0,0 +1,17 @@ +# Budget + +Workspace summary: + +```bash +$KANDEV_CLI kandev budget get +``` + +Per-agent spend: + +```bash +$KANDEV_CLI kandev budget get --agent-id A-1 +``` + +Check spend before hiring, approving a `budget_grant`, weekly reporting, or investigating an agent that is working for a long time without progress. + +This CLI is read-only. Adjust per-agent caps with `$KANDEV_CLI kandev agents update --budget-monthly-cents`. Workspace-level budget caps live in Settings > Costs. diff --git a/apps/backend/internal/office/configloader/skills/kandev-team-admin/references/hiring.md b/apps/backend/internal/office/configloader/skills/kandev-team-admin/references/hiring.md new file mode 100644 index 0000000000..67e9a25c3a --- /dev/null +++ b/apps/backend/internal/office/configloader/skills/kandev-team-admin/references/hiring.md @@ -0,0 +1,27 @@ +# Hiring + +Hiring is gated by workspace approval policy. A new agent usually enters `pending_approval` and becomes `idle` only after approval. + +Required inputs: + +- Name: display name, such as `Frontend-Worker`. +- Role: `worker`, `specialist`, `assistant`, or `reviewer`. +- Reason: one concrete sentence surfaced on the approval row. + +Hire a worker: + +```bash +$KANDEV_CLI kandev agents create \ + --name "Frontend-Worker" \ + --role worker \ + --reason "Three frontend tasks queued and the existing worker is at capacity" +``` + +Check approval state: + +```bash +$KANDEV_CLI kandev approvals list --status pending +$KANDEV_CLI kandev agents list +``` + +Do not hire when the work is one-off, budget is tight, or an existing specialist already matches. diff --git a/apps/backend/internal/office/configloader/skills/kandev-team-admin/references/team.md b/apps/backend/internal/office/configloader/skills/kandev-team-admin/references/team.md new file mode 100644 index 0000000000..8342907022 --- /dev/null +++ b/apps/backend/internal/office/configloader/skills/kandev-team-admin/references/team.md @@ -0,0 +1,18 @@ +# Team Roster + +List the whole organization: + +```bash +$KANDEV_CLI kandev agents list +``` + +Returns every agent in the workspace with `id`, `name`, `role`, `status`, `budget_monthly_cents`, and `desired_skills`. + +Filter by role or status: + +```bash +$KANDEV_CLI kandev agents list --role worker +$KANDEV_CLI kandev agents list --status idle +``` + +Use this before delegation, hiring, or budget adjustments. Do not call it on every heartbeat when the existing wake payload already has enough context. diff --git a/apps/backend/internal/office/configloader/skills/kandev-team/SKILL.md b/apps/backend/internal/office/configloader/skills/kandev-team/SKILL.md deleted file mode 100644 index 1d4ff1dfc5..0000000000 --- a/apps/backend/internal/office/configloader/skills/kandev-team/SKILL.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -name: kandev-team -description: List and inspect agents in the workspace before delegating or hiring -kandev: - system: true - version: "0.42.0" - default_for_roles: [ceo] ---- - -# Team — list and inspect agents - -You manage a team. Before delegating work, check who's available. Before hiring, check whether someone on the roster already fits. - -## List the whole org - -```bash -$KANDEV_CLI kandev agents list -``` - -Returns every agent in the workspace with `id`, `name`, `role`, `status`, `budget_monthly_cents`, and `desired_skills`. - -## Filter by role or status - -```bash -$KANDEV_CLI kandev agents list --role worker -$KANDEV_CLI kandev agents list --status idle -``` - -## When to use - -- Before `agentctl kandev tasks message --id …` — confirm the assignee is `idle`, not `working` on a different task. -- Before `agentctl kandev agents create` — confirm you actually need a new agent (often a `specialist` already exists). -- When budget pressure rises (see `kandev-budget`) — list agents and see which ones could be paused. - -Do not call this on every heartbeat — only when you need fresh org state. The list does not change between routine fires. diff --git a/apps/backend/internal/office/configloader/skills/memory/SKILL.md b/apps/backend/internal/office/configloader/skills/memory/SKILL.md index 59b0127067..b100c54fdd 100644 --- a/apps/backend/internal/office/configloader/skills/memory/SKILL.md +++ b/apps/backend/internal/office/configloader/skills/memory/SKILL.md @@ -1,6 +1,6 @@ --- name: memory -description: Read and write persistent memory entries via agentctl +description: Read, write, and summarize persistent Office memory when durable behavioral guidance or project knowledge should survive across agent sessions. kandev: system: true version: "0.42.0" diff --git a/apps/backend/internal/office/skills/runtime_adapter.go b/apps/backend/internal/office/skills/runtime_adapter.go index 11d297e5ba..5ed5059376 100644 --- a/apps/backend/internal/office/skills/runtime_adapter.go +++ b/apps/backend/internal/office/skills/runtime_adapter.go @@ -7,6 +7,7 @@ package skills import ( "context" + "encoding/json" runtimeskill "github.com/kandev/kandev/internal/agent/runtime/lifecycle/skill" "github.com/kandev/kandev/internal/office/models" @@ -52,10 +53,34 @@ func (a *SkillReaderAdapter) GetSkillFromConfig(ctx context.Context, idOrSlug st return &runtimeskill.Skill{ Slug: sk.Slug, Content: sk.Content, + Files: runtimeSkillFiles(sk.FileInventory), SourceType: string(sk.SourceType), }, nil } +type skillInventoryFile struct { + Path string `json:"path"` + Content string `json:"content,omitempty"` +} + +func runtimeSkillFiles(raw string) []runtimeskill.SkillFile { + var files []skillInventoryFile + if err := json.Unmarshal([]byte(raw), &files); err != nil { + return nil + } + out := make([]runtimeskill.SkillFile, 0, len(files)) + for _, file := range files { + if file.Path == "" || file.Content == "" { + continue + } + out = append(out, runtimeskill.SkillFile{ + Path: file.Path, + Content: file.Content, + }) + } + return out +} + // InstructionListerAdapter bridges an office repo to the runtime-tier // InstructionLister interface used by skill.Deployer. type InstructionListerAdapter struct { diff --git a/apps/backend/internal/office/skills/runtime_adapter_test.go b/apps/backend/internal/office/skills/runtime_adapter_test.go new file mode 100644 index 0000000000..49ff223049 --- /dev/null +++ b/apps/backend/internal/office/skills/runtime_adapter_test.go @@ -0,0 +1,38 @@ +package skills + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/office/models" +) + +type stubOfficeSkillReader struct { + skill *models.Skill +} + +func (s stubOfficeSkillReader) GetSkillFromConfig(context.Context, string) (*models.Skill, error) { + return s.skill, nil +} + +func TestSkillReaderAdapterMapsInventoryFiles(t *testing.T) { + adapter := NewSkillReaderAdapter(stubOfficeSkillReader{skill: &models.Skill{ + Slug: "office-admin", + Content: "# Office Admin\n", + FileInventory: `[{"path":"references/tasks.md","content":"# Tasks\n"},{"path":"empty.md"}]`, + }}) + + got, err := adapter.GetSkillFromConfig(context.Background(), "office-admin") + if err != nil { + t.Fatalf("GetSkillFromConfig: %v", err) + } + if got == nil { + t.Fatal("expected skill") + } + if len(got.Files) != 1 { + t.Fatalf("files = %+v, want one content-bearing file", got.Files) + } + if got.Files[0].Path != "references/tasks.md" || got.Files[0].Content != "# Tasks\n" { + t.Errorf("file = %+v", got.Files[0]) + } +} diff --git a/apps/backend/internal/office/skills/system_sync.go b/apps/backend/internal/office/skills/system_sync.go index da6401da73..2d5b0d3b22 100644 --- a/apps/backend/internal/office/skills/system_sync.go +++ b/apps/backend/internal/office/skills/system_sync.go @@ -25,6 +25,17 @@ import ( // the spec stay in sync. const SourceTypeSystem = "system" +var retiredDefaultSkillReplacements = map[string]string{ + "kandev-agent-edit": "kandev-team-admin", + "kandev-budget": "kandev-team-admin", + "kandev-config-export": "kandev-config-sync", + "kandev-config-import": "kandev-config-sync", + "kandev-hiring": "kandev-team-admin", + "kandev-task-comment": "kandev-task-ops", + "kandev-tasks": "kandev-task-ops", + "kandev-team": "kandev-team-admin", +} + // SystemSkillSpec is the parsed view of a single embedded SKILL.md // from `apps/backend/internal/office/configloader/skills//`. type SystemSkillSpec struct { @@ -34,6 +45,7 @@ type SystemSkillSpec struct { Version string DefaultForRoles []string Content string + FileInventory string ContentHash string } @@ -150,13 +162,15 @@ func syncWorkspace( spec := bundled[slug] cur, ok := existingBySlug[slug] if !ok { - if err := repo.CreateSkill(ctx, newSystemSkillRow(wsID, spec)); err != nil { + row := newSystemSkillRow(wsID, spec) + if err := repo.CreateSkill(ctx, row); err != nil { return inserted, updated, removed, fmt.Errorf("insert %s: %w", slug, err) } + existingBySlug[slug] = row inserted = append(inserted, slug) continue } - if cur.ContentHash == spec.ContentHash && cur.IsSystem { + if systemSkillUpToDate(cur, spec) { continue } applySystemSkillUpdate(cur, spec) @@ -170,6 +184,11 @@ func syncWorkspace( if _, kept := bundled[slug]; kept { continue } + if replacement := replacementSystemSkill(existingBySlug, slug); replacement != nil { + if err := replaceSkillOnAgents(ctx, repo, wsID, cur, replacement); err != nil { + return inserted, updated, removed, fmt.Errorf("replace %s: %w", slug, err) + } + } if err := repo.DeleteSkill(ctx, cur.ID); err != nil { return inserted, updated, removed, fmt.Errorf("delete %s: %w", slug, err) } @@ -181,6 +200,44 @@ func syncWorkspace( return inserted, updated, removed, nil } +func replacementSystemSkill(skills map[string]*models.Skill, retiredSlug string) *models.Skill { + replacementSlug, ok := retiredDefaultSkillReplacements[retiredSlug] + if !ok { + return nil + } + return skills[replacementSlug] +} + +func replaceSkillOnAgents( + ctx context.Context, + repo SystemSyncRepo, + wsID string, + retired *models.Skill, + replacement *models.Skill, +) error { + agents, err := repo.ListAgentInstances(ctx, wsID) + if err != nil { + return fmt.Errorf("list agents: %w", err) + } + for _, agent := range agents { + newSkillIDs, skillIDsChanged := replaceJSONArrayValue(agent.SkillIDs, retired.ID, replacement.ID) + newDesired, desiredChanged := replaceJSONArrayValue(agent.DesiredSkills, retired.Slug, replacement.Slug) + if !skillIDsChanged && !desiredChanged { + continue + } + if skillIDsChanged { + agent.SkillIDs = newSkillIDs + } + if desiredChanged { + agent.DesiredSkills = newDesired + } + if err := repo.UpdateAgentInstance(ctx, agent); err != nil { + return fmt.Errorf("update agent %s: %w", agent.ID, err) + } + } + return nil +} + // detachSkillFromAgents removes the deleted skill's ID from every // agent_profiles.skill_ids array in the workspace, preventing // dangling references after a kandev release retires a bundled skill. @@ -205,6 +262,53 @@ func detachSkillFromAgents( return nil } +func systemSkillUpToDate(cur *models.Skill, spec SystemSkillSpec) bool { + return cur.IsSystem && + cur.ContentHash == spec.ContentHash && + cur.Content == spec.Content && + cur.FileInventory == normalizedFileInventory(spec.FileInventory) && + cur.Name == spec.Name && + cur.Description == spec.Description && + cur.Version == spec.Version && + cur.SystemVersion == spec.Version +} + +func replaceJSONArrayValue(raw, oldValue, newValue string) (string, bool) { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "null" { + return raw, false + } + var values []string + if err := json.Unmarshal([]byte(raw), &values); err != nil { + return raw, false + } + out := make([]string, 0, len(values)) + changed := false + seen := make(map[string]bool, len(values)) + for _, value := range values { + if value == oldValue { + value = newValue + changed = true + } + if value == "" || seen[value] { + if value != "" { + changed = true + } + continue + } + seen[value] = true + out = append(out, value) + } + if !changed { + return raw, false + } + encoded, err := json.Marshal(out) + if err != nil { + return raw, false + } + return string(encoded), true +} + // removeIDFromJSONArray parses a JSON-array string, removes every // occurrence of `id`, and returns the re-encoded array along with a // flag indicating whether anything was removed. Malformed input is @@ -249,7 +353,7 @@ func newSystemSkillRow(wsID string, spec SystemSkillSpec) *models.Skill { SourceType: SourceTypeSystem, SourceLocator: "bundled:" + spec.Slug, Content: spec.Content, - FileInventory: "[]", + FileInventory: normalizedFileInventory(spec.FileInventory), Version: spec.Version, ContentHash: spec.ContentHash, ApprovalState: "approved", @@ -266,6 +370,7 @@ func applySystemSkillUpdate(cur *models.Skill, spec SystemSkillSpec) { cur.SourceType = SourceTypeSystem cur.SourceLocator = "bundled:" + spec.Slug cur.Content = spec.Content + cur.FileInventory = normalizedFileInventory(spec.FileInventory) cur.Version = spec.Version cur.ContentHash = spec.ContentHash cur.ApprovalState = "approved" @@ -274,6 +379,13 @@ func applySystemSkillUpdate(cur *models.Skill, spec SystemSkillSpec) { cur.DefaultForRoles = string(roles) } +func normalizedFileInventory(raw string) string { + if strings.TrimSpace(raw) == "" { + return "[]" + } + return raw +} + func scope(wsID string, slugs []string) []string { if len(slugs) == 0 { return nil @@ -309,12 +421,56 @@ func LoadBundledSystemSkills() ([]SystemSkillSpec, error) { if spec == nil { continue } + inventory, err := bundledSkillFileInventory(slug) + if err != nil { + return nil, fmt.Errorf("inventory %s: %w", slug, err) + } + spec.FileInventory = inventory + spec.ContentHash = bundledSkillContentHash([]byte(spec.Content), inventory) out = append(out, *spec) } sort.Slice(out, func(i, j int) bool { return out[i].Slug < out[j].Slug }) return out, nil } +type bundledSkillInventoryFile struct { + Path string `json:"path"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` + Content string `json:"content,omitempty"` +} + +func bundledSkillFileInventory(slug string) (string, error) { + files, err := configloader.BundledSkillFiles(slug) + if err != nil { + return "", err + } + inventory := make([]bundledSkillInventoryFile, 0, len(files)) + for _, file := range files { + sum := sha256.Sum256(file.Content) + inventory = append(inventory, bundledSkillInventoryFile{ + Path: file.Path, + Size: int64(len(file.Content)), + SHA256: hex.EncodeToString(sum[:]), + Content: string(file.Content), + }) + } + data, err := json.Marshal(inventory) + if err != nil { + return "", err + } + return string(data), nil +} + +func bundledSkillContentHash(content []byte, inventory string) string { + if inventory == "[]" || strings.TrimSpace(inventory) == "" { + sum := sha256.Sum256(content) + return hex.EncodeToString(sum[:]) + } + sum := sha256.Sum256([]byte(string(content) + "\x00" + inventory)) + return hex.EncodeToString(sum[:]) +} + // skillFrontmatter is the parsed YAML block at the top of a // SKILL.md. The `Kandev` sub-block is the marker that promotes a // skill from user-imported to kandev-owned. @@ -330,12 +486,12 @@ type kandevFrontmatter struct { DefaultForRoles []string `yaml:"default_for_roles"` } -// parseSystemSkill splits a SKILL.md into its frontmatter + body, -// validates the kandev block, and returns the spec. nil + nil -// signals "not a system skill" (kandev block missing or system = -// false) — the caller skips it without erroring. +// parseSystemSkill validates a SKILL.md frontmatter block and returns +// the spec while preserving the original file content for runtime +// delivery. nil + nil signals "not a system skill" (kandev block +// missing or system = false) — the caller skips it without erroring. func parseSystemSkill(slug string, raw []byte) (*SystemSkillSpec, error) { - frontmatterBytes, body, ok := splitFrontmatter(raw) + frontmatterBytes, _, ok := splitFrontmatter(raw) if !ok { // No frontmatter at all → not a system skill (some bundled // fixtures pre-date the kandev frontmatter block). Skip @@ -360,7 +516,7 @@ func parseSystemSkill(slug string, raw []byte) (*SystemSkillSpec, error) { Description: fm.Description, Version: fm.Kandev.Version, DefaultForRoles: append([]string{}, fm.Kandev.DefaultForRoles...), - Content: string(body), + Content: string(raw), ContentHash: hex.EncodeToString(sum[:]), }, nil } diff --git a/apps/backend/internal/office/skills/system_sync_parse_test.go b/apps/backend/internal/office/skills/system_sync_parse_test.go index b47a05fa0c..a71f38f62e 100644 --- a/apps/backend/internal/office/skills/system_sync_parse_test.go +++ b/apps/backend/internal/office/skills/system_sync_parse_test.go @@ -1,6 +1,7 @@ package skills import ( + "encoding/json" "strings" "testing" ) @@ -179,6 +180,9 @@ func TestParseSystemSkill_HappyPathPopulatesAllFields(t *testing.T) { if spec.ContentHash == "" { t.Error("content_hash must be populated") } + if !strings.HasPrefix(spec.Content, "---\nname: Demo Skill") { + t.Errorf("content should preserve original SKILL.md frontmatter, got %q", spec.Content) + } } // TestLoadBundledSystemSkills_FailsLoudOnEmbeddedYAMLError documents @@ -213,3 +217,34 @@ func TestLoadBundledSystemSkills_FailsLoudContract(t *testing.T) { } } } + +func TestLoadBundledSystemSkills_IncludesReferenceFileInventory(t *testing.T) { + specs, err := LoadBundledSystemSkills() + if err != nil { + t.Fatalf("LoadBundledSystemSkills: %v", err) + } + var teamAdmin *SystemSkillSpec + for i := range specs { + if specs[i].Slug == "kandev-team-admin" { + teamAdmin = &specs[i] + break + } + } + if teamAdmin == nil { + t.Fatal("expected kandev-team-admin bundled skill") + } + var files []bundledSkillInventoryFile + if err := json.Unmarshal([]byte(teamAdmin.FileInventory), &files); err != nil { + t.Fatalf("decode file inventory: %v", err) + } + found := false + for _, file := range files { + if file.Path == "references/team.md" && strings.Contains(file.Content, "List the whole organization") { + found = true + break + } + } + if !found { + t.Fatalf("references/team.md not found in inventory: %+v", files) + } +} diff --git a/apps/backend/internal/office/skills/system_sync_test.go b/apps/backend/internal/office/skills/system_sync_test.go index b1f7db4021..f172800421 100644 --- a/apps/backend/internal/office/skills/system_sync_test.go +++ b/apps/backend/internal/office/skills/system_sync_test.go @@ -311,6 +311,66 @@ func TestSyncSystemSkills_UpdatesContentWhenBundledHashDiffers(t *testing.T) { } } +// TestSyncSystemSkills_UpdatesBodyOnlyRowsWithMatchingHash pins the +// migration from the old parser, which stored only the markdown body +// while already recording the hash of the full SKILL.md. The sync +// must refresh content even when content_hash already matches. +func TestSyncSystemSkills_UpdatesBodyOnlyRowsWithMatchingHash(t *testing.T) { + repo := newStubSyncRepo() + log := logger.Default() + + const slug = "frontmatter-skill" + const fullContent = "---\nname: Frontmatter Skill\ndescription: Use for frontmatter preservation.\n---\n# Guidance\n" + const existingHash = "hash-full-content" + + repo.rows["ws-1"] = map[string]*models.Skill{ + slug: { + ID: "skill-frontmatter-1", + WorkspaceID: "ws-1", + Slug: slug, + Name: "Frontmatter Skill", + Description: "Use for frontmatter preservation.", + SourceType: skills.SourceTypeSystem, + SourceLocator: "bundled:" + slug, + Content: "# Guidance\n", + ContentHash: existingHash, + Version: "1.0.0", + IsSystem: true, + SystemVersion: "1.0.0", + ApprovalState: "approved", + }, + } + + bundled := []skills.SystemSkillSpec{{ + Slug: slug, + Name: "Frontmatter Skill", + Description: "Use for frontmatter preservation.", + Version: "1.0.0", + Content: fullContent, + ContentHash: existingHash, + }} + + report, err := skills.SyncSystemSkills( + context.Background(), repo, []string{"ws-1"}, bundled, log, + ) + if err != nil { + t.Fatalf("sync: %v", err) + } + if len(report.Updated) != 1 || !strings.HasSuffix(report.Updated[0], slug) { + t.Fatalf("expected one update for %s, got %v", slug, report.Updated) + } + got, err := repo.GetSkillBySlug(context.Background(), "ws-1", slug) + if err != nil { + t.Fatalf("get after sync: %v", err) + } + if got.Content != fullContent { + t.Errorf("content = %q, want full SKILL.md %q", got.Content, fullContent) + } + if got.ContentHash != existingHash { + t.Errorf("content_hash = %q, want %q", got.ContentHash, existingHash) + } +} + // TestSyncSystemSkills_RemovesOrphanedSlugAndDetachesFromAgents pins // the "kandev release retired a bundled skill" scenario: a system // row whose slug is missing from the injected `bundled` slice must be @@ -403,6 +463,98 @@ func TestSyncSystemSkills_RemovesOrphanedSlugAndDetachesFromAgents(t *testing.T) } } +func TestSyncSystemSkills_ReplacesRetiredDefaultSkillReferences(t *testing.T) { + repo := newStubSyncRepo() + log := logger.Default() + + const oldTasksID = "skill-old-tasks" + const oldCommentID = "skill-old-comment" + const newTaskOpsID = "skill-new-task-ops" + + repo.rows["ws-1"] = map[string]*models.Skill{ + "kandev-tasks": { + ID: oldTasksID, + WorkspaceID: "ws-1", + Slug: "kandev-tasks", + Name: "Tasks", + IsSystem: true, + SourceType: skills.SourceTypeSystem, + }, + "kandev-task-comment": { + ID: oldCommentID, + WorkspaceID: "ws-1", + Slug: "kandev-task-comment", + Name: "Task Comment", + IsSystem: true, + SourceType: skills.SourceTypeSystem, + }, + "kandev-task-ops": { + ID: newTaskOpsID, + WorkspaceID: "ws-1", + Slug: "kandev-task-ops", + Name: "Task Ops", + IsSystem: true, + SourceType: skills.SourceTypeSystem, + ContentHash: "old-task-ops-hash", + }, + } + repo.agents["ws-1"] = map[string]*settingsmodels.AgentProfile{ + "agent-1": { + ID: "agent-1", + WorkspaceID: "ws-1", + SkillIDs: mustJSONArray(t, []string{oldTasksID, oldCommentID}), + DesiredSkills: mustJSONArray(t, []string{"kandev-tasks", "kandev-task-comment"}), + }, + "agent-2": { + ID: "agent-2", + WorkspaceID: "ws-1", + SkillIDs: mustJSONArray(t, []string{oldTasksID, newTaskOpsID}), + DesiredSkills: mustJSONArray(t, []string{"kandev-tasks", "kandev-task-ops"}), + }, + } + + bundled := []skills.SystemSkillSpec{{ + Slug: "kandev-task-ops", + Name: "Task Ops", + Description: "Task operations", + Version: "1.0.0", + Content: "---\nname: kandev-task-ops\ndescription: Task operations\n---\n# Task Ops\n", + ContentHash: "hash-task-ops", + }} + + report, err := skills.SyncSystemSkills(context.Background(), repo, []string{"ws-1"}, bundled, log) + if err != nil { + t.Fatalf("sync: %v", err) + } + if len(report.Removed) != 2 { + t.Fatalf("expected two retired removals, got %v", report.Removed) + } + + gotTaskOps, err := repo.GetSkillBySlug(context.Background(), "ws-1", "kandev-task-ops") + if err != nil { + t.Fatalf("replacement skill missing: %v", err) + } + if gotTaskOps.ID == "" { + t.Fatal("replacement skill should have an ID") + } + + agent1 := repo.agents["ws-1"]["agent-1"] + if got := decodeIDs(t, agent1.SkillIDs); len(got) != 1 || got[0] != gotTaskOps.ID { + t.Fatalf("agent-1.skill_ids = %v, want replacement %s", got, gotTaskOps.ID) + } + if got := decodeIDs(t, agent1.DesiredSkills); len(got) != 1 || got[0] != "kandev-task-ops" { + t.Fatalf("agent-1.desired_skills = %v, want kandev-task-ops", got) + } + + agent2 := repo.agents["ws-1"]["agent-2"] + if got := decodeIDs(t, agent2.SkillIDs); len(got) != 1 || got[0] != newTaskOpsID { + t.Fatalf("agent-2.skill_ids should dedupe existing replacement, got %v", got) + } + if got := decodeIDs(t, agent2.DesiredSkills); len(got) != 1 || got[0] != "kandev-task-ops" { + t.Fatalf("agent-2.desired_skills should dedupe existing replacement, got %v", got) + } +} + func mustJSONArray(t *testing.T, ids []string) string { t.Helper() b, err := json.Marshal(ids) diff --git a/apps/web/e2e/tests/office/system-skills.spec.ts b/apps/web/e2e/tests/office/system-skills.spec.ts index 97a51409c5..dffc873af9 100644 --- a/apps/web/e2e/tests/office/system-skills.spec.ts +++ b/apps/web/e2e/tests/office/system-skills.spec.ts @@ -25,7 +25,7 @@ test.describe("Office system skills", () => { // Spot-check several v1 slugs across the role-default tiers so a // bulk rename (e.g. kandev-* prefix drop) trips the assertion. - const expectSlugs = ["kandev-protocol", "memory", "kandev-team", "kandev-hiring"]; + const expectSlugs = ["kandev-protocol", "memory", "kandev-team-admin", "kandev-task-ops"]; for (const slug of expectSlugs) { const row = skills.find((s) => s.slug === slug); expect(row, `expected bundled skill ${slug} in workspace skill list`).toBeTruthy(); @@ -65,7 +65,7 @@ test.describe("Office system skills", () => { expect(desiredSlugs.length, "desired_skills").toBeGreaterThan(0); expect(desiredIds.length, "skill_ids").toBeGreaterThan(0); - for (const slug of ["kandev-protocol", "memory", "kandev-team", "kandev-hiring"]) { + for (const slug of ["kandev-protocol", "memory", "kandev-team-admin", "kandev-task-ops"]) { expect(desiredSlugs, `${slug} must be auto-attached to the CEO`).toContain(slug); } }); @@ -84,12 +84,12 @@ test.describe("Office system skills", () => { ); expect(priming.ok).toBe(true); const primed = (await priming.json()) as { skills?: Array<{ slug: string }> }; - expect((primed.skills ?? []).map((s) => s.slug)).toContain("kandev-hiring"); + expect((primed.skills ?? []).map((s) => s.slug)).toContain("kandev-team-admin"); await testPage.goto("/office/workspace/skills"); // The count badge shows N≥3 available (3 pre-existing v1 bundled - // slugs at minimum). Wait specifically for ≥13 — anything lower + // slugs at minimum). Wait specifically for ≥8 — anything lower // means the SSR didn't see the synced set. await expect .poll( @@ -103,7 +103,7 @@ test.describe("Office system skills", () => { }, { timeout: 10_000 }, ) - .toBeGreaterThanOrEqual(10); + .toBeGreaterThanOrEqual(8); // Expand the System group. With the heading rendered inside a // button containing "System" + a count badge as separate spans, @@ -114,7 +114,9 @@ test.describe("Office system skills", () => { await expect(systemToggle).toBeVisible({ timeout: 5_000 }); await systemToggle.click(); - await expect(testPage.getByText("kandev-hiring").first()).toBeVisible({ timeout: 5_000 }); - await expect(testPage.getByText("kandev-team").first()).toBeVisible(); + await expect(testPage.getByText("kandev-team-admin").first()).toBeVisible({ + timeout: 5_000, + }); + await expect(testPage.getByText("kandev-task-ops").first()).toBeVisible(); }); }); diff --git a/docs/decisions/0031-office-skill-reference-files.md b/docs/decisions/0031-office-skill-reference-files.md new file mode 100644 index 0000000000..a465ebe55f --- /dev/null +++ b/docs/decisions/0031-office-skill-reference-files.md @@ -0,0 +1,27 @@ +# 0031: Office Skill Reference Files + +**Status:** accepted +**Date:** 2026-07-06 +**Area:** backend + +## Context + +Office system skills are bundled under `apps/backend/internal/office/configloader/skills/` and synced into `office_skills`. The original system-skill parser stored only the markdown body after YAML frontmatter, so the runtime deployer generated placeholder frontmatter such as `description: kandev-approvals`. The bundled skill set also had overlapping small skills that would be better expressed as concise entry skills plus reference files, but the runtime deployer only wrote `SKILL.md`. + +## Decision + +Bundled system skills preserve the full `SKILL.md` content, including YAML frontmatter, when synced into `office_skills`. The system sync also records non-`SKILL.md` bundled files in `file_inventory` with their content, and the runtime skill deployer materializes those files next to `SKILL.md` for local, Docker, and Sprites executors. + +The Office bundle now uses progressive disclosure for broader system skills such as `kandev-team-admin`, `kandev-task-ops`, and `kandev-config-sync`, with detailed guidance in `references/`. + +## Consequences + +Skill descriptions used for agent discovery remain meaningful at runtime instead of being replaced by slug-only generated frontmatter. Bundled skills can be consolidated without losing detailed instructions, reducing the number of attached skills while keeping context load small. The runtime deployer now validates support-file paths before writing them, so inventory entries cannot escape the deployed skill directory. + +Existing body-only system rows are refreshed during system sync even when their historical `content_hash` already matches the full bundled file hash. + +## Alternatives Considered + +- **Keep every Office workflow as a separate skill.** Rejected because it avoided runtime changes but continued to produce a noisy skill list and made discovery less precise. +- **Store all reference text inline in `SKILL.md`.** Rejected because it avoided support-file materialization but would increase prompt/context load for every skill discovery. +- **Only rename skill descriptions.** Rejected because it would not fix the deployed placeholder metadata unless frontmatter was preserved. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 7cc22e1542..92cbd1a51d 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -36,3 +36,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 0028 | [Backend-owned task-create last-used preferences](0028-task-create-last-used-source-of-truth.md) | accepted | backend, frontend | 2026-06-29 | | 0029 | [Release backfill and desktop diagnostics](0029-release-backfill-and-desktop-diagnostics.md) | accepted | infra, workflow | 2026-07-01 | | 0030 | [Workspace-scoped integration settings](0030-workspace-scoped-integration-settings.md) | accepted | backend, frontend | 2026-07-01 | +| 0031 | [Office skill reference files](0031-office-skill-reference-files.md) | accepted | backend | 2026-07-06 | diff --git a/docs/specs/office/agents.md b/docs/specs/office/agents.md index 17c58208a2..eb54545b52 100644 --- a/docs/specs/office/agents.md +++ b/docs/specs/office/agents.md @@ -121,7 +121,7 @@ Before each session, instructions are written to `~/.kandev/runtime/` directory), `description`, `source_type` (`inline` | `local_path` | `git`), `source_locator` (path/URL), `content` (SKILL.md text for inline, null otherwise), `file_inventory` (JSON list of `{name, size}`), `workspace_id` FK, `created_by_agent_instance_id` (nullable; agents only edit skills they created), `is_system` (bool), `system_version` (kandev release). @@ -129,7 +129,7 @@ System skills ship inside the kandev binary (`apps/backend/internal/office/confi System SKILL.md carries an optional `kandev:` frontmatter block with `system: true`, `version: ""`, `default_for_roles: []`. `default_for_roles` drives auto-attach: a new agent with role `R` automatically gets every system skill whose `default_for_roles` contains `R`, unless the caller passes an explicit `desired_skills`. Users can untick a default-attached system skill on any agent (role default is a soft suggestion). -v1 system-skill set: `kandev-protocol` and `memory` (every role); `kandev-task-comment` (every role); `kandev-escalation` (worker, specialist, assistant, reviewer); `kandev-tasks` (ceo, worker, specialist); `kandev-team`, `kandev-hiring`, `kandev-agent-edit`, `kandev-routines`, `kandev-approvals`, `kandev-budget`, `kandev-config-export`, `kandev-config-import` (ceo). +v1 system-skill set: `kandev-protocol`, `memory`, and `kandev-task-ops` (every role); `kandev-escalation` (worker, specialist, assistant, reviewer); `kandev-team-admin`, `kandev-routines`, `kandev-approvals`, and `kandev-config-sync` (ceo). ### Activity, runs, events diff --git a/docs/specs/office/costs.md b/docs/specs/office/costs.md index 2bcc0036e4..d351e442f2 100644 --- a/docs/specs/office/costs.md +++ b/docs/specs/office/costs.md @@ -148,7 +148,7 @@ Monthly reset is idempotent: backend restart mid-month does not refire. Cost data and budget policies are workspace-scoped. The same auth gate as the rest of the office HTTP surface applies (see [agents.md](./agents.md#permissions)): UI requests authenticated via the user session bypass as admin; agent JWT requests run through the office permission middleware. - **Read cost data** (`GET /workspaces/:wsId/costs*`): any caller with workspace access. CEO, worker, specialist, assistant, and reviewer agents can all read their workspace's cost rollups. There is no per-agent or per-project read scoping today - any agent in the workspace sees workspace-wide totals. -- **Manage budget policies** (`POST /workspaces/:wsId/budgets`, `PATCH /budgets/:id`, `DELETE /budgets/:id`): UI / admin only. The CEO's `kandev-budget` skill is the agent-facing surface for budget proposals; CEO agents do not call the budget HTTP endpoints directly, they raise proposals that the user actions through the inbox. +- **Manage budget policies** (`POST /workspaces/:wsId/budgets`, `PATCH /budgets/:id`, `DELETE /budgets/:id`): UI / admin only. The CEO's `kandev-team-admin` skill is the agent-facing surface for budget checks and proposals; CEO agents do not call the budget HTTP endpoints directly, they raise proposals that the user actions through the inbox. - **Override pricing** (per-model overrides written into workspace settings): user only. Agents cannot mutate the pricing table. - **Trigger pause** (the side effect of `CheckBudget` when `action_on_exceed=pause_agent`): performed by the `system` actor inside the cost subscriber, not by any caller. The agent pause it produces is identical to a user-initiated pause; only the user can resume by raising the budget or waiting for the monthly reset.