layered-soul/skills/colibri-development/SKILL.md
Sam & Claude 4d8ce07fa7 docs: apply Prettier to current markdown (Sam & Codex)
Normalize markdown formatting after the latest main updates.\n\nChecks: python3 scripts/layered_soul.py validate .; npx --yes prettier@3 --check '**/*.md'; git diff --check.
2026-06-14 01:48:32 +02:00

20 KiB

name description
colibri-development Develop in the Colibri Rust workspace — add crates, run tests, write integration tests against glasspane/daemon/client APIs, build the TUI.

Colibri Development

Trigger: user asks to work on /home/samob/ai/colibri — add a crate, write a test, build the TUI, or interact with the glasspane/daemon/client stack.

Workspace Layout (9 crates)

crates/
  colibri-contracts       — shared types, schemas, wire contracts
  colibri-deepseek       — DeepSeek API probe + cache key logic
  colibri-runtime        — runtime harness (pi, session, heartbeat)
  colibri-glasspane      — agent state machine, PiJsonlIngestor, GlasspaneSnapshot
  colibri-daemon         — always-on daemon: socket server, spawner, session manager,
                            scheduler, cost discipline
  colibri-client         — typed Unix-socket client for daemon (DaemonClient)
  colibri-glasspane-tui  — ratatui terminal dashboard (colibri-tui binary)
  colibri-store          — SQLite coordination store (task board, agents, skills)
  colibri-skills         — read-only skill consumer (scaffold, Phase 1 only)
(root)                   — colibri-probe + colibri-runtime-inventory binaries

Adding a New Crate

  1. Create crates/<name>/Cargo.toml with edition = "2021". For binaries use [[bin]], for libraries use [lib].
  2. Add to workspace Cargo.toml members array (comma-separated, keep alphabetical).
  3. cargo build --package <name> to verify.
  4. cargo test --workspace to confirm nothing broke.
  5. Commit with -c user.name="Sam & Hermes".

Pitfall: the lint tool runs syntax checks without reading Cargo.toml, so it may flag async fn as "not permitted in Rust 2015". Ignore these — the build with edition = "2021" works correctly.

Build & Test

cargo fmt --all                                       # auto-format before committing
cargo build --workspace --release                     # full release build
cargo test --workspace                                 # all tests (baseline: 151, 2026-06-04)
cargo clippy --workspace --all-targets -- -D warnings  # must be clean before commit
cargo test --package colibri-store                     # specific crate
cargo build --package colibri-glasspane-tui --release  # just the TUI binary

Pre-FreeBSD Linux check: Before handing off to the FreeBSD/OSA build host, run cargo build --workspace --release && cargo test --workspace on the Linux dev host (Debian, rustc 1.95+). This catches Rust-level compilation errors (missing enum variants, type mismatches, edition errors) that would also block the FreeBSD build — without needing FreeBSD tools. See references/linux-precheck.md.

Integration Test Patterns

Adding a coordination crate (store, scheduler, etc.)

See references/store-integration.md for the step-by-step integration pattern used for colibri-store: config extension, DaemonState wiring, Mutex for !Sync types, socket command dispatch, and updating all config constructors.

See references/scheduler-integration.md for the scheduler module pattern: cron/interval/one-shot jobs, leader/delegate capability matching, intake queue, and the 4th daemon loop tick.

See references/freebsd-daemon-smoke.md for the FreeBSD /tmp daemon smoke test pattern, rc.d service file conventions, and session report format.

Against PiJsonlIngestor (glasspane state machine)

The ingestor uses Default, not new(). It ingests line-by-line via ingest_line_at(line, system_time), not by whole JSONL string.

use colibri_glasspane::{PiJsonlIngestor, AgentState, GlasspaneSnapshot, Pane};
use std::time::SystemTime;

let mut ingestor = PiJsonlIngestor::default();
let t = SystemTime::now();
ingestor.ingest_line_at(r#"{"type":"session","id":"s1"}"#, t);
// state is now idle (session alone doesn't transition)
ingestor.ingest_line_at(r#"{"type":"turn_start"}"#, t);
assert_eq!(ingestor.state(), AgentState::Working);

Key types:

  • Pane { id, agent, state, pi_session_id, last_event_at, cwd, stalled } — note id not pane_id, pi_session_id not session_id
  • GlasspaneSnapshot { schema, host, observed_at, panes } — use GlasspaneSnapshot::new(host, observed_at, panes)
  • GlasspaneSnapshot::count(state) — count panes in a given state
  • GlasspaneSnapshot::stalled_count() — count stalled panes

Against DaemonClient (socket client)

use colibri_client::DaemonClient;
let client = DaemonClient::new("/tmp/colibri-daemon.sock");
let snapshot: GlasspaneSnapshot = client.glasspane_snapshot().await?;

Requires a running daemon or a mock UnixListener in tests.

TUI Binary

cargo run --bin colibri-tui -- /tmp/colibri-daemon.sock

Hotkeys: q/Esc quit, r manual refresh, j/k scroll pane list. Auto-refresh every 2s.

Common Pitfalls

  • edition = "2021" is mandatory in every crate's Cargo.toml — omitting it gives cryptic "async fn not permitted in Rust 2015" errors from rust-analyzer and the lint tool. These are false positives from the linter not reading Cargo.toml; the actual build with edition = "2021" works correctly.

  • PiJsonlIngestor has no new() — use Default::default().

  • Pane field is id, not pane_id. GlasspaneSnapshot requires schema, host, observed_at in addition to panes.

  • Serialized state values are lowercase ("working", not "Working").

  • row_highlight_style (not highlight_style) in ratatui 0.30 Table.

  • Not all Pi event types map to state transitions. tool_error, for example, keeps the current state. Unknown event types are silently ignored (forward-compatible). See references/pi-event-state-mapping.md for the full event→state table.

  • Integration tests go in crates/<crate>/tests/<name>.rs — these compile as separate test binaries and are NOT run with cargo test --package <crate> unless explicitly targeted.

  • rusqlite !Sync: rusqlite::Connection contains a RefCell and is not Sync. When adding a Store (or any type wrapping rusqlite::Connection) to DaemonState (which is behind Arc), wrap it in std::sync::Mutex<Store>. All access requires .lock().unwrap().

  • daemon::run_loop must be started from main.rs: The scheduler tick, heartbeat, session rotation, and memory handoff all live inside daemon::run_loop. If main.rs only spawns the socket server but never spawns daemon::run_loop, intake-task commands queue into the scheduler's in-memory buffer but never get processed into SQLite tasks. Always verify that main.rs calls both socket::serve AND daemon::run_loop as separate tokio tasks.

  • Scheduler store deadlock: Scheduler::tick() must NOT hold state.store.lock() across a branch that also tries to lock the store. The intake drain loop and job-firing loop both access the store — structure the code so the store lock is released before the next lock attempt. If list-tasks times out after intake-task, this is the root cause.

  • tokio::sync::Mutex for async state: When a Mutex guard is held across .await points (e.g. scheduler tick), use tokio::sync::Mutex — NOT std::sync::Mutex. The latter blocks the async runtime thread. The store uses std::sync::Mutex because its locks are always short-lived (no .await inside).

  • Bash heredoc corruption: When writing shell scripts containing function definitions that collide with common command names (e.g. sudo(), git()), do NOT use terminal with cat > file <<'EOF' — the parent bash shell may have completions/aliases that rewrite the content before the heredoc is written. Use write_file instead to write literal bytes directly.

  • SSH agent for git push: SSH agent does not survive tmux restarts. Run ssh-add ~/.ssh/codeberg-clawdie before git push/pull. If agent is dead: eval $(ssh-agent -s) && ssh-add ~/.ssh/codeberg-clawdie. Codeberg SSH sometimes drops connections — retry.

  • Accurate test counting: cargo test --workspace -- --list overcounts — it includes test helper modules alongside actual #[test] functions. Always count from cargo test --workspace output: sum the N passed numbers from each crate's test result: line. Do NOT use --list for status reports or handoffs.

  • DaemonConfig field additions: When adding a new field to DaemonConfig, every test_config() function in the codebase must be updated. Check socket.rs, scheduler.rs, live_socket_smoke.rs, and any other test that constructs DaemonConfig { ... }. Missing fields cause error[E0063]: missing field at build time. This bit us twice (db_path, cost_mode) — grep for DaemonConfig { before committing.

  • Cost discipline (cost.rs): Phase 5 module with CostMode enum (Fast/Smart/Max), escalation path, tool result compaction. Socket command: set-cost-mode. Config field: cost_mode: String (env: COLIBRI_COST_MODE, default "smart"). 9 unit tests in the module. Session thresholds change per mode: session_max_bytes() and max_uncompacted_turns() vary by 4-16x between Fast and Max.

  • UTF-8-safe string truncation: compact_tool_result originally sliced at a raw byte boundary (&raw[..max_bytes]), which panics when the cut lands inside a multi-byte UTF-8 character (a, o, s, c, z, CJK). FreeBSD tool output and agent logs regularly contain non-ASCII. Fix: use str::floor_char_boundary(max_bytes) to round down to the nearest valid character boundary before slicing. Always add a multibyte test string — e.g. "Cene ze se cesnje je".repeat(2000) for Slavic 2-byte chars (s/c/z), "aou".repeat(2000) for Germanic umlauts + CJK 3-byte chars. These catch both 2-byte and 3-byte boundary panics.

  • colibri CLI Command enum sync: The binary at crates/colibri-client/src/bin/colibri.rs defines Command in four places that MUST stay in sync: (1) the enum Command definition (variants + fields), (2) the parse_args() match arms that map CLI strings to enum variants, (3) the run() handler match that dispatches to DaemonClient methods, and (4) the usage() function text. When adding a subcommand (e.g. list-skills, register-skill), all four locations must be updated. Forgetting the enum definition causes 6+ compilation errors because both parse_args() and run() reference variants that don't exist. Always build immediately after adding a subcommand: cargo build --package colibri-client --release.

    This also fires after git pull — an upstream PR may add handlers in parse_args() and run() without the enum variants. The FreeBSD ISO build may not catch this if the crate wasn't rebuilt (binaries staged from a prior build), so the first Linux cargo build after a pull is the detection point. Example: PR #19 (fix/colibri-cli-list-skills) merged with ListSkills/RegisterSkill handlers wired in arg parser and run dispatcher, but the enum definition was missing — 151 other tests passed, build failed only on colibri-client. Run cargo build --workspace --release after every pull.

Post-Build Cleanup (mandatory)

After finishing a Colibri coding session AND BEFORE handing off to another agent:

cd ~/ai/colibri && cargo clean
rm -rf /tmp/colibri-daemon-socket-test-* /tmp/colibri-scheduler-test-* /tmp/colibri-live-smoke-* /tmp/colibri-osa-smoke-*

Why mandatory: cargo clean frees 6-8 GB of build artifacts. Stale /tmp test dirs accumulate ~34+ directories per session (socket tests create UUID-named temp dirs that tests don't always clean up on panic). Disk was at 91% before cleanup, 90% after. Leaving this for the next agent is disrespectful.

When to skip: If you're continuing work in the same tmux session within minutes — the build cache speeds up iteration. But clean before handoff, end-of-day, or if you won't rebuild within the hour.

This applies to ALL agents (Hermes, Claude, Codex) — the skill is shared.

Git Convention

git -c user.name="Sam & Hermes" -c user.email="hello@clawdie.si" commit -m "message"

Pre-commit hooks require Node.js (installed via nvm). When the hook fails:

export PATH="$HOME/.nvm/versions/node/v24.16.0/bin:$PATH"
git -c user.name="Sam & Hermes" -c user.email="hello@clawdie.si" commit -m "message"

If hooks still fail, --no-verify is acceptable — but the author and email MUST be set.

Cross-Repo Coordination

The Colibri repo has a sibling AGENTS.md at the repo root. When changing crate structure or adding integration contracts, update the design docs under docs/. The migration inventory (docs/MIGRATION-INVENTORY.md) tracks phase progress.

ISO integration workflow: see references/iso-integration.md for the ISO smoke checklist, rc.d conventions, cross-repo commit flow, and FreeBSD service layout.

Multi-agent handoff protocol: see references/multiagent-handoff.md for the handoff entry format, verification rules, relay pattern, platform split, and ISO build handoff specifics.

Pi spawn path testing

The spawn→JSONL→glasspane pipeline is proven via scripts/fake-pi-agent.py and crates/colibri-daemon/tests/pi_spawn_live.rs. The fake agent emits the colibri-pi-events JSONL taxonomy (session, agent_start, turn_start, turn_end, agent_end). For real Pi binary testing (when installed): set COLIBRI_PI_BINARY=pi and follow the same spawn pattern.

When writing spawn path tests:

  • Script path: CARGO_MANIFEST_DIR/../../scripts/fake-pi-agent.py (two .parent() calls from daemon crate)
  • Use tokio::process::Command for spawning
  • Stream stdout via BufReader::new(stdout).lines()
  • Verify AgentState::Done and pi_session_id in the snapshot
  • The test is at tests/pi_spawn_live.rs — integration test, not unit

T1.4 — Cache-first prompt discipline

The prompt assembly pipeline lives in:

Component File Purpose
PromptAssembly session.rs 3-region wrapper (immutable prefix, appendable log, volatile scratch)
CacheMetrics session.rs Per-session cache hit/miss tracking
CostMode cost.rs Fast/Smart/Max with per-mode budgets
EscalationTrigger cost.rs BudgetExceeded + CompactionInsufficient
trim_to_budget() session.rs Trim volatile scratch first, then oldest appendable entries
auto_escalate() cost.rs Fast→Smart→Max escalation, ceiling at Max

PR 1 (structs, no behavior change) and PR 2 (trimming + escalation) are merged. PR 3a (scheduler injection) and PR 3b (cache warming) are merged. All four T1.4 phases are complete on main.

Testing trim_to_budget: Use make_assembly(prefix_content, log_count, scratch_count) helper that creates messages with ~10KB content each (via x.repeat(10000)). Each message includes format!("msg {i} #N") to make them unique. Budget thresholds: Fast=500K, Smart=2M, Max=8M. To exceed Fast budget, use at least 80 log entries with 10KB content + large scratch. Verify with assert!(orig_bytes > CostMode::Fast.session_max_bytes()) before testing trim behavior.

Pitfall: The make_assembly helper in tests originally used format!("message {i}") for content (9-11 chars) — this makes assemblies too small to trigger budget limits. Always use a large repeat string like "x".repeat(10000) for the content.

T1.4 — Cache warming (PR3b, merged)

Fire-and-forget on daemon startup. Config-gated, disabled by default:

  • COLIBRI_CACHE_WARMING=0|1 — enable (default: 0)
  • COLIBRI_CACHE_WARMING_INTERVAL_HOURS=N — re-warm interval (0 = once)

warm_cache() is a sync fire-and-forget function — it spawns a tokio::spawn task and returns immediately. Status exposed in cmd_status under cache_warming.

Pitfall: Box<dyn Error> is not Send. Change to Box<dyn Error + Send + Sync> in colibri-deepseek/src/lib.rs one_call() return type.

Spawn-argv (merged)

HerdrCommand::SpawnAgent now accepts local_args: Option<Vec<String>>. Enables Pi spawn without wrapper: local_args: ["--mode","json","--no-tools","-p","task"]. Backward-compatible. Update DaemonClient::spawn_agent in client lib.rs when adding new fields.

ISO service hardening

The FreeBSD service is in packaging/freebsd/colibri_daemon.in. Key additions:

  • start_postcmd: waits up to 10s for socket via [ -S $socket ] loop
  • stop_postcmd: removes stale socket from tmpfs
  • health extra command: probes socket with printf '{"cmd":"status"}\n' | nc -U
  • Log rotation: packaging/freebsd/newsyslog-colibri.conf (1MB, 7 archives)
  • Staging: scripts/stage-colibri-iso.sh copies binaries + service + newsyslog config
  • Service layout documented in docs/ISO-SERVICE-LAYOUT.md

Validate shell syntax with sh -n before committing rc.d or staging script changes.

daemon(8) rc.d pitfall — procname collision + pidfile mismatch

When an rc.d service wraps its foreground binary in daemon(8), three bugs commonly co-occur and cascade:

  1. procname collision: procname="/usr/sbin/daemon" matches ANY daemon(8) instance on the system (tailscaled, etc.). service status always reports "not running" because _find_processes in rc.subr finds the wrong process.
  2. Pidfile flag mismatch: -P writes the supervisor PID (daemon itself), not the child PID. rc.subr can't match the child against the procname even if procname were correct. Use -p (child pidfile) instead.
  3. Cascading bypass: When procname matches a generic daemon(8) path, rc.subr may skip the daemon(8) wrapper entirely. The process tree becomes rc.d → su → colibri-daemon with no supervision, crash restart, or privilege dropping.

Fix (when keeping daemon(8)):

  • procname = the child binary's name (e.g. "colibri-daemon"), never "/usr/sbin/daemon"
  • -p ${pidfile} (lowercase, child PID) instead of -P (supervisor PID)
  • Keep -r (restart), -u (user drop), -o (log redirection), -t (process title)

This preserves daemon(8)'s crash-restart safety while making rc.subr's _find_processes target the correct process. Verified pattern from COLIBRI-XFCE-HANDOFF-04.JUN.2026 live USB diagnostics.

Forgejo API merge workflow

Colibri repos on Forgejo have branch protection (no direct push to main). Instead of web UI, use API token for PR creation + merge:

source ~/.hermes/.env  # loads FORGEJO_API_TOKEN
API="https://code.smilepowered.org/api/v1/repos/clawdie/colibri"

# Create PR
PR=$(curl -s -X POST -H "Authorization: token $FORGEJO_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"...","head":"branch-name","base":"main","body":"..."}' \
  "$API/pulls")
NUM=$(echo "$PR" | python3 -c "import sys,json; print(json.load(sys.stdin)['number'])")

# Merge
curl -s -X POST -H "Authorization: token $FORGEJO_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"do":"merge"}' "$API/pulls/$NUM/merge"

Rule: No auto-merge until both Linux AND FreeBSD validations are posted. The API token enables fast merges, but FreeBSD/OSA cargo test --workspace must pass before merging any Colibri PR.

cmd_status now returns: paths (data_dir, db_path, socket_path), cost (mode, thresholds), tasks (counts by status), panes (count), scheduler (interval). When adding new daemon state fields, update cmd_status in socket.rs to expose them.

Cost thresholds in session rotation

session_rotation in daemon.rs reads CostMode thresholds (session_max_bytes(), max_uncompacted_turns()) instead of static DaemonConfig fields. Changing COLIBRI_COST_MODE (Fast/Smart/Max) affects compaction behavior immediately on next rotation tick.