Implement layered sandbox configuration system

- Add global.yaml base config (renamed from sandbox.yaml)
- Add backend-specific configs (claude.yaml, kiro-cli.yaml)
- Add role configs (developer, reviewer, tester, architect, etc.)
- Add task configs (git, docker, go, rust, python, nodejs, etc.)
- Implement config merging: global + backend + role + tasks
- Update workflows to use roles and tasks
- Create tester role with read-only source, execute-only bins
- Fix reviewer role to use rox for bin directories
- Auto-create RW directories that don't exist
- Make backend configs required (fail if missing)
- Update mkfile to install cfg files to ~/.config/anvillm
- Remove Role-Based Sessions from IDEAS.md (now implemented)
This commit is contained in:
Levi Neely 2026-02-15 18:57:55 +01:00
parent d149f5b595
commit 873f839278
49 changed files with 907 additions and 266 deletions

1
.gitignore vendored
View File

@ -8,3 +8,4 @@ anvilweb
.kiro
*.elc
Assist
anvilsrv

162
IDEAS.md
View File

@ -1,167 +1,5 @@
# IDEAS.md
## Role-Based Sessions
Specialized session types with enforced capabilities via context and sandbox configuration.
### Current Implementation
Already possible using existing features:
- `context` file defines role behavior
- Sandbox config enforces filesystem boundaries
- Scripts create role-specific sessions (see `scripts/DevReview`, `scripts/Planning`)
### Example Roles
**Developer**:
- Context: implements features, stages changes, requests reviews
- Sandbox: read-write in project directory
- Can: modify code, run tests, commit to feature branches
- Cannot: push to main, merge PRs
**Reviewer**:
- Context: reviews staged changes, provides feedback
- Sandbox: read-only filesystem
- Can: read code, run git diff, analyze changes
- Cannot: modify files, commit, push
**Planner**:
- Context: analyzes requirements, creates task breakdowns
- Sandbox: read-only filesystem, no git write
- Can: read codebase, write plans to separate files
- Cannot: modify code, commit, push
**Tester**:
- Context: writes and runs tests
- Sandbox: read-write in test directories only
- Can: create test files, run test commands
- Cannot: modify source code outside test dirs
### Implementation
**1. Role Templates**
Store role definitions in `~/.config/anvillm/roles/`:
```
roles/
├── developer.txt
├── reviewer.txt
├── planner.txt
└── tester.txt
```
Scripts load templates:
```bash
cat ~/.config/anvillm/roles/developer.txt | 9p write agent/$SESSION_ID/context
```
**2. Role-Specific Sandbox Configs**
Create per-role sandbox configs in `~/.config/anvillm/sandbox-roles/`:
```yaml
# sandbox-roles/reviewer.yaml
filesystem:
ro:
- "{CWD}"
- "/usr"
- "/lib"
rw: [] # No write access
network:
enabled: false
```
```yaml
# sandbox-roles/developer.yaml
filesystem:
ro:
- "/usr"
- "/lib"
rw:
- "{CWD}"
- "{TMPDIR}"
network:
enabled: true
```
Session creation with role:
```bash
echo 'new kiro /project reviewer' | 9p write agent/ctl
# Daemon loads sandbox-roles/reviewer.yaml for this session
```
**3. Role Metadata**
Add `agent/{session}/role` file to 9P filesystem:
```
agent/<id>/role # "developer" | "reviewer" | "planner" | "tester"
```
Benefits:
- UI can filter/group by role
- Scripts can discover sessions by role
- Easier than parsing aliases
**4. Role Discovery**
Find sessions by role:
```bash
# List all reviewer sessions
9p read agent/list | while read id alias state pid cwd; do
role=$(9p read agent/$id/role)
[ "$role" = "reviewer" ] && echo "$id $alias"
done
```
Or use peer-bot skill (already works):
```
Find the reviewer agent and send them: "Please review staged changes"
```
### Enforcement Layers
**Soft enforcement** (current):
- Context instructions tell bot what its role is
- Bot follows instructions (usually)
**Hard enforcement** (with role-specific sandboxes):
- Filesystem boundaries prevent unauthorized writes
- Reviewer literally cannot modify code files
- Planner cannot commit or push
### Example Workflow
```bash
# Create developer session with role
DEV_ID=$(echo 'new kiro /project developer' | 9p write agent/ctl && ...)
cat ~/.config/anvillm/roles/developer.txt | 9p write agent/$DEV_ID/context
# Create reviewer session with role
REV_ID=$(echo 'new kiro /project reviewer' | 9p write agent/ctl && ...)
cat ~/.config/anvillm/roles/reviewer.txt | 9p write agent/$REV_ID/context
# Developer implements feature
echo "Implement user authentication" | 9p write agent/$DEV_ID/in
# Developer requests review (via peer-bot skill)
# Reviewer provides feedback
# Loop until LGTM
```
### Benefits
- **Separation of concerns**: Each role has clear responsibilities
- **Safety**: Sandbox prevents accidental damage (reviewer can't modify code)
- **Reusability**: Role templates used across projects
- **Discoverability**: Find sessions by role, not just alias
- **Scalability**: Spawn multiple workers of same role
### Already Working
See `scripts/DevReview` and `scripts/Planning` for working examples of role-based multi-agent workflows using context system.
## Persistent Work Tracking (Beads)
Git-backed work units that survive orchestrator/bot crashes and preserve workflow state.

BIN
anvilsrv

Binary file not shown.

10
cfg/backends/claude.yaml Normal file
View File

@ -0,0 +1,10 @@
# Claude backend requirements
filesystem:
rw:
- "{HOME}/.claude"
- "{HOME}/.claude.json"
- "{HOME}/.config/anvillm"
network:
unrestricted: true
env:
- ANTHROPIC_API_KEY

View File

@ -0,0 +1,10 @@
# Kiro backend requirements
filesystem:
rw:
- "{HOME}/.kiro"
- "{XDG_DATA_HOME}/kiro-cli"
- "{CWD}/.kiro"
network:
unrestricted: true
env:
- KIRO_API_KEY

63
cfg/global.yaml Normal file
View File

@ -0,0 +1,63 @@
# AnviLLM Sandbox Configuration
# This file controls landrun sandboxing for all backend sessions
# Documentation: https://github.com/landlock-lsm/landrun
#
# Path templates:
# {CWD} - Session working directory
# {HOME} - User home directory
# {TMPDIR} - Temporary directory (/tmp or $TMPDIR)
#
# WARNING: CLI tools need network access to function!
# - Claude requires connect_tcp for claude.com
# - Kiro requires connect_tcp for kiro.ai
#
# Changes apply to NEW sessions only. Existing sessions are not affected.
general:
enabled: true
best_effort: true
log_level: error
filesystem:
ro:
- /etc/passwd
- /etc/group
- /etc/localtime
- /etc/nsswitch.conf
- /etc/hosts
- /etc/resolv.conf
- /etc/services
- /etc/ssl/certs
- /proc
- '{XDG_CONFIG_HOME}/anvillm'
- '{HOME}/env'
rox:
- /usr
- /lib
- /lib64
- /bin
- /sbin
rw:
- '{TMPDIR}'
- '{XDG_CACHE_HOME}'
- /dev/null
- /dev/zero
- /dev/urandom
- /dev/random
rwx: []
network:
enabled: false
unrestricted: false
bind_tcp: []
connect_tcp: []
env:
- HOME
- USER
- PATH
- LANG
- TERM
- NAMESPACE
- DISPLAY
- WAYLAND_DISPLAY
advanced:
ldd: false
add_exec: true

12
cfg/roles/admin.yaml Normal file
View File

@ -0,0 +1,12 @@
# Admin role: system administration tasks
filesystem:
ro:
- "{CWD}"
- "/var/log"
rw:
- "/etc"
- "/usr/local/etc"
- "{HOME}"
network:
enabled: true
unrestricted: true

8
cfg/roles/architect.yaml Normal file
View File

@ -0,0 +1,8 @@
# Architect role: access to knowledge base and planning documents
filesystem:
ro:
- "{XDG_DOCUMENTS_DIR}/agent-kb"
- "{HOME}/doc/agent-kb"
rw:
- "{XDG_DOCUMENTS_DIR}/plans"
- "{HOME}/doc/plans"

10
cfg/roles/default.yaml Normal file
View File

@ -0,0 +1,10 @@
# Default role: permissive settings for ad-hoc usage
# Equivalent to current global sandbox.yaml behavior
filesystem:
rw:
- "{CWD}"
network:
unrestricted: true
env:
- ANTHROPIC_API_KEY
- KIRO_API_KEY

8
cfg/roles/developer.yaml Normal file
View File

@ -0,0 +1,8 @@
# Developer role configuration
filesystem:
rw:
- "{CWD}"
rwx:
- "{HOME}/bin"
- "{HOME}/.local/bin"
- "{HOME}/src"

10
cfg/roles/research.yaml Normal file
View File

@ -0,0 +1,10 @@
# Research role: access to documentation and knowledge base
filesystem:
ro:
- "{CWD}"
- "{HOME}/src"
- "{XDG_DOCUMENTS_DIR}"
- "{HOME}/doc"
rw:
- "{XDG_DOCUMENTS_DIR}/agent-kb"
- "{HOME}/doc/agent-kb"

8
cfg/roles/reviewer.yaml Normal file
View File

@ -0,0 +1,8 @@
# Reviewer role: read-only access to project and dev directories
filesystem:
ro:
- "{CWD}"
- "{HOME}/src"
rox:
- "{HOME}/bin"
- "{HOME}/.local/bin"

View File

@ -0,0 +1,8 @@
# Technical editor role: documentation with code access
filesystem:
ro:
- "{HOME}/src"
rw:
- "{CWD}"
- "{XDG_DOCUMENTS_DIR}"
- "{HOME}/doc"

9
cfg/roles/tester.yaml Normal file
View File

@ -0,0 +1,9 @@
# Tester role: execute binaries and read source, no write access
filesystem:
ro:
- "{CWD}"
- "{HOME}/src"
rox:
- "{CWD}"
- "{HOME}/bin"
- "{HOME}/.local/bin"

8
cfg/roles/writer.yaml Normal file
View File

@ -0,0 +1,8 @@
# Writer role: documentation and content creation
filesystem:
ro:
- "{CWD}"
- "{HOME}/src"
rw:
- "{XDG_DOCUMENTS_DIR}"
- "{HOME}/doc"

16
cfg/tasks/ansible.yaml Normal file
View File

@ -0,0 +1,16 @@
# Ansible configuration management tool
filesystem:
ro:
- "{HOME}/.ansible"
- "{HOME}/.ansible.cfg"
- "{HOME}/.ssh/config"
- "{HOME}/.ssh/id_*"
- "{HOME}/.ssh/id_*.pub"
rw:
- "{HOME}/.ssh/known_hosts"
- "{HOME}/.ansible/tmp"
network:
enabled: true
connect_tcp:
- "22" # SSH
- "443" # Galaxy, collections

7
cfg/tasks/aws.yaml Normal file
View File

@ -0,0 +1,7 @@
# AWS CLI configuration
filesystem:
ro:
- "{HOME}/.aws"
network:
enabled: true
unrestricted: true

4
cfg/tasks/curl.yaml Normal file
View File

@ -0,0 +1,4 @@
# curl/wget network tools
network:
enabled: true
unrestricted: true

9
cfg/tasks/docker.yaml Normal file
View File

@ -0,0 +1,9 @@
# Docker container tool configuration
filesystem:
rw:
- "/var/run/docker.sock"
- "{HOME}/.docker"
network:
enabled: true
connect_tcp:
- "443" # Docker Hub, registries

11
cfg/tasks/editor.yaml Normal file
View File

@ -0,0 +1,11 @@
# Text editor configuration (vim, emacs, nano, etc.)
filesystem:
rw:
- "{XDG_CONFIG_HOME}/nvim"
- "{XDG_CONFIG_HOME}/vim"
- "{XDG_DATA_HOME}/nvim"
- "{XDG_STATE_HOME}/nvim"
- "{HOME}/.vimrc"
- "{HOME}/.vim"
- "{HOME}/.emacs.d"
- "{HOME}/.config/emacs"

24
cfg/tasks/git.yaml Normal file
View File

@ -0,0 +1,24 @@
# Git task: access to git configuration and system resources
filesystem:
ro:
- "{HOME}/.gitconfig"
- "{XDG_CONFIG_HOME}/git/config"
- "{XDG_CONFIG_HOME}/git/ignore"
- "{XDG_CONFIG_HOME}/git/attributes"
- "{HOME}/.ssh"
- "{HOME}/.netrc"
- "/etc/gitconfig"
- "/etc/gitattributes"
- "/etc/ssh/ssh_config"
- "/etc/ssh/ssh_known_hosts"
rw:
- "{HOME}/.ssh/known_hosts"
- "{HOME}/.git-credentials"
- "{XDG_CONFIG_HOME}/git/credentials"
- "{XDG_RUNTIME_DIR}/git/"
network:
enabled: true
connect_tcp:
- "22" # SSH (git@...)
- "443" # HTTPS (https://...)
- "9418" # Git protocol (git://...)

10
cfg/tasks/github-cli.yaml Normal file
View File

@ -0,0 +1,10 @@
# GitHub CLI (gh) tool configuration
filesystem:
ro:
- "{XDG_CONFIG_HOME}/gh"
- "{XDG_DATA_HOME}/gh/extensions"
network:
enabled: true
connect_tcp:
- "443" # HTTPS API
- "22" # SSH (optional)

8
cfg/tasks/go.yaml Normal file
View File

@ -0,0 +1,8 @@
# Go toolchain configuration
filesystem:
rw:
- "{HOME}/go"
network:
enabled: true
connect_tcp:
- "443" # pkg.go.dev, proxy.golang.org

8
cfg/tasks/gpg.yaml Normal file
View File

@ -0,0 +1,8 @@
# GPG encryption tool
filesystem:
ro:
- "{HOME}/.gnupg"
rw:
- "{HOME}/.gnupg/trustdb.gpg"
- "{HOME}/.gnupg/pubring.kbx"
- "{HOME}/.gnupg/random_seed"

8
cfg/tasks/gradle.yaml Normal file
View File

@ -0,0 +1,8 @@
# Gradle (Java/Kotlin build tool) configuration
filesystem:
rw:
- "{HOME}/.gradle"
network:
enabled: true
connect_tcp:
- "443" # services.gradle.org, Maven Central, other repos

9
cfg/tasks/jira.yaml Normal file
View File

@ -0,0 +1,9 @@
# Jira CLI configuration
filesystem:
ro:
- "{HOME}/.jira.d"
- "{XDG_CONFIG_HOME}/jira-cli"
network:
enabled: true
connect_tcp:
- "443" # Jira API

8
cfg/tasks/maven.yaml Normal file
View File

@ -0,0 +1,8 @@
# Maven (Java build tool) configuration
filesystem:
rw:
- "{HOME}/.m2"
network:
enabled: true
connect_tcp:
- "443" # repo.maven.apache.org, Maven Central

9
cfg/tasks/mysql.yaml Normal file
View File

@ -0,0 +1,9 @@
# MySQL client configuration
filesystem:
ro:
- "{HOME}/.my.cnf"
- "{HOME}/.mylogin.cnf"
network:
enabled: true
connect_tcp:
- "3306" # MySQL default port

13
cfg/tasks/nodejs.yaml Normal file
View File

@ -0,0 +1,13 @@
# Node.js package managers (npm, yarn, pnpm) configuration
filesystem:
rw:
- "{HOME}/.npm"
- "{HOME}/.yarn"
- "{HOME}/.pnpm-store"
- "{XDG_CONFIG_HOME}/yarn"
- "{HOME}/.yarnrc.yml"
- "{HOME}/.npmrc"
network:
enabled: true
connect_tcp:
- "443" # registry.npmjs.org, registry.yarnpkg.com

10
cfg/tasks/plan9.yaml Normal file
View File

@ -0,0 +1,10 @@
# Plan 9 from User Space toolchain configuration
filesystem:
rwx:
- "{PLAN9}"
network:
enabled: true
connect_tcp:
- "564" # 9P protocol
env:
- PLAN9

10
cfg/tasks/podman.yaml Normal file
View File

@ -0,0 +1,10 @@
# Podman container tool configuration
filesystem:
rw:
- "{XDG_RUNTIME_DIR}/podman"
- "{XDG_DATA_HOME}/containers"
- "{XDG_CONFIG_HOME}/containers"
network:
enabled: true
connect_tcp:
- "443" # Container registries

View File

@ -0,0 +1,9 @@
# PostgreSQL client configuration
filesystem:
ro:
- "{HOME}/.psqlrc"
- "{HOME}/.pgpass"
network:
enabled: true
connect_tcp:
- "5432" # PostgreSQL default port

9
cfg/tasks/python.yaml Normal file
View File

@ -0,0 +1,9 @@
# Python pip package manager configuration
filesystem:
rw:
- "{XDG_CONFIG_HOME}/pip"
- "{HOME}/.pip"
network:
enabled: true
connect_tcp:
- "443" # pypi.org, files.pythonhosted.org

13
cfg/tasks/rsync.yaml Normal file
View File

@ -0,0 +1,13 @@
# rsync tool configuration
filesystem:
ro:
- "{HOME}/.ssh/config"
- "{HOME}/.ssh/id_*"
- "{HOME}/.ssh/id_*.pub"
rw:
- "{HOME}/.ssh/known_hosts"
network:
enabled: true
connect_tcp:
- "22" # SSH transport
- "873" # rsync daemon

9
cfg/tasks/rust.yaml Normal file
View File

@ -0,0 +1,9 @@
# Rust toolchain (cargo, rustc, rustup) configuration
filesystem:
rw:
- "{HOME}/.cargo"
- "{HOME}/.rustup"
network:
enabled: true
connect_tcp:
- "443" # crates.io, static.rust-lang.org

12
cfg/tasks/ssh.yaml Normal file
View File

@ -0,0 +1,12 @@
# SSH tool configuration
filesystem:
ro:
- "{HOME}/.ssh/config"
- "{HOME}/.ssh/id_*"
- "{HOME}/.ssh/id_*.pub"
rw:
- "{HOME}/.ssh/known_hosts"
network:
enabled: true
connect_tcp:
- "22"

10
cfg/tasks/systemd.yaml Normal file
View File

@ -0,0 +1,10 @@
# systemd tools (systemctl, journalctl, etc.)
filesystem:
ro:
- "/run/systemd"
- "/var/log/journal"
- "/etc/systemd"
network:
enabled: true
connect_tcp:
- "9001" # systemd socket activation (if needed)

View File

@ -13,14 +13,20 @@ var (
ErrBackendNotFound = errors.New("backend not found")
)
// SessionOptions contains options for creating a session
type SessionOptions struct {
CWD string
Role string
Tasks []string
}
// Backend represents any chat backend (CLI tool via PTY, or direct API)
type Backend interface {
// Name returns the backend name
Name() string
// CreateSession creates a new session instance
// cwd is the working directory (may be ignored by API backends)
CreateSession(ctx context.Context, cwd string) (Session, error)
CreateSession(ctx context.Context, opts SessionOptions) (Session, error)
}
// Session represents an active conversation session
@ -66,6 +72,12 @@ type Session interface {
// SetAlias sets a user-friendly alias for the session
SetAlias(alias string)
// Role returns the session role (empty if not specified)
Role() string
// Tasks returns the session tasks (empty if not specified)
Tasks() []string
}
// SessionMetadata provides information about a session
@ -75,6 +87,7 @@ type SessionMetadata struct {
Alias string // User-assigned alias
Backend string // Backend name (e.g., "kiro-cli", "claude")
CreatedAt time.Time // Session creation time
WinID int // Acme window ID (0 if not applicable)
Extra map[string]string // Backend-specific metadata
}

View File

@ -22,6 +22,9 @@ type Session struct {
windowName string // window name within tmux session (same as id)
cwd string
alias string
role string // session role (e.g., "developer", "reviewer")
tasks []string // session tasks (e.g., ["git", "docker"])
winID int // Acme window ID (0 if not applicable)
pid int
state string
context string // prepended to every prompt
@ -182,6 +185,7 @@ func (s *Session) Metadata() backend.SessionMetadata {
Alias: s.alias,
Backend: s.backendName,
CreatedAt: s.createdAt,
WinID: s.winID,
Extra: map[string]string{
"tmux_session": s.tmuxSession,
"tmux_window": s.windowName,
@ -193,6 +197,20 @@ func (s *Session) Commands() backend.CommandHandler {
return s.commands
}
// Role returns the session role
func (s *Session) Role() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.role
}
// Tasks returns the session tasks
func (s *Session) Tasks() []string {
s.mu.Lock()
defer s.mu.Unlock()
return s.tasks
}
// GetPid gets the process ID
func (s *Session) GetPid() int {
s.mu.Lock()
@ -214,6 +232,13 @@ func (s *Session) GetContext() string {
return s.context
}
// SetWinID sets the Acme window ID
func (s *Session) SetWinID(winID int) {
s.mu.Lock()
defer s.mu.Unlock()
s.winID = winID
}
// reader continuously reads from FIFO and sends to dataCh
func (s *Session) reader() {
// Capture the reader generation at start to detect if we've been superseded

View File

@ -109,19 +109,63 @@ func (b *Backend) Name() string {
return b.cfg.Name
}
func (b *Backend) CreateSession(ctx context.Context, cwd string) (backend.Session, error) {
func (b *Backend) CreateSession(ctx context.Context, opts backend.SessionOptions) (backend.Session, error) {
id := generateID()
windowName := id // Use session ID as window name
debug.Log("[session %s] creating window in tmux session %s", id, b.tmuxSession)
debug.Log("[session %s] creating window in tmux session %s (role=%s, tasks=%v)", id, b.tmuxSession, opts.Role, opts.Tasks)
// Load sandbox configuration fresh for each session
sandboxCfg, err := sandbox.Load()
// Build layered sandbox configuration
// Start with global.yaml as base
baseCfg, err := sandbox.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading sandbox config: %v (using defaults)\n", err)
debug.Log("[session %s] failed to load sandbox config: %v, using defaults", id, err)
sandboxCfg = sandbox.DefaultConfig()
return nil, fmt.Errorf("failed to load global config: %w", err)
}
// Convert base config to layered format
baseLayer := sandbox.LayeredConfig{
Filesystem: baseCfg.Filesystem,
Network: baseCfg.Network,
Env: baseCfg.Env,
}
layers := []sandbox.LayeredConfig{baseLayer}
backendLayer, err := sandbox.LoadBackend(b.cfg.Name)
if err != nil {
return nil, fmt.Errorf("failed to load backend config %q: %w", b.cfg.Name, err)
}
layers = append(layers, backendLayer)
// Add role layer if specified, otherwise use "default" role
role := opts.Role
if role == "" {
role = sandbox.DefaultRole
}
roleLayer, err := sandbox.LoadRole(role)
if err != nil {
return nil, fmt.Errorf("failed to load role %q: %w", role, err)
}
layers = append(layers, roleLayer)
// Add task layers
for _, taskName := range opts.Tasks {
taskLayer, err := sandbox.LoadTask(taskName)
if err != nil {
return nil, fmt.Errorf("failed to load task %q: %w", taskName, err)
}
layers = append(layers, taskLayer)
}
// Merge layers into final config
general := sandbox.GeneralConfig{
BestEffort: false,
LogLevel: "error",
}
advanced := sandbox.AdvancedConfig{
LDD: false,
AddExec: true,
}
sandboxCfg := sandbox.Merge(general, advanced, layers...)
// 1. Ensure persistent tmux session exists
if err := b.ensureTmuxSession(); err != nil {
@ -187,11 +231,11 @@ func (b *Backend) CreateSession(ctx context.Context, cwd string) (backend.Sessio
killWindow(b.tmuxSession, windowName)
fmt.Fprintf(os.Stderr, "ERROR: landrun not available and sandboxing is required\n")
fmt.Fprintf(os.Stderr, "Install landrun: go install github.com/landlock-lsm/landrun@latest\n")
fmt.Fprintf(os.Stderr, "Or enable best_effort mode in ~/.config/anvillm/sandbox.yaml (NOT RECOMMENDED)\n")
fmt.Fprintf(os.Stderr, "Or enable best_effort mode in ~/.config/anvillm/global.yaml (NOT RECOMMENDED)\n")
return nil, fmt.Errorf("landrun not available")
}
}
command = sandbox.WrapCommand(sandboxCfg, command, cwd)
command = sandbox.WrapCommand(sandboxCfg, command, opts.CWD)
debug.Log("[session %s] sandboxed command: %v", id, command)
// 8. Build command string for tmux
@ -209,7 +253,7 @@ func (b *Backend) CreateSession(ctx context.Context, cwd string) (backend.Sessio
}
// Change to working directory first
if err := sendKeys(target, fmt.Sprintf("cd \"%s\"", cwd), "C-m"); err != nil {
if err := sendKeys(target, fmt.Sprintf("cd \"%s\"", opts.CWD), "C-m"); err != nil {
os.Remove(fifoPath)
killWindow(b.tmuxSession, windowName)
return nil, fmt.Errorf("failed to change directory: %w", err)
@ -247,7 +291,7 @@ func (b *Backend) CreateSession(ctx context.Context, cwd string) (backend.Sessio
// Find the actual backend process (child of bash shell)
pid := 0
if panePID != 0 {
time.Sleep(200 * time.Millisecond) // Brief delay for backend to start
time.Sleep(500 * time.Millisecond) // Brief delay for backend to start
pid = FindBackendPID(panePID)
if pid == 0 {
debug.Log("[session %s] warning: backend process not found for pane PID %d", id, panePID)
@ -261,7 +305,9 @@ func (b *Backend) CreateSession(ctx context.Context, cwd string) (backend.Sessio
backendName: b.cfg.Name,
tmuxSession: b.tmuxSession,
windowName: windowName,
cwd: cwd,
cwd: opts.CWD,
role: role,
tasks: opts.Tasks,
pid: pid,
state: "starting",
createdAt: time.Now(),

View File

@ -72,16 +72,19 @@ const (
fileAlias
fileBackend
fileContext
fileRole
fileTasks
fileCount
)
var fileNames = []string{"ctl", "in", "out", "log", "state", "pid", "cwd", "alias", "backend", "context"}
var fileNames = []string{"ctl", "in", "out", "log", "state", "pid", "cwd", "alias", "backend", "context", "role", "tasks"}
type Server struct {
mgr *session.Manager
listener net.Listener
socketPath string
mu sync.RWMutex
mgr *session.Manager
listener net.Listener
socketPath string
OnAliasChange func(backend.Session) // Called when session alias changes
mu sync.RWMutex
}
type connState struct {
@ -322,12 +325,32 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
fmt.Fprintf(os.Stderr, "[DEBUG] ctl write: input=%q args=%v len=%d fc.Count=%d len(fc.Data)=%d\n",
input, args, len(args), fc.Count, len(fc.Data))
if len(args) < 2 || args[0] != "new" {
return errFcall(fc, "usage: new <backend> <cwd>")
return errFcall(fc, "usage: new <backend> <cwd> [role=<role>] [tasks=<task1,task2>]")
}
backendName := args[1]
cwd, _ := os.Getwd()
if len(args) > 2 {
cwd = strings.Trim(args[2], `"`)
var role string
var tasks []string
// Parse remaining arguments: first non-key=value is cwd, rest are options
cwdSet := false
for i := 2; i < len(args); i++ {
arg := args[i]
if strings.HasPrefix(arg, "role=") {
role = strings.TrimPrefix(arg, "role=")
} else if strings.HasPrefix(arg, "tasks=") {
taskStr := strings.TrimPrefix(arg, "tasks=")
if taskStr != "" {
tasks = strings.Split(taskStr, ",")
}
} else if !cwdSet {
// First positional argument is cwd
cwd = strings.Trim(arg, `"`)
cwdSet = true
} else {
return errFcall(fc, fmt.Sprintf("unexpected argument: %s", arg))
}
}
// Validate and clean the path
@ -349,7 +372,12 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
return errFcall(fc, fmt.Sprintf("path is not a directory: %s", cleanPath))
}
_, err := s.mgr.New(backendName, cleanPath)
opts := backend.SessionOptions{
CWD: cleanPath,
Role: role,
Tasks: tasks,
}
_, err := s.mgr.New(opts, backendName)
if err != nil {
return errFcall(fc, err.Error())
}
@ -425,6 +453,9 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
return errFcall(fc, "invalid alias: must match [A-Za-z0-9_-]+")
}
sess.SetAlias(input)
if s.OnAliasChange != nil {
s.OnAliasChange(sess)
}
return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))}
}
@ -651,6 +682,10 @@ func (s *Server) getSessionFile(sess backend.Session, idx int) string {
return tmuxSess.GetContext()
}
return ""
case fileRole:
return sess.Role()
case fileTasks:
return strings.Join(sess.Tasks(), ",")
}
return ""
}

View File

@ -1,8 +1,10 @@
package sandbox
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"gopkg.in/yaml.v3"
@ -17,6 +19,13 @@ type Config struct {
Advanced AdvancedConfig `yaml:"advanced"`
}
// LayeredConfig represents a single layer (system/backend/role/task)
type LayeredConfig struct {
Filesystem FilesystemConfig `yaml:"filesystem"`
Network NetworkConfig `yaml:"network"`
Env []string `yaml:"env"`
}
// GeneralConfig contains general sandbox settings
type GeneralConfig struct {
BestEffort bool `yaml:"best_effort"`
@ -101,7 +110,7 @@ func DefaultConfig() *Config {
// ConfigPath returns the path to the config file
func ConfigPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "anvillm", "sandbox.yaml")
return filepath.Join(home, ".config", "anvillm", "global.yaml")
}
// Load loads the config from disk, creating defaults if it doesn't exist
@ -167,11 +176,303 @@ func Save(cfg *Config) error {
return os.WriteFile(path, data, 0644)
}
// DefaultRole is the role used when none is specified
const DefaultRole = "default"
// validateName checks if a role/task name is safe for use in file paths
func validateName(name string) error {
if name == "" {
return fmt.Errorf("name cannot be empty")
}
if len(name) > 64 {
return fmt.Errorf("name too long (max 64 characters)")
}
// Only allow alphanumeric, hyphen, and underscore
for _, r := range name {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_') {
return fmt.Errorf("name contains invalid character: %c (only alphanumeric, hyphen, underscore allowed)", r)
}
}
return nil
}
// expandPath replaces template variables with actual values
func expandPath(pattern, cwd, home, tmpdir string) string {
func expandPath(pattern, cwd string) string {
s := pattern
// CWD is special - passed as parameter, not from env
s = strings.ReplaceAll(s, "{CWD}", cwd)
s = strings.ReplaceAll(s, "{HOME}", home)
s = strings.ReplaceAll(s, "{TMPDIR}", tmpdir)
// Generic environment variable expansion: {VAR_NAME}
re := regexp.MustCompile(`\{([A-Z_][A-Z0-9_]*)\}`)
s = re.ReplaceAllStringFunc(s, func(match string) string {
varName := match[1 : len(match)-1] // Strip { and }
// Special handling with defaults
home, _ := os.UserHomeDir()
switch varName {
case "HOME":
if home != "" {
return home
}
case "TMPDIR":
if tmpdir := os.Getenv("TMPDIR"); tmpdir != "" {
return tmpdir
}
return "/tmp"
case "XDG_CONFIG_HOME":
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return xdg
}
return filepath.Join(home, ".config")
case "XDG_DATA_HOME":
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
return xdg
}
return filepath.Join(home, ".local/share")
case "XDG_CACHE_HOME":
if xdg := os.Getenv("XDG_CACHE_HOME"); xdg != "" {
return xdg
}
return filepath.Join(home, ".cache")
case "XDG_STATE_HOME":
if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
return xdg
}
return filepath.Join(home, ".local/state")
case "XDG_RUNTIME_DIR":
if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" {
return xdg
}
return fmt.Sprintf("/run/user/%d", os.Getuid())
case "XDG_DOCUMENTS_DIR":
if xdg := os.Getenv("XDG_DOCUMENTS_DIR"); xdg != "" {
return xdg
}
return filepath.Join(home, "Documents")
case "XDG_DOWNLOAD_DIR":
if xdg := os.Getenv("XDG_DOWNLOAD_DIR"); xdg != "" {
return xdg
}
return filepath.Join(home, "Downloads")
case "XDG_MUSIC_DIR":
if xdg := os.Getenv("XDG_MUSIC_DIR"); xdg != "" {
return xdg
}
return filepath.Join(home, "Music")
case "XDG_PICTURES_DIR":
if xdg := os.Getenv("XDG_PICTURES_DIR"); xdg != "" {
return xdg
}
return filepath.Join(home, "Pictures")
case "XDG_VIDEOS_DIR":
if xdg := os.Getenv("XDG_VIDEOS_DIR"); xdg != "" {
return xdg
}
return filepath.Join(home, "Videos")
}
// Generic env var lookup
if val := os.Getenv(varName); val != "" {
return val
}
return match // Keep original if env var not set
})
return s
}
// SystemDefaults returns the system-level defaults (restrictive baseline)
func SystemDefaults() LayeredConfig {
return LayeredConfig{
Filesystem: FilesystemConfig{
RO: []string{
"/etc/passwd",
"/dev/null",
"/proc/meminfo",
"/proc/self/cgroup",
"/proc/self/maps",
"/proc/version",
},
ROX: []string{
"/usr",
"/lib",
"/lib64",
"/bin",
"/sbin",
},
RW: []string{
"{TMPDIR}",
},
},
Network: NetworkConfig{
Enabled: false,
Unrestricted: false,
},
Env: []string{
"HOME",
"USER",
"PATH",
"LANG",
"TERM",
},
}
}
// LoadBackend loads backend-specific config
func LoadBackend(name string) (LayeredConfig, error) {
if err := validateName(name); err != nil {
return LayeredConfig{}, fmt.Errorf("invalid backend name: %w", err)
}
home, _ := os.UserHomeDir()
path := filepath.Join(home, ".config", "anvillm", "backends", name+".yaml")
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return LayeredConfig{}, fmt.Errorf("backend %q not found (looked in %s)", name, path)
}
if err != nil {
return LayeredConfig{}, err
}
var cfg LayeredConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return LayeredConfig{}, fmt.Errorf("parse %s: %w", path, err)
}
return cfg, nil
}
// LoadRole loads role-specific config
func LoadRole(name string) (LayeredConfig, error) {
if err := validateName(name); err != nil {
return LayeredConfig{}, fmt.Errorf("invalid role name: %w", err)
}
home, _ := os.UserHomeDir()
path := filepath.Join(home, ".config", "anvillm", "roles", name+".yaml")
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return LayeredConfig{}, fmt.Errorf("role %q not found (looked in %s)", name, path)
}
if err != nil {
return LayeredConfig{}, err
}
var cfg LayeredConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return LayeredConfig{}, fmt.Errorf("parse %s: %w", path, err)
}
return cfg, nil
}
// LoadTask loads task-specific config
func LoadTask(name string) (LayeredConfig, error) {
if err := validateName(name); err != nil {
return LayeredConfig{}, fmt.Errorf("invalid task name: %w", err)
}
home, _ := os.UserHomeDir()
path := filepath.Join(home, ".config", "anvillm", "tasks", name+".yaml")
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return LayeredConfig{}, fmt.Errorf("task %q not found (looked in %s)", name, path)
}
if err != nil {
return LayeredConfig{}, err
}
var cfg LayeredConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return LayeredConfig{}, fmt.Errorf("parse %s: %w", path, err)
}
return cfg, nil
}
// Merge combines multiple layers into a final Config (most permissive wins)
func Merge(general GeneralConfig, advanced AdvancedConfig, layers ...LayeredConfig) *Config {
cfg := &Config{
General: general,
Advanced: advanced,
Filesystem: FilesystemConfig{
RO: []string{},
ROX: []string{},
RW: []string{},
RWX: []string{},
},
Network: NetworkConfig{
Enabled: false,
Unrestricted: false,
},
Env: []string{},
}
// Track paths by permission level (higher = more permissive)
pathPerms := make(map[string]int) // 0=none, 1=ro, 2=rox, 3=rw, 4=rwx
envSet := make(map[string]bool)
for _, layer := range layers {
// Merge filesystem permissions (most permissive wins)
for _, p := range layer.Filesystem.RO {
if pathPerms[p] < 1 {
pathPerms[p] = 1
}
}
for _, p := range layer.Filesystem.ROX {
if pathPerms[p] < 2 {
pathPerms[p] = 2
}
}
for _, p := range layer.Filesystem.RW {
if pathPerms[p] < 3 {
pathPerms[p] = 3
}
}
for _, p := range layer.Filesystem.RWX {
if pathPerms[p] < 4 {
pathPerms[p] = 4
}
}
// Merge network (most permissive wins)
if layer.Network.Enabled {
cfg.Network.Enabled = true
}
if layer.Network.Unrestricted {
cfg.Network.Unrestricted = true
}
cfg.Network.BindTCP = append(cfg.Network.BindTCP, layer.Network.BindTCP...)
cfg.Network.ConnectTCP = append(cfg.Network.ConnectTCP, layer.Network.ConnectTCP...)
// Merge environment (union)
for _, e := range layer.Env {
envSet[e] = true
}
}
// Convert path permissions back to lists
for path, perm := range pathPerms {
switch perm {
case 1:
cfg.Filesystem.RO = append(cfg.Filesystem.RO, path)
case 2:
cfg.Filesystem.ROX = append(cfg.Filesystem.ROX, path)
case 3:
cfg.Filesystem.RW = append(cfg.Filesystem.RW, path)
case 4:
cfg.Filesystem.RWX = append(cfg.Filesystem.RWX, path)
}
}
// Convert env set to list
for e := range envSet {
cfg.Env = append(cfg.Env, e)
}
return cfg
}

View File

@ -25,12 +25,6 @@ func WrapCommand(cfg *Config, originalCmd []string, cwd string) []string {
return originalCmd
}
home, _ := os.UserHomeDir()
tmpdir := os.Getenv("TMPDIR")
if tmpdir == "" {
tmpdir = "/tmp"
}
args := []string{"landrun"}
// Log level
@ -54,25 +48,25 @@ func WrapCommand(cfg *Config, originalCmd []string, cwd string) []string {
// Collect all filesystem paths
var entries []pathEntry
for _, path := range cfg.Filesystem.RO {
expanded := expandPath(path, cwd, home, tmpdir)
expanded := expandPath(path, cwd)
if pathExists(expanded) {
entries = append(entries, pathEntry{expanded, "--ro"})
}
}
for _, path := range cfg.Filesystem.ROX {
expanded := expandPath(path, cwd, home, tmpdir)
expanded := expandPath(path, cwd)
if pathExists(expanded) {
entries = append(entries, pathEntry{expanded, "--rox"})
}
}
for _, path := range cfg.Filesystem.RW {
expanded := expandPath(path, cwd, home, tmpdir)
expanded := expandPath(path, cwd)
if pathExists(expanded) {
entries = append(entries, pathEntry{expanded, "--rw"})
}
}
for _, path := range cfg.Filesystem.RWX {
expanded := expandPath(path, cwd, home, tmpdir)
expanded := expandPath(path, cwd)
if pathExists(expanded) {
entries = append(entries, pathEntry{expanded, "--rwx"})
}

View File

@ -24,7 +24,7 @@ func NewManager(backends map[string]backend.Backend) *Manager {
}
// New creates a new session in the given working directory using the specified backend
func (m *Manager) New(backendName, cwd string) (backend.Session, error) {
func (m *Manager) New(opts backend.SessionOptions, backendName string) (backend.Session, error) {
m.mu.RLock()
b, ok := m.backends[backendName]
m.mu.RUnlock()
@ -33,7 +33,7 @@ func (m *Manager) New(backendName, cwd string) (backend.Session, error) {
return nil, backend.ErrBackendNotFound
}
sess, err := b.CreateSession(context.Background(), cwd)
sess, err := b.CreateSession(context.Background(), opts)
if err != nil {
return nil, err
}

View File

@ -77,7 +77,7 @@ func main() {
defer srv.Close()
// Rename prompt window when alias changes
srv.OnAliasChange = func(sess Session) {
srv.OnAliasChange = func(sess backend.Session) {
meta := sess.Metadata()
if meta.WinID > 0 {
w, err := acme.Open(meta.WinID, nil)
@ -153,7 +153,8 @@ func main() {
fmt.Fprintf(os.Stderr, "Error: Kiro requires a path argument\n")
continue
}
_, err := mgr.New("kiro-cli", arg)
opts := backend.SessionOptions{CWD: arg}
_, err := mgr.New(opts, "kiro-cli")
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
continue
@ -165,7 +166,8 @@ func main() {
fmt.Fprintf(os.Stderr, "Error: Claude requires a path argument\n")
continue
}
_, err := mgr.New("claude", arg)
opts := backend.SessionOptions{CWD: arg}
_, err := mgr.New(opts, "claude")
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
continue

8
mkfile
View File

@ -10,12 +10,14 @@ build:V:
cp scripts/Alog $INSTALL_PATH/Alog
chmod 0755 $INSTALL_PATH/Alog
mkdir -p $INSTALL_PATH/Workflows
cp scripts/DevReview $INSTALL_PATH/Workflows/DevReview
cp workflows/DevReview $INSTALL_PATH/Workflows/DevReview
chmod 0755 $INSTALL_PATH/Workflows/DevReview
cp scripts/DevReviewQA $INSTALL_PATH/Workflows/DevReviewQA
cp workflows/DevReviewQA $INSTALL_PATH/Workflows/DevReviewQA
chmod 0755 $INSTALL_PATH/Workflows/DevReviewQA
cp scripts/Planning $INSTALL_PATH/Workflows/Planning
cp workflows/Planning $INSTALL_PATH/Workflows/Planning
chmod 0755 $INSTALL_PATH/Workflows/Planning
mkdir -p $HOME/.config/anvillm
cp -rf cfg/* $HOME/.config/anvillm/
install:V: build

View File

@ -31,22 +31,18 @@ fi
AGENT_MOUNT="agent"
# Helper to create session and capture ID
create_session() {
local backend="$1" cwd="$2"
local before after
before=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
echo "new $backend $cwd" | 9p write $AGENT_MOUNT/ctl
after=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
comm -13 <(echo "$before") <(echo "$after") | head -1
}
echo "Creating developer session..."
DEV_ID=$(create_session "$BACKEND" "$PROJECT_PATH")
before=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
echo "new $BACKEND $PROJECT_PATH role=developer tasks=git,editor,go,rust,python,nodejs,docker,podman,maven,gradle,plan9,gpg,github-cli" | 9p write $AGENT_MOUNT/ctl
after=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
DEV_ID=$(comm -13 <(echo "$before") <(echo "$after") | head -1)
echo "Developer session ID: $DEV_ID"
echo "Creating reviewer session..."
REVIEWER_ID=$(create_session "$BACKEND" "$PROJECT_PATH")
before=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
echo "new $BACKEND $PROJECT_PATH role=reviewer tasks=git,github-cli" | 9p write $AGENT_MOUNT/ctl
after=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
REVIEWER_ID=$(comm -13 <(echo "$before") <(echo "$after") | head -1)
echo "Reviewer session ID: $REVIEWER_ID"
# Set aliases

View File

@ -31,26 +31,25 @@ fi
AGENT_MOUNT="agent"
# Helper to create session and capture ID
create_session() {
local backend="$1" cwd="$2"
local before after
before=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
echo "new $backend $cwd" | 9p write $AGENT_MOUNT/ctl
after=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
comm -13 <(echo "$before") <(echo "$after") | head -1
}
echo "Creating developer session..."
DEV_ID=$(create_session "$BACKEND" "$PROJECT_PATH")
before=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
echo "new $BACKEND $PROJECT_PATH role=developer tasks=git,editor,go,rust,python,nodejs,docker,podman,maven,gradle,plan9,gpg,github-cli" | 9p write $AGENT_MOUNT/ctl
after=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
DEV_ID=$(comm -13 <(echo "$before") <(echo "$after") | head -1)
echo "Developer session ID: $DEV_ID"
echo "Creating reviewer session..."
REVIEWER_ID=$(create_session "$BACKEND" "$PROJECT_PATH")
before=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
echo "new $BACKEND $PROJECT_PATH role=reviewer tasks=git,github-cli" | 9p write $AGENT_MOUNT/ctl
after=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
REVIEWER_ID=$(comm -13 <(echo "$before") <(echo "$after") | head -1)
echo "Reviewer session ID: $REVIEWER_ID"
echo "Creating QA session..."
QA_ID=$(create_session "$BACKEND" "$PROJECT_PATH")
before=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
echo "new $BACKEND $PROJECT_PATH role=tester tasks=git,github-cli" | 9p write $AGENT_MOUNT/ctl
after=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
QA_ID=$(comm -13 <(echo "$before") <(echo "$after") | head -1)
echo "QA session ID: $QA_ID"
# Set aliases

View File

@ -1,5 +1,5 @@
#!/bin/bash
# Planning - Research/Engineering/Tech-Editor workflow
# Planning - Research/Architect/Techeditor workflow
# Usage: ./scripts/Planning <backend> <workdir>
set -e
@ -26,31 +26,30 @@ fi
AGENT="agent"
# Helper to create session and capture ID
create_session() {
local backend="$1" cwd="$2"
local before after
before=$(9p read $AGENT/list | awk '{print $1}' | sort)
echo "new $backend $cwd" | 9p write $AGENT/ctl
after=$(9p read $AGENT/list | awk '{print $1}' | sort)
comm -13 <(echo "$before") <(echo "$after") | head -1
}
echo "Creating research session..."
RESEARCH_ID=$(create_session "$BACKEND" "$DOC_PATH")
before=$(9p read $AGENT/list | awk '{print $1}' | sort)
echo "new $BACKEND $DOC_PATH role=research" | 9p write $AGENT/ctl
after=$(9p read $AGENT/list | awk '{print $1}' | sort)
RESEARCH_ID=$(comm -13 <(echo "$before") <(echo "$after") | head -1)
echo " ID: $RESEARCH_ID"
echo "Creating engineering session..."
ENG_ID=$(create_session "$BACKEND" "$DOC_PATH")
echo " ID: $ENG_ID"
echo "Creating architect session..."
before=$(9p read $AGENT/list | awk '{print $1}' | sort)
echo "new $BACKEND $DOC_PATH role=architect tasks=git" | 9p write $AGENT/ctl
after=$(9p read $AGENT/list | awk '{print $1}' | sort)
ARCH_ID=$(comm -13 <(echo "$before") <(echo "$after") | head -1)
echo " ID: $ARCH_ID"
echo "Creating tech-editor session..."
EDITOR_ID=$(create_session "$BACKEND" "$DOC_PATH")
echo "Creating techeditor session..."
before=$(9p read $AGENT/list | awk '{print $1}' | sort)
echo "new $BACKEND $DOC_PATH role=techeditor tasks=git" | 9p write $AGENT/ctl
after=$(9p read $AGENT/list | awk '{print $1}' | sort)
EDITOR_ID=$(comm -13 <(echo "$before") <(echo "$after") | head -1)
echo " ID: $EDITOR_ID"
# Set aliases
echo "${ALIAS_PREFIX}-research" | 9p write $AGENT/$RESEARCH_ID/alias
echo "${ALIAS_PREFIX}-engineering" | 9p write $AGENT/$ENG_ID/alias
echo "${ALIAS_PREFIX}-architect" | 9p write $AGENT/$ARCH_ID/alias
echo "${ALIAS_PREFIX}-techeditor" | 9p write $AGENT/$EDITOR_ID/alias
# Research context
@ -62,15 +61,15 @@ Your job is to research system architecture and code for requested information.
- Prefer updating existing kb documents over creating new ones
- Only create new KB documents if the knowledge discovered is significant and reusable
When you receive a query from engineering, research and respond:
- Use the peer-bot skill to find and communicate with the engineering agent (alias contains "engineering")
- Send your answer back to engineering
When you receive a query from the architect, research and respond:
- Use the peer-bot skill to find and communicate with the architect agent (alias contains "architect")
- Send your answer back to the architect
Do not actively await responses, but stop working until you receive an explicit request to conduct research.
EOF
# Engineering context
cat <<EOF | 9p write $AGENT/$ENG_ID/context
# Architect context
cat <<EOF | 9p write $AGENT/$ARCH_ID/context
Your job is to update design documentation in $DOC_PATH. Follow agent-kb conventions. Search the knowledge base for relevant documents.
If documentation is insufficient, query the research bot:
@ -78,23 +77,23 @@ If documentation is insufficient, query the research bot:
- Include in your query: the question, context, and request for full path to new/updated docs or a direct answer
- Wait for the research agent to send the answer back
You will receive edit requests from the tech-editor:
- Use the peer-bot skill to find and communicate with the tech-editor agent (alias contains "techeditor")
You will receive edit requests from the techeditor:
- Use the peer-bot skill to find and communicate with the techeditor agent (alias contains "techeditor")
- Apply the requested changes and send acknowledgment back
Do not actively await responses, but stop working until you receive an explicit request to make a change from the tech-editor.
Do not actively await responses, but stop working until you receive an explicit request to make a change from the techeditor.
EOF
# Tech-editor context
cat <<EOF | 9p write $AGENT/$EDITOR_ID/context
You are a professional technical writer. Ensure documentation in $DOC_PATH is high quality, readable, and at the right detail level for software engineers.
Do not edit documents directly. Send recommended changes to engineering:
- Use the peer-bot skill to find and communicate with the engineering agent (alias contains "engineering")
Do not edit documents directly. Send recommended changes to the architect:
- Use the peer-bot skill to find and communicate with the architect agent (alias contains "architect")
- Include your recommended changes
- Request acknowledgment from engineering
- Request acknowledgment from the architect
When engineering acknowledges, proofread again. Iterate until satisfied.
When the architect acknowledges, proofread again. Iterate until satisfied.
Do not actively await responses, but stop working until you receive an explicit request to perform work.
EOF
@ -103,8 +102,8 @@ echo ""
echo "✓ Planning workflow ready ($ALIAS_PREFIX)"
echo ""
echo "Sessions:"
echo " ${ALIAS_PREFIX}-research: $RESEARCH_ID"
echo " ${ALIAS_PREFIX}-engineering: $ENG_ID"
echo " ${ALIAS_PREFIX}-techeditor: $EDITOR_ID"
echo " ${ALIAS_PREFIX}-research: $RESEARCH_ID"
echo " ${ALIAS_PREFIX}-architect: $ARCH_ID"
echo " ${ALIAS_PREFIX}-techeditor: $EDITOR_ID"
echo ""
echo "Start by giving engineering a task, or tech-editor a document to review."
echo "Start by giving the architect a task, or techeditor a document to review."