feat: consolidate office system skills (#1604)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 "<projectSkillDir>/kandev-*" to the
|
||||
// worktree's .git/info/exclude file so injected skill directories
|
||||
// never appear as dirty files in git status. Idempotent.
|
||||
|
||||
@@ -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.") {
|
||||
|
||||
@@ -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/<projectSkillDir>).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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`:
|
||||
|
||||
@@ -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.
|
||||
@@ -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/<slug>/kandev.yml` — workspace settings
|
||||
- `workspaces/<slug>/agents/*.yml` — agent profiles
|
||||
- `workspaces/<slug>/projects/*.yml` — projects
|
||||
- `workspaces/<slug>/routines/*.yml` — routines + triggers
|
||||
- `workspaces/<slug>/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.
|
||||
@@ -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/<slug>/` 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.
|
||||
@@ -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.
|
||||
+7
@@ -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.
|
||||
+9
@@ -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/<slug>/`, 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.
|
||||
@@ -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: <question>"
|
||||
$KANDEV_CLI kandev tasks message --prompt "Escalated to human: created task $HUMAN_TASK_ID. Waiting for decision on: <question>"
|
||||
|
||||
$KANDEV_CLI kandev task update --status blocked
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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).
|
||||
@@ -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`.
|
||||
+17
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+29
@@ -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.
|
||||
+17
@@ -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.
|
||||
+27
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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/<slug>/`.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -121,7 +121,7 @@ Before each session, instructions are written to `~/.kandev/runtime/<workspace-s
|
||||
|
||||
### Skills
|
||||
|
||||
A skill is a directory containing `SKILL.md` (required: the markdown instructions the agent reads) plus optional scripts and reference files. The structure matches Claude Code's native skill discovery and other agent CLIs. Materialized `SKILL.md` files must be valid Codex/Claude-style skill files: when stored content lacks YAML frontmatter, the runtime prepends generated `name` and `description` frontmatter from the skill slug before writing or uploading the file.
|
||||
A skill is a directory containing `SKILL.md` (required: the markdown instructions the agent reads) plus optional scripts and reference files. The structure matches Claude Code's native skill discovery and other agent CLIs. Materialized `SKILL.md` files must be valid Codex/Claude-style skill files: when stored content lacks YAML frontmatter, the runtime prepends generated `name` and `description` frontmatter from the skill slug before writing or uploading the file. Supporting files recorded in `file_inventory` are written beside `SKILL.md` so bundled skills can use progressive disclosure through `references/`. Decision: ADR-0030.
|
||||
|
||||
`skill` DB row (workspace-scoped): `id` PK, `name`, `slug` (kebab-case, used as `kandev-<slug>` 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: "<release>"`, `default_for_roles: [<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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user