feat: consolidate office system skills (#1604)

This commit is contained in:
Carlos Florêncio
2026-07-06 22:19:52 +01:00
committed by GitHub
parent a915590f13
commit c9063e1b0a
45 changed files with 1047 additions and 394 deletions
+2
View File
@@ -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.
@@ -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.
@@ -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`.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
+1
View File
@@ -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 |
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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.