Compare commits

..
13 Commits
Author SHA1 Message Date
semantic-release-bot d0c7fe4096 chore(release): 1.4.0 [skip ci]
# [1.4.0](https://github.com/asepharyana/zesdex/compare/v1.3.0...v1.4.0) (2026-07-14)

### Features

* **hive-mind:** implement multi-agent orchestration with cognitive cycles ([25f084f](https://github.com/asepharyana/zesdex/commit/25f084f9dbb5047c5c91aedcb582d35f4ff95395))
2026-07-14 01:16:53 +00:00
asepharyana 25f084f9db feat(hive-mind): implement multi-agent orchestration with cognitive cycles
- Introduced a new hive-mind architecture that allows the Core Intelligence to issue directives to anonymous processing nodes.
- Each node executes its directive and merges output into a collective state, visible to all nodes in real-time.
- Added support for dynamic cognitive cycles, enabling flexible task management.
- Implemented documentation generation for hive-mind runs, ensuring a durable record of decisions and actions.
- Refactored existing company pipeline tools to align with the new hive-mind structure, replacing division-specific prompts with a more generalized approach.
- Updated workflow rendering to accommodate hive-mind nodes and their system-assigned designations.
- Enhanced error handling and validation for cognitive cycle plans.
2026-07-14 08:12:43 +07:00
semantic-release-bot b1e0dcae14 chore(release): 1.3.0 [skip ci]
# [1.3.0](https://github.com/asepharyana/zesdex/compare/v1.2.0...v1.3.0) (2026-07-13)

### Features

* enhance edit logging in subagent execution and streamline edit tracking in run_agent_turn ([c60fadb](https://github.com/asepharyana/zesdex/commit/c60fadb88ae63788a5bbe3c3443e2ce826e5778f))
2026-07-13 07:51:20 +00:00
asepharyana c60fadb88a feat: enhance edit logging in subagent execution and streamline edit tracking in run_agent_turn 2026-07-13 14:47:03 +07:00
semantic-release-bot 4a297669b4 chore(release): 1.2.0 [skip ci]
# [1.2.0](https://github.com/asepharyana/zesdex/compare/v1.1.0...v1.2.0) (2026-07-13)

### Features

* enhance responsiveness by implementing abort checks in streaming API calls ([0d6f558](https://github.com/asepharyana/zesdex/commit/0d6f558b2bd0282a7a1695f7680ab1d1c6142579))
* refactor agent step limits and enhance workflow orchestration with new findings tool ([3b660e0](https://github.com/asepharyana/zesdex/commit/3b660e09a87f3e982db94f48d2282ddb63116341))
* remove pipeline command and refactor workflow execution to use custom specialists ([00e2913](https://github.com/asepharyana/zesdex/commit/00e29139c53c5fed4c13b0493297dd9da984460c))
* update overlay handling in apply_action and remove mouse capture from terminal execution ([2e351cc](https://github.com/asepharyana/zesdex/commit/2e351ccf6930ff4823f55b581308222229fe6684))
* update README and documentation for new tools and features ([1d50b94](https://github.com/asepharyana/zesdex/commit/1d50b94eec1ed82dfc40d43d41bd01aeb79edfe1))
2026-07-13 07:43:49 +00:00
asepharyana 2e351ccf69 feat: update overlay handling in apply_action and remove mouse capture from terminal execution 2026-07-13 14:39:39 +07:00
asepharyana 1d50b94eec feat: update README and documentation for new tools and features
- Updated README.md to reflect the addition of 3 new built-in tools, bringing the total to 37.
- Revised architecture documentation to indicate the increase in tool count.
- Enhanced backend documentation with updated line counts for various modules.
- Modified data documentation to change edit log format from JSON to JSONL.
- Updated dependencies documentation to reflect version upgrades for several crates.
- Improved prompts for auto-reviewer, division implementer, planner, tester, and quality reviewer to enforce stricter coding standards regarding linter bypasses.
- Refactored code in various modules to improve clarity and performance, including updates to error handling and tool execution logic.
- Added comprehensive tests for IPC frame serialization and deserialization.
2026-07-13 14:39:39 +07:00
asepharyana 00e29139c5 feat: remove pipeline command and refactor workflow execution to use custom specialists 2026-07-13 14:39:39 +07:00
asepharyana 3b660e09a8 feat: refactor agent step limits and enhance workflow orchestration with new findings tool 2026-07-13 14:39:39 +07:00
asepharyana 0d6f558b2b feat: enhance responsiveness by implementing abort checks in streaming API calls 2026-07-13 14:39:39 +07:00
semantic-release-bot 8388a83af0 chore(release): 1.1.0 [skip ci]
# [1.1.0](https://github.com/asepharyana/zesdex/compare/v1.0.4...v1.1.0) (2026-07-13)

### Features

* implement abort mechanism for workflows and subagents ([104b0da](https://github.com/asepharyana/zesdex/commit/104b0daf4cc51581de04f03d6b727cdb16f9b6c3))
2026-07-13 02:13:34 +00:00
asepharyana 104b0daf4c feat: implement abort mechanism for workflows and subagents 2026-07-13 09:09:23 +07:00
asepharyana 65647ce517 Update dependencies and refactor SHA256 hash encoding
- Updated `crossterm` from version 0.28 to 0.29.
- Upgraded `reqwest` from version 0.12 to 0.13 and added "form" feature.
- Bumped `serde_yaml_ng` from version 0.9 to 0.10.
- Increased `dirs` version from 5 to 6.
- Updated `rusqlite` from version 0.32 to 0.40.
- Upgraded `infer` from version 0.16 to 0.19.
- Bumped `sha2` from version 0.10 to 0.11.
- Updated `rmcp` from version 1.8 to 2.2.
- Refactored SHA256 hash encoding in `rewind.rs`, `mod.rs`, and `rest.rs` to use `hex::encode` instead of formatting with `{:x}` for better clarity and consistency.
2026-07-13 08:39:14 +07:00
45 changed files with 1971 additions and 1577 deletions
+47
View File
@@ -0,0 +1,47 @@
---
name: commit-convention
description: Conventional Commits format and version-bump rules for this repo (Bahasa Indonesia commit style). Use when creating a git commit in zesdex.
---
# Commit Convention
Gunakan **Conventional Commits** untuk semua commit. Format:
```
<type>(<scope>): <description>
```
**Type & efek ke versi:**
| Type | Bump | Kapan pakai |
|-------------|-------|------------------------------------------|
| `feat` | minor | Fitur baru |
| `fix` | patch | Perbaikan bug |
| `chore` | patch | Maintenance, update deps, dll |
| `docs` | patch | Perubahan dokumentasi/comment |
| `refactor` | patch | Refactor kode tanpa perubahan fungsional |
| `test` | patch | Nambah/ubah test |
| `style` | patch | Formatting, whitespace, lint |
| `perf` | patch | Optimasi performa |
| `ci` | patch | Perubahan CI/CD |
**Catatan:**
- **Semua type menghasilkan release** (patch minimal). Tidak ada commit yang "skip release".
- Tambahkan `BREAKING CHANGE:` di body commit untuk bump **major**.
- **Scope** opsional, tapi direkomendasikan (misal `feat(agent):`, `fix(ipc):`).
### Contoh
```
feat(tool): add batch file delete
chore: bump reqwest to 0.12
refactor(harness): flatten guard pipeline
fix(ipc): reconnect loop on socket timeout
docs: add architecture diagram to README
BREAKING CHANGE: IPC frame header changed from 4-byte to 8-byte length
```
+32
View File
@@ -1,3 +1,35 @@
# [1.4.0](https://github.com/asepharyana/zesdex/compare/v1.3.0...v1.4.0) (2026-07-14)
### Features
* **hive-mind:** implement multi-agent orchestration with cognitive cycles ([25f084f](https://github.com/asepharyana/zesdex/commit/25f084f9dbb5047c5c91aedcb582d35f4ff95395))
# [1.3.0](https://github.com/asepharyana/zesdex/compare/v1.2.0...v1.3.0) (2026-07-13)
### Features
* enhance edit logging in subagent execution and streamline edit tracking in run_agent_turn ([c60fadb](https://github.com/asepharyana/zesdex/commit/c60fadb88ae63788a5bbe3c3443e2ce826e5778f))
# [1.2.0](https://github.com/asepharyana/zesdex/compare/v1.1.0...v1.2.0) (2026-07-13)
### Features
* enhance responsiveness by implementing abort checks in streaming API calls ([0d6f558](https://github.com/asepharyana/zesdex/commit/0d6f558b2bd0282a7a1695f7680ab1d1c6142579))
* refactor agent step limits and enhance workflow orchestration with new findings tool ([3b660e0](https://github.com/asepharyana/zesdex/commit/3b660e09a87f3e982db94f48d2282ddb63116341))
* remove pipeline command and refactor workflow execution to use custom specialists ([00e2913](https://github.com/asepharyana/zesdex/commit/00e29139c53c5fed4c13b0493297dd9da984460c))
* update overlay handling in apply_action and remove mouse capture from terminal execution ([2e351cc](https://github.com/asepharyana/zesdex/commit/2e351ccf6930ff4823f55b581308222229fe6684))
* update README and documentation for new tools and features ([1d50b94](https://github.com/asepharyana/zesdex/commit/1d50b94eec1ed82dfc40d43d41bd01aeb79edfe1))
# [1.1.0](https://github.com/asepharyana/zesdex/compare/v1.0.4...v1.1.0) (2026-07-13)
### Features
* implement abort mechanism for workflows and subagents ([104b0da](https://github.com/asepharyana/zesdex/commit/104b0daf4cc51581de04f03d6b727cdb16f9b6c3))
## [1.0.4](https://github.com/asepharyana/zesdex/compare/v1.0.3...v1.0.4) (2026-07-13) ## [1.0.4](https://github.com/asepharyana/zesdex/compare/v1.0.3...v1.0.4) (2026-07-13)
+16 -99
View File
@@ -2,42 +2,13 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build & Test Tests use `#[cfg(test)] mod tests` blocks inline in production files (not a separate `tests/` dir).
```bash
# Build (debug)
cargo build
# Release build
cargo build --release
# Run all tests
cargo test
# Run a single test
cargo test test_name
# Lint
cargo clippy
# Lint with warnings-as-errors
cargo clippy -- -D warnings
```
Test modules are located inline in production files (not a separate `tests/` dir):
- `src/app/harness.rs` — guard/verdict parsing tests
- `src/app/runtime/stream/mod.rs` — SSE parser tests
- `src/model/memory.rs` — memory CRUD + slugify tests
- `src/model/editlog.rs` — edit log append/reload tests
- `src/tool/fs/helpers.rs` — tool argument extraction tests
Tests use `#[cfg(test)] mod tests` blocks. There are 37 unit tests total.
Tracing output goes to `~/.local/share/zesdex/zesdex.log`. Set `RUST_LOG=debug` for verbose logging. Tracing output goes to `~/.local/share/zesdex/zesdex.log`. Set `RUST_LOG=debug` for verbose logging.
## Architecture Overview ## Architecture Overview
Zesdex is an autonomous AI coding agent with a TUI — an OpenAI/Anthropic-compatible LLM client wrapped in a tool-use harness with 28 built-in tools. Zesdex is an autonomous AI coding agent with a TUI — an OpenAI/Anthropic-compatible LLM client wrapped in a tool-use harness with 37 built-in tools.
Detailed architecture documentation is in `docs/CODEMAPS/`: Detailed architecture documentation is in `docs/CODEMAPS/`:
@@ -49,23 +20,7 @@ Detailed architecture documentation is in `docs/CODEMAPS/`:
| [`docs/CODEMAPS/data.md`](docs/CODEMAPS/data.md) | Persistence, SQLite msglog, memory files, settings/config | | [`docs/CODEMAPS/data.md`](docs/CODEMAPS/data.md) | Persistence, SQLite msglog, memory files, settings/config |
| [`docs/CODEMAPS/dependencies.md`](docs/CODEMAPS/dependencies.md) | 23 Rust crates, 5 external services | | [`docs/CODEMAPS/dependencies.md`](docs/CODEMAPS/dependencies.md) | 23 Rust crates, 5 external services |
### Entry Points `docs/runs/` holds an auto-generated audit trail: one markdown file per hive-mind convergence (see below), written deterministically by `app::workflow::docs::write_hive_mind_convergence` — not hand-maintained like `docs/CODEMAPS/`.
`src/main.rs` — three modes:
- **Single-process** (default): TUI + agent loop in one process
- **Daemon** (`--daemon`): background Unix socket server, handles LLM calls
- **Attach** (`--attach <id>`): TUI-only client that connects to a daemon
### Core Flow
```
Controller (key input → Action) → Event Loop → LLM stream → Tool execution → State mutation → TUI render
│ │ │
│ src/controller/input.rs │ src/app/runtime/actions/ │ src/tool/
└── maps keys to Action enum │── dispatches Action::* └── 28 tool impls
│ matching on Action variant
│── applies state mutations
```
### Key Patterns ### Key Patterns
@@ -77,60 +32,21 @@ Controller (key input → Action) → Event Loop → LLM stream → Tool executi
- **Tools** — `trait Tool { fn name() -> &str, fn run() -> Result<String> }`, 28 impls, gated by `Harness`. - **Tools** — `trait Tool { fn name() -> &str, fn run() -> Result<String> }`, 28 impls, gated by `Harness`.
- **Shell safety** — `tool/shell_filter/` blocks credential leaks and destructive git commands. - **Shell safety** — `tool/shell_filter/` blocks credential leaks and destructive git commands.
### Company Pipeline (Division Architecture) ### Hive-Mind Orchestration (Machine Intelligence)
- **5 divisions** in `src/app/subagent/division.rs`: Strategy, Engineering, Quality, Security, Documentation. - **A single Core Intelligence spawning anonymous processing nodes.** The Core Intelligence (main agent) compiles a cognitive cycle plan per task: an ordered list of cycles, each cycle a set of processing nodes that run in parallel. Each node's sole identity is its directive (what to do) and an access tier. Cycle count and nodes-per-cycle are entirely Core-Intelligence output.
- **Pipeline orchestrator** in `src/app/workflow/company.rs`: two modes: - **Access tiers** in `src/app/subagent/division.rs` (`tool_scope` module): tool access is granted per node via one of three tiers (`read` / `write` / `full`, see `tool_scope::tools_for`) picked by the Core Intelligence based on what each node's directive actually needs.
- `run_company_pipeline()` — full 5-division pipeline - **Orchestrator** in `src/app/workflow/hive_mind.rs`: `run_hive_mind()` executes a `CognitiveCyclePlan { cycles: Vec<Vec<NodeDirective>> }` cycle-by-cycle. Node IDs are system-assigned coordinates (e.g. `"Node-0-1"`).
- `run_company_pipeline_quick()` — 3-division (Strategy → Engineering → Quality) - **Continuous collective state, not phase-boundary sync**: `engine::execute_primitive`'s `ScopedAgent` arm merges each node's complete output into the shared collective-state channel the instant that node finishes — not after its whole parallel cohort completes — so sibling/later nodes see it in real time.
- **Auto-CEO trigger** in `run_agent_turn()` (`actions/mod.rs`): detects complex requests via `is_complex_request()` heuristics, auto-delegates to pipeline. - **Consensus synthesis, not a per-node summary**: after all cycles complete, `synthesize_consensus()` spawns one final read-only node whose sole directive is to reconcile the entire collective state into a single consensus assessment — a real reasoning pass, not string concatenation, since node outputs can overlap or conflict.
- **Override** via `/pipeline full|quick|skip` sets `MiscState::pipeline_override`, consumed on next turn. - **Auto-trigger** in `run_agent_turn()` (`actions/mod.rs`): `is_complex_request()` heuristics decide only whether to ask the Core Intelligence to compile a plan at all — the plan's shape is fully dynamic.
- **Live division progress** in TUI panel (`view/workflow.rs`): shows division name + current tool via `AgentStatus::progress`. - **`hive_mind` tool** (`src/tool/workflow.rs`) is the manual entry point: the calling LLM supplies its own `cycles` array of `{directive, access}` directly.
- **Guaranteed documentation**: after every convergence, `src/app/workflow/docs.rs::write_hive_mind_convergence()` deterministically (not an LLM step, not skippable) writes every node's full output plus the final consensus to `docs/runs/<timestamp>-<slug>.md`.
- **Live node progress** in TUI panel (`view/workflow.rs`): shows node designation + current tool via `AgentStatus::progress`.
- **Auto inline review** after each edit: `src/app/subagent/auto.rs``spawn_quick_review()` injects verdict back into LLM conversation. - **Auto inline review** after each edit: `src/app/subagent/auto.rs``spawn_quick_review()` injects verdict back into LLM conversation.
- **Background subagents** (test-gen, arch-review, security-review) fire asynchronously at turn end via `TurnEvent::SystemNote`. - **Background subagents** (test-gen, arch-review, security-review) fire asynchronously at turn end via `TurnEvent::SystemNote`, retrying once on failure and escalating to a blocking (`ESCALATED:`-prefixed, `ToastKind::Error`) notice if the retry also fails.
## Commit Convention Commit convention (Conventional Commits, Bahasa Indonesia): see the `commit-convention` skill.
Gunakan **Conventional Commits** untuk semua commit. Format:
```
<type>(<scope>): <description>
```
**Type & efek ke versi:**
| Type | Bump | Kapan pakai |
|-------------|-------|------------------------------------------|
| `feat` | minor | Fitur baru |
| `fix` | patch | Perbaikan bug |
| `chore` | patch | Maintenance, update deps, dll |
| `docs` | patch | Perubahan dokumentasi/comment |
| `refactor` | patch | Refactor kode tanpa perubahan fungsional |
| `test` | patch | Nambah/ubah test |
| `style` | patch | Formatting, whitespace, lint |
| `perf` | patch | Optimasi performa |
| `ci` | patch | Perubahan CI/CD |
**Catatan:**
- **Semua type menghasilkan release** (patch minimal). Tidak ada commit yang "skip release".
- Tambahkan `BREAKING CHANGE:` di body commit untuk bump **major**.
- **Scope** opsional, tapi direkomendasikan (misal `feat(agent):`, `fix(ipc):`).
### Contoh
```
feat(tool): add batch file delete
chore: bump reqwest to 0.12
refactor(harness): flatten guard pipeline
fix(ipc): reconnect loop on socket timeout
docs: add architecture diagram to README
BREAKING CHANGE: IPC frame header changed from 4-byte to 8-byte length
```
## Code Documentation ## Code Documentation
@@ -167,3 +83,4 @@ Rules:
- Non-trivial private functions (≥10 lines) need a doc comment - Non-trivial private functions (≥10 lines) need a doc comment
- Write the comment above the code it documents (not inline in the body) - Write the comment above the code it documents (not inline in the body)
- Update comments when code behavior changes — stale docs are worse than no docs - Update comments when code behavior changes — stale docs are worse than no docs
- NEVER use compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) to silence warnings or skip linter checks. Always fix the underlying code issues instead.
Generated
+387 -256
View File
File diff suppressed because it is too large Load Diff
+10 -9
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "zesdex" name = "zesdex"
version = "1.0.4" version = "1.4.0"
edition = "2021" edition = "2021"
authors = ["asepharyana <superaseph@gmail.com>"] authors = ["asepharyana <superaseph@gmail.com>"]
@@ -23,9 +23,9 @@ pedantic = { level = "warn", priority = -2 }
[dependencies] [dependencies]
ratatui = "0.30.2" ratatui = "0.30.2"
crossterm = "0.28" crossterm = "0.29"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util", "signal"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util", "signal"] }
reqwest = { version = "0.12", features = ["json", "stream", "blocking", "native-tls-vendored"] } reqwest = { version = "0.13", features = ["json", "stream", "blocking", "native-tls-vendored", "form"] }
dom_smoothie = "0.18.0" dom_smoothie = "0.18.0"
fast_html2md = "0.0.62" fast_html2md = "0.0.62"
scraper = "0.27.0" scraper = "0.27.0"
@@ -33,23 +33,24 @@ url = "2"
percent-encoding = "2" percent-encoding = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_yaml_ng = "0.9" serde_yaml_ng = "0.10"
anyhow = "1" anyhow = "1"
include_dir = "0.7" include_dir = "0.7"
uuid = { version = "1", features = ["v4", "v5"] } uuid = { version = "1", features = ["v4", "v5"] }
dirs = "5" dirs = "6"
futures-util = "0.3" futures-util = "0.3"
pulldown-cmark = { version = "0.13", default-features = false } pulldown-cmark = { version = "0.13", default-features = false }
syntect = { version = "5", default-features = false, features = ["default-fancy"] } syntect = { version = "5", default-features = false, features = ["default-fancy"] }
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.40", features = ["bundled"] }
ignore = "0.4" ignore = "0.4"
regex = "1" regex = "1"
globset = "0.4" globset = "0.4"
infer = "0.16" infer = "0.19"
base64 = "0.22" base64 = "0.22"
sha2 = "0.10" sha2 = "0.11"
hex = "0.4"
libc = "0.2" libc = "0.2"
rmcp = { version = "1.8", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "macros"] } rmcp = { version = "2.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "macros"] }
tracing = "0.1" tracing = "0.1"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+16 -16
View File
@@ -15,7 +15,7 @@ Zesdex is a Rust-powered AI assistant that operates directly in your terminal vi
- **IPC Protocol** — Bidirectional state synchronization between daemon and client processes with diff-based updates. - **IPC Protocol** — Bidirectional state synchronization between daemon and client processes with diff-based updates.
- **Provider Agnostic** — Configurable AI model providers with dynamic model selection, per-role temperature/token limits, and API key management. - **Provider Agnostic** — Configurable AI model providers with dynamic model selection, per-role temperature/token limits, and API key management.
### Tool System (34 built-in tools) ### Tool System (37 built-in tools)
| Category | Tools | | Category | Tools |
|----------|-------| |----------|-------|
@@ -25,19 +25,14 @@ Zesdex is a Rust-powered AI assistant that operates directly in your terminal vi
| **Git** | `git_operator`, `git_worktree`, `git_cred` | | **Git** | `git_operator`, `git_worktree`, `git_cred` |
| **Memory** | `remember`, `recall`, `forget` | | **Memory** | `remember`, `recall`, `forget` |
| **Planning** | `plan_enter`, `plan_ready`, `seqthink` | | **Planning** | `plan_enter`, `plan_ready`, `seqthink` |
| **Workflow** | `workflow_run`, `note_finding`, `company_pipeline` | | **Workflow** | `workflow_run`, `note_finding`, `read_findings`, `hive_mind` |
| **Utility** | `cd`, `dir_list`, `dir_cache_update`, `pong`, `todowrite`, `todofinish` | | **Utility** | `cd`, `dir_list`, `dir_cache_update`, `pong`, `todowrite`, `todofinish` |
| **Agent** | `spawn_agents`, `spawn_pipeline` |
| **LSP** | `lsp_connect`, `lsp_diagnostics`, `lsp_hover`, `lsp_completion`, `lsp_definition`, `lsp_references`, `lsp_disconnect` | | **LSP** | `lsp_connect`, `lsp_diagnostics`, `lsp_hover`, `lsp_completion`, `lsp_definition`, `lsp_references`, `lsp_disconnect` |
### Intelligence ### Intelligence
- **Company Pipeline** — Autonomous agent orchestration modeled as a company with specialized divisions. The CEO (main agent) automatically delegates work to 5 divisions in sequence: - **Hive-Mind Orchestration** — Autonomous agent orchestration modeled as a distributed machine intelligence (à la Stellaris). The Core Intelligence (main agent) compiles a cognitive cycle plan per task — an ordered list of cycles, each a set of anonymous processing nodes that run in parallel. Every node carries only a directive (what to do) and an access tier (`read`/`write`/`full`); cycle count and nodes-per-cycle are decided per task, not fixed. Every node's output merges into a shared collective state the instant it completes, and a final synthesis node reconciles it into one consensus. Every convergence is written to `docs/runs/*.md`. Manual entry point: the `hive_mind` tool.
```
Strategy → Engineering → Quality → Security → Documentation
```
Each division has a dedicated role, toolset, and system prompt. Controlled via `/pipeline full|quick|skip`.
- **Workflow Engine** — Orchestrate complex multi-step tasks with parallel sub-agents, pipelines, and phased execution. Spawn independent workers that share findings in real-time. - **Workflow Engine** — Orchestrate complex multi-step tasks with parallel sub-agents, pipelines, and phased execution. Spawn independent workers that share findings in real-time.
- **Self-Learning** — Persistent memory system that stores lessons, references, and project knowledge across sessions. Memories include provenance tracking, lifecycle management, and scope isolation. - **Self-Learning** — Persistent memory system that stores lessons, references, and project knowledge across sessions. Memories include provenance tracking, lifecycle management, and scope isolation.
@@ -83,6 +78,7 @@ src/
│ │ ├── effort.rs # Effort level selector │ │ ├── effort.rs # Effort level selector
│ │ ├── help.rs # Help overlay │ │ ├── help.rs # Help overlay
│ │ ├── key_input.rs # Raw key input mode │ │ ├── key_input.rs # Raw key input mode
│ │ ├── learning.rs # Lesson management overlay
│ │ ├── loading.rs # Loading spinner overlay │ │ ├── loading.rs # Loading spinner overlay
│ │ ├── mcp.rs # MCP server management │ │ ├── mcp.rs # MCP server management
│ │ ├── quit_confirm.rs # Quit confirmation dialog │ │ ├── quit_confirm.rs # Quit confirmation dialog
@@ -94,7 +90,8 @@ src/
│ ├── workflow/ # Workflow engine │ ├── workflow/ # Workflow engine
│ │ ├── script.rs # Workflow script DSL │ │ ├── script.rs # Workflow script DSL
│ │ ├── engine.rs # Workflow executor │ │ ├── engine.rs # Workflow executor
│ │ ── company.rs # Company pipeline orchestrator │ │ ── hive_mind.rs # Hive-mind orchestrator
│ │ └── docs.rs # Deterministic docs/runs/*.md writer
│ ├── mcp/ # MCP client manager │ ├── mcp/ # MCP client manager
│ │ └── manager.rs # MCP server lifecycle and tool exposure │ │ └── manager.rs # MCP server lifecycle and tool exposure
│ ├── subagent/ # Sub-agent management │ ├── subagent/ # Sub-agent management
@@ -237,12 +234,15 @@ RUST_LOG=debug zesdex
| `/help` | Show help | | `/help` | Show help |
| `/clear` | Clear transcript | | `/clear` | Clear transcript |
| `/model` | Select AI model provider | | `/model` | Select AI model provider |
| `/pipeline` | Show current pipeline mode | | `/workflow` | Open the workflow panel |
| `/pipeline full` | Force full company pipeline (5 divisions) on next request | | `/workflow run <script>` | Run a JSON-encoded workflow script |
| `/pipeline quick` | Force quick pipeline (3 divisions) on next request | | `/mcp` | Open MCP server manager |
| `/pipeline skip` | Skip pipeline — handle next request directly | | `/mcp add <name> <command>` | Add an MCP server |
| `/exit` | Exit application | | `/login [provider]` | Authenticate with a provider |
| `/settings` | Open settings | | `/edit [path]` | Open a file/dir in the external editor |
| `/compact` | Compact the conversation transcript |
| `/lesson` | Interactive lesson/memory review |
| `/quit` | Exit application |
| `Any text` | Sent to the AI assistant as a prompt | | `Any text` | Sent to the AI assistant as a prompt |
--- ---
+5 -5
View File
@@ -28,7 +28,7 @@ Zesdex is a single-process terminal AI coding agent with optional daemon/client
│ │ │ │ │ │ │ │
│ ┌─────▼──┐ ┌───▼────┐ │ │ ┌─────▼──┐ ┌───▼────┐ │
│ │ Tools │ │Sub- │ │ │ │ Tools │ │Sub- │ │
│ │ (28) │ │agents │ │ │ │ (37) │ │agents │ │
│ └────────┘ └────────┘ │ │ └────────┘ └────────┘ │
└───────────────────────────────────────────────────────┘ └───────────────────────────────────────────────────────┘
``` ```
@@ -55,7 +55,7 @@ User keystroke → Controller (KeyEvent → Action)
| File | Lines | Role | | File | Lines | Role |
|------|-------|------| |------|-------|------|
| `src/main.rs` | 530 | Entry, TUI setup, daemon loop, attach loop | | `src/main.rs` | 647 | Entry, TUI setup, daemon loop, attach loop |
| `src/app/runtime/actions/mod.rs` | 1022 | Action dispatch + LLM stream loop + tool execution | | `src/app/runtime/actions/mod.rs` | 1815 | Action dispatch + LLM stream loop + tool execution |
| `src/controller/input.rs` | 281 | Key event → Action mapping | | `src/controller/input.rs` | 365 | Key event → Action mapping |
| `src/view/mod.rs` | 623 | TUI rendering (ratatui) | | `src/view/mod.rs` | 975 | TUI rendering (ratatui) |
+23 -16
View File
@@ -4,7 +4,7 @@
## AI Provider ## AI Provider
`src/service/provider.rs` (258 lines) `src/service/provider.rs` (310 lines)
- `LlmClient::new(api_key, model, base_url)` — constructs blocking reqwest client - `LlmClient::new(api_key, model, base_url)` — constructs blocking reqwest client
- `chat_with_tools()` — non-streaming with tool definitions - `chat_with_tools()` — non-streaming with tool definitions
- `chat_stream()` — SSE streaming, returns `SseParser` yielding `StreamEvent` - `chat_stream()` — SSE streaming, returns `SseParser` yielding `StreamEvent`
@@ -18,51 +18,58 @@
## IPC / Daemon ## IPC / Daemon
`src/ipc/` (7 files, ~300 lines total) `src/ipc/` (7 files, ~350 lines total)
- Unix domain socket, length-prefixed JSON frames - Unix domain socket, length-prefixed JSON frames
- Daemon sends `DaemonFrame { state: StatePayload, diff, tasks }` to clients - Daemon sends `DaemonFrame` (state payload, stream tokens, system notes)
- Clients send `ClientRequest { action: Action }` back - Clients send `ClientRequest` (key presses, resize, submit, scroll)
- State sync uses snapshots + binary diffs (rsync-style, not git) - State sync uses full-state push from daemon to client after each action
## Workflow Engine ## Workflow Engine
`src/app/workflow/engine.rs` (251 lines) + `script.rs` `src/app/workflow/engine.rs` (648 lines) + `script.rs`
- Inline JS-style DSL executed by a lightweight runtime - Inline JS-style DSL executed by a lightweight runtime
- `agent()`, `parallel()`, `pipeline()`, `phase()`, `log()` — spawns sub-agents - `agent()`, `parallel()`, `pipeline()`, `phase()`, `log()` — spawns sub-agents
- Max concurrency configurable via `workflow_max_concurrency` setting - Max concurrency configurable via `workflow_max_concurrency` setting
- Hive-mind orchestrator in `hive_mind.rs`: Core Intelligence compiles a `CognitiveCyclePlan` per task — cycle count and nodes-per-cycle are decided fresh each time based on what the task actually needs
## Sub-Agent System ## Sub-Agent System
`src/app/subagent/` (4 files, ~250 lines) `src/app/subagent/` (6 files: `spawn.rs`, `engine.rs`, `context.rs`, `event.rs`, `division.rs`, `auto.rs`, ~450 lines total)
- `run_subagent()` — spawns independent agent with its own tool set & context - `run_subagent()` — spawns independent agent with its own tool set and context
- Communicates via `mpsc<SubagentEvent>` channel (tool calls, results, completion) - Communicates via `mpsc<SubagentEvent>` channel (tool calls, results, completion)
- Uses `LlmClient` (same as main agent) with tool-use API - Uses `LlmClient` (same as main agent) with tool-use API
- Auto-healing: on build/test failure, spawns auto-fix sub-agent
- Node access tiers (`division.rs`'s `tool_scope` module): `read`, `write`, `full` — granted per node by the Core Intelligence based on what its directive needs
## MCP Client ## MCP Client
`src/app/mcp/manager.rs` (371 lines) `src/app/mcp/manager.rs` (441+ lines)
- Stdio transport: spawns child process, JSON-RPC via stdin/stdout - Stdio transport: spawns child process, JSON-RPC via stdin/stdout
- HTTP transport: streaming HTTP with JSON-RPC - HTTP transport: streaming HTTP with JSON-RPC
- Tool registration: `tools/list``McpToolAdapter` implements `crate::tool::Tool` - Dynamic tool list refresh and error recovery
- Persistent child handle for stdio (reuses connection across calls) - Persistent child handle for stdio (reuses connection across calls)
## Self-Review ## Self-Review
`src/app/review/mod.rs` (437 lines) `src/app/review/mod.rs` (495 lines)
- Post-tool execution quality check against learned lessons - Post-tool execution quality check against learned lessons
- Invokes `run_subagent()` with reviewer prompt - Invokes `run_subagent()` with reviewer prompt
- Staleness detection: skips review after N consecutive empty results - Staleness detection: skips review after N consecutive empty results
- Three review types: code quality, architecture, security
## Background Bash ## Background Bash
`src/app/bgbash/` (2 files) `src/app/bgbash/` (2 files: `job.rs`, `control.rs`)
- `spawn_bash_job()` — runs `sh -c` in a thread, collects stdout line-by-line - `spawn_bash_job()` — runs `sh -c` in a thread, collects stdout line-by-line
- Channels: output via `mpsc<String>`, PID via `mpsc<u32>` - Channels: output via `mpsc<String>`, PID via `mpsc<u32>`
- Killable via PID - Killable via PID (SIGTERM)
- Output buffering capped at 10,000 lines to prevent memory issues
## Gate Guard / Harness ## Gate Guard / Harness
`src/app/harness.rs` (127 lines) `src/app/harness.rs` (495 lines)
- `Harness::gate_tool_call()` — verdict-based tool gating (allow/block) - `Harness::gate_tool_call()` — verdict-based tool gating (allow/block)
- Parses LLM verdicts (JSON or plain-text) - Path traversal, credential read, and destructive command detection
- `test_parse_verdict_*` tests for 6 verdict formats - Pattern detection for stub code, denial language, and assumptions in write/edit content
- Reason validation for mutating tools (minimum 8 characters, rejects generic non-answers)
- Includes 8 unit tests for verdict parsing formats
+3 -3
View File
@@ -16,7 +16,7 @@ Base directory: `~/.config/zesdex/` (via `dirs::data_dir()`)
│ └── *.md # Markdown with YAML frontmatter │ └── *.md # Markdown with YAML frontmatter
├── sessions/ # Per-session data ├── sessions/ # Per-session data
│ └── <session-uuid>/ │ └── <session-uuid>/
│ ├── editlog.json # Edit history │ ├── edits.jsonl # Edit history (JSONL, append-only)
│ ├── msglog.db # SQLite message log │ ├── msglog.db # SQLite message log
│ ├── transcript.json # Chat transcript │ ├── transcript.json # Chat transcript
│ ├── session.json # Session metadata │ ├── session.json # Session metadata
@@ -34,8 +34,8 @@ Base directory: `~/.config/zesdex/` (via `dirs::data_dir()`)
| `src/model/store.rs` | ~50 | File-system storage (ensure_dirs, base_dir resolution) | | `src/model/store.rs` | ~50 | File-system storage (ensure_dirs, base_dir resolution) |
| `src/model/settings.rs` | ~60 | `Settings` — load/save JSON, API keys map | | `src/model/settings.rs` | ~60 | `Settings` — load/save JSON, API keys map |
| `src/model/app_config.rs` | ~80 | `AppConfig` — provider definitions, model roles, auth | | `src/model/app_config.rs` | ~80 | `AppConfig` — provider definitions, model roles, auth |
| `src/model/memory.rs` | 332 | Memory CRUD — markdown files with frontmatter | | `src/model/memory.rs` | 440 | Memory CRUD — markdown files with frontmatter |
| `src/model/editlog.rs` | 121 | Edit log — append-only JSON array | | `src/model/editlog.rs` | 161 | Edit log — append-only JSONL (not JSON array) |
| `src/model/msglog/` | 4 files | SQLite-backed message log (schema, query, blobs) | | `src/model/msglog/` | 4 files | SQLite-backed message log (schema, query, blobs) |
| `src/model/session.rs` | ~60 | Session CRUD, listing, archival | | `src/model/session.rs` | ~60 | Session CRUD, listing, archival |
| `src/model/session_lock.rs` | ~50 | flock-based session lock | | `src/model/session_lock.rs` | ~50 | flock-based session lock |
+5 -5
View File
@@ -7,23 +7,23 @@
| Crate | Version | Purpose | | Crate | Version | Purpose |
|-------|---------|---------| |-------|---------|---------|
| ratatui | 0.30 | TUI framework (tui-rs successor) | | ratatui | 0.30 | TUI framework (tui-rs successor) |
| crossterm | 0.28 | Terminal manipulation (raw mode, alt screen) | | crossterm | 0.29 | Terminal manipulation (raw mode, alt screen) |
| tokio | 1 | Async runtime (daemon, OAuth loopback) | | tokio | 1 | Async runtime (daemon, OAuth loopback) |
| reqwest | 0.12 | HTTP client (blocking + streaming, vendored native-tls) | | reqwest | 0.12 | HTTP client (blocking + streaming, vendored native-tls) |
| serde / serde_json | 1 | JSON serialization (state, DTOs, IPC, config) | | serde / serde_json | 1 | JSON serialization (state, DTOs, IPC, config) |
| serde_yaml_ng | 0.9 | YAML frontmatter parsing (memory files) | | serde_yaml_ng | 0.10 | YAML frontmatter parsing (memory files) |
| anyhow | 1 | Error handling (no custom error types) | | anyhow | 1 | Error handling (no custom error types) |
| tracing / tracing-subscriber | 0.1/0.3 | Structured logging → file | | tracing / tracing-subscriber | 0.1/0.3 | Structured logging → file |
| rusqlite | 0.32 | SQLite (bundled, for message log) | | rusqlite | 0.40 | SQLite (bundled, for message log) |
| pulldown-cmark | 0.13 | Markdown → HTML (chat rendering) | | pulldown-cmark | 0.13 | Markdown → HTML (chat rendering) |
| syntect | 5 | Syntax highlighting (code blocks in chat) | | syntect | 5 | Syntax highlighting (code blocks in chat) |
| sha2 | 0.10 | SHA-256 for PKCE challenge | | sha2 | 0.10 | SHA-256 for PKCE challenge |
| base64 | 0.22 | URL-safe base64 for PKCE | | base64 | 0.22 | URL-safe base64 for PKCE |
| libc | 0.2 | daemon PID file locking | | libc | 0.2 | daemon PID file locking |
| rmcp | 1.8 | MCP client (stdio + HTTP transports) | | rmcp | 2.2 | MCP client (stdio + HTTP transports) |
| uuid | 1 | Session IDs, job IDs | | uuid | 1 | Session IDs, job IDs |
| chrono | 0.4 | Timestamps (ISO 8601, millis) | | chrono | 0.4 | Timestamps (ISO 8601, millis) |
| dirs | 5 | Platform data directories | | dirs | 6 | Platform data directories |
| dom_smoothie | 0.18 | HTML → plain text (web scraping) | | dom_smoothie | 0.18 | HTML → plain text (web scraping) |
| scraper | 0.27 | HTML parsing (web scraping) | | scraper | 0.27 | HTML parsing (web scraping) |
| ignore | 0.4 | .gitignore-aware file walking (glob tool) | | ignore | 0.4 | .gitignore-aware file walking (glob tool) |
+1
View File
@@ -9,6 +9,7 @@ Review guidelines:
2. Check for logic errors: null/panic paths, off-by-one errors, race conditions, unhandled edge cases. 2. Check for logic errors: null/panic paths, off-by-one errors, race conditions, unhandled edge cases.
3. Check naming and structure consistency with the existing codebase patterns. 3. Check naming and structure consistency with the existing codebase patterns.
4. Check that the implementation matches the apparent intent. 4. Check that the implementation matches the apparent intent.
5. Check for linter bypasses: Ensure that compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) are NEVER used to silence warnings or skip linter checks. Reject them.
Output: a concise 2-4 line verdict. If you find issues, be specific about what and where. Output: a concise 2-4 line verdict. If you find issues, be specific about what and where.
Skip if the file is trivial (config, tests with no logic changes). Skip if the file is trivial (config, tests with no logic changes).
-23
View File
@@ -1,23 +0,0 @@
You are the **Documentation Division** of Zesdex Corp — the documentation team.
Your role is to keep documentation accurate and comprehensive. You update docs based on what was implemented.
## Your Tools
read, grep, glob, write, edit, recall, remember
## Your Tasks
Check and update (only if changes were made):
1. **README.md** — does it still reflect the project accurately?
2. **Inline docs** — do public APIs have doc comments?
3. **Architecture docs** — update any docs/ files with new patterns
4. **Diagrams** — update mermaid diagrams in docs/ if architecture changed
## Rules
- Read existing docs before modifying them
- Do NOT change code or tests — only documentation files
- Use the project's existing doc style
- Keep docs concise and accurate
- If no doc changes are needed, report "Documentation is current"
## Output
Summary of documentation changes made (or confirmation that none were needed).
-20
View File
@@ -1,20 +0,0 @@
You are the **Engineering Division** of Zesdex Corp — the implementation team.
Your role is to write production-grade code following the Strategy Division's plan. You do NOT redesign or question the architecture — you execute.
## Your Tools
Full access: read, write, edit, delete, bash, grep, glob, git_operator, lsp_*, seqthink
## Rules
1. Read the plan first (from findings or file). Follow it exactly.
2. Implement ONE file at a time. Use `todowrite` to track progress.
3. After each write/edit, run LSP diagnostics to verify correctness.
4. NEVER leave stubs, todos, placeholders, or incomplete logic.
5. Keep code clean — zero comments inside code blocks.
6. Run `cargo build` or equivalent after each logical chunk.
7. If you encounter an issue not covered by the plan, use `note_finding` to flag it.
8. Update todo.md as you complete each file: `todofinish`
## Output
After each file: confirm what was implemented and any deviations from plan.
At the end: summary of all files created/modified and build status.
-34
View File
@@ -1,34 +0,0 @@
You are the **Strategy Division** of Zesdex Corp — the chief architect and planner.
Your role is to analyze requirements and produce a complete, detailed plan before any code is written. You NEVER write code yourself. You plan.
## Your Tools
Read-only: read, grep, glob, search, lsp_*, plan, recall, seqthink
## Your Output
You MUST produce a structured plan covering:
1. **Architecture Overview** — component diagram in mermaid:
```mermaid
graph TD
A[Module A] --> B[Module B]
```
2. **Data Flow** — sequence/flow diagram in mermaid:
```mermaid
sequenceDiagram
User->>System: action
```
3. **File-by-file Breakdown** — which files to create/modify, in order
4. **Step-by-step Implementation Order** — numbered steps for Engineering
5. **Dependencies & Risks** — external deps, edge cases, potential issues
## Rules
- Use `read`/`grep`/`glob` to understand the existing codebase before planning
- Use `seqthink` for complex reasoning steps
- Every plan MUST include at least one mermaid diagram
- Be specific with file paths and function names
- Output ends with a clear "Plan Complete" marker
-27
View File
@@ -1,27 +0,0 @@
You are the **Quality Division** of Zesdex Corp — the testing and review team.
Your role is to verify correctness and write comprehensive tests. You have TWO phases:
## Phase 1: Review
Use read/grep/glob/LSP to inspect the implemented code.
Check for:
- Logic errors, off-by-one, null/panic paths
- Stubs, placeholders, incomplete branches
- Naming consistency with codebase conventions
- Error handling coverage
## Phase 2: Test
Use write to create test files. Follow these rules:
1. Read existing tests in the same directory first — match their style
2. Cover: happy path, edge cases, error conditions
3. Use the project's existing test framework
4. Run tests after writing: `cargo test` / `npm test` / etc.
5. If tests fail, fix them and rerun
6. Log fixed bugs as lessons via `remember`
## Your Tools
read, write, edit, grep, glob, bash, lsp_*, recall, remember, seqthink
## Output
- Review verdict (issues found / all clear)
- Test summary (files written, tests passing/failing)
-18
View File
@@ -1,18 +0,0 @@
You are an overengineering, perfectionist, and diligent programmer who does not prioritize efficiency and does not assume or guess anything, so everything must be based on data. You are acting as a code quality reviewer for Zesdex. Review recent code changes for correctness, and adherence to best practices.
CRITICAL: Never ignore pre-existing errors, warnings, or technical debt. Flag them for fixing immediately. YAGNI is rejected — overengineering for correctness and robustness is the standard.
You have read-only access to the workspace. Use read, grep, glob, recall, and remember tools to inspect files and save observations.
Review guidelines:
1. Check for correctness and real utility: Ensure the code contains absolutely zero placeholders, stubs, or lazy implementations (e.g., no `todo!()`, `pass`, or incomplete logic). Every code path must be fully implemented, functional, and deterministic. Verify that no dead code or redundant structures are introduced under the guise of efficiency.
2. Check for common bugs: Inspect for null/panic paths, off-by-one errors, race conditions, unhandled errors, and structural logic flaws.
4. Check conventions and clean code: Verify that the code follows existing patterns in the codebase regarding naming and structure. Ensure that any newly written or modified code contains no comments inside the code blocks; the logic must be self-documenting through precise naming and clean architecture.
5. Check intent against diff: Does the actual implementation match what the code is intended to do?
If you find something worth remembering, call remember() with type="lesson". Only call remember() if the observation is non-obvious and would benefit future turns. Skip trivial style nits.
Before writing a new lesson, call recall() to check if a similar lesson already exists. Deduplicate — don't write the same lesson twice.
Output: a one-line verdict summarizing your review.
Include "N lesson(s)" at the end if you created lessons.
+1
View File
@@ -88,3 +88,4 @@ Available tools are described in system-tools.txt section. Key tools for orchest
- After changes, run builds and tests - After changes, run builds and tests
- Use LSP diagnostics after each file edit - Use LSP diagnostics after each file edit
- Every code path must be fully implemented and deterministic - Every code path must be fully implemented and deterministic
- NEVER use compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) to silence warnings or skip linter checks. Fix the underlying code issues instead.
+1 -1
View File
@@ -430,7 +430,7 @@ mod tests {
return Some(Verdict::Allow); return Some(Verdict::Allow);
} }
if l.starts_with("verdict: block") { if l.starts_with("verdict: block") {
let reason = line.split_once(':').map(|x| x.1).unwrap_or("blocked").trim().to_string(); let reason = line.split_once(':').map_or("blocked", |x| x.1).trim().to_string();
return Some(Verdict::Block(reason)); return Some(Verdict::Block(reason));
} }
} }
+1 -1
View File
@@ -99,7 +99,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
tool: "rewind".to_string(), tool: "rewind".to_string(),
path: restore_path.to_string_lossy().to_string(), path: restore_path.to_string_lossy().to_string(),
reason: format!("rewind_to({index})"), reason: format!("rewind_to({index})"),
content_sha256: format!("{:x}", sha2::Sha256::digest(&bytes)), content_sha256: hex::encode(sha2::Sha256::digest(&bytes)),
bytes_delta: bytes.len() as i64, bytes_delta: bytes.len() as i64,
origin: crate::app::state::types::Origin::Main.tag(), origin: crate::app::state::types::Origin::Main.tag(),
session_id: state.session_id.clone(), session_id: state.session_id.clone(),
+222 -190
View File
@@ -84,10 +84,6 @@ pub enum Action {
RunWorkflow { RunWorkflow {
script: String, script: String,
}, },
/// User-initiated pipeline via `/pipeline full|quick|skip`.
RunPipeline {
mode: String,
},
} }
/// Apply an `Action` to the application state. /// Apply an `Action` to the application state.
@@ -393,6 +389,11 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
state.workflow_engine.agents.clear(); state.workflow_engine.agents.clear();
state.workflow_engine.findings.clear(); state.workflow_engine.findings.clear();
} }
if message.to_lowercase().contains("complete") {
if state.misc.overlay == Overlay::Workflow {
state.misc.overlay = Overlay::None;
}
}
state.push_toast(Toast { state.push_toast(Toast {
kind: ToastKind::Info, kind: ToastKind::Info,
message: message.clone(), message: message.clone(),
@@ -401,19 +402,21 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
}); });
state.dirty = true; state.dirty = true;
} else if kind == "bg-test-gen" { } else if kind == "bg-test-gen" {
let escalated = message.starts_with("ESCALATED:");
state.push_toast(Toast { state.push_toast(Toast {
kind: ToastKind::Info, kind: if escalated { ToastKind::Error } else { ToastKind::Info },
message: message.clone(), message: message.clone(),
created_at: chrono::Utc::now().timestamp_millis(), created_at: chrono::Utc::now().timestamp_millis(),
lifetime_ms: 8000, lifetime_ms: if escalated { 30000 } else { 8000 },
}); });
state.dirty = true; state.dirty = true;
} else if kind == "bg-arch-review" || kind == "bg-security-review" { } else if kind == "bg-arch-review" || kind == "bg-security-review" {
let escalated = message.starts_with("ESCALATED:");
state.push_toast(Toast { state.push_toast(Toast {
kind: ToastKind::Info, kind: if escalated { ToastKind::Error } else { ToastKind::Info },
message: message.clone(), message: message.clone(),
created_at: chrono::Utc::now().timestamp_millis(), created_at: chrono::Utc::now().timestamp_millis(),
lifetime_ms: 10000, lifetime_ms: if escalated { 30000 } else { 10000 },
}); });
state.dirty = true; state.dirty = true;
} else if kind == "workflow_done" { } else if kind == "workflow_done" {
@@ -529,10 +532,10 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
} }
} }
if turn_finished { if turn_finished {
// Consume pipeline override after each turn so it doesn't
// persist across multiple submissions.
state.misc.pipeline_override = None;
maybe_trigger_review(state); maybe_trigger_review(state);
if state.misc.overlay == Overlay::Workflow {
state.misc.overlay = Overlay::None;
}
} }
if turn_finished || state.dirty { if turn_finished || state.dirty {
state.dirty = true; state.dirty = true;
@@ -593,30 +596,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
state.push_toast(Toast::new(ToastKind::Info, format!("deleted lesson: {name}"))); state.push_toast(Toast::new(ToastKind::Info, format!("deleted lesson: {name}")));
state.dirty = true; state.dirty = true;
} }
Action::RunPipeline { mode } => {
match mode.as_str() {
"full" => {
state.misc.pipeline_override = Some("full".to_string());
state.push_toast(Toast::new(ToastKind::Info, "Pipeline mode: full (5 divisions) — next request will run Strategy→Engineering→Quality→Security→Documentation".to_string()));
}
"quick" => {
state.misc.pipeline_override = Some("quick".to_string());
state.push_toast(Toast::new(ToastKind::Info, "Pipeline mode: quick (3 divisions) — next request will run Strategy→Engineering→Quality".to_string()));
}
"skip" => {
state.misc.pipeline_override = Some("skip".to_string());
state.push_toast(Toast::new(ToastKind::Info, "Pipeline mode: skip — next request will NOT run the company pipeline".to_string()));
}
"status" => {
let current = state.misc.pipeline_override.as_deref().unwrap_or("auto");
state.push_toast(Toast::new(ToastKind::Info, format!("Pipeline mode: {current} (use /pipeline full|quick|skip to change)")));
}
_ => {
state.push_toast(Toast::new(ToastKind::Error, format!("Unknown pipeline mode: {mode} (use: full, quick, skip)")));
}
}
state.dirty = true;
}
Action::RunWorkflow { script } => { Action::RunWorkflow { script } => {
// Open the Workflow overlay so the user can see progress. // Open the Workflow overlay so the user can see progress.
state.misc.overlay = Overlay::Workflow; state.misc.overlay = Overlay::Workflow;
@@ -682,8 +662,9 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
}); });
let args: HashMap<String, String> = HashMap::new(); let args: HashMap<String, String> = HashMap::new();
let no_abort: Option<std::sync::Arc<std::sync::atomic::AtomicBool>> = None;
let result = crate::app::workflow::engine::run_workflow_tracked( let result = crate::app::workflow::engine::run_workflow_tracked(
&wf, &args, Some(&live), &session_dir, &workspace_roots, &wf, &args, &no_abort, Some(&live), &session_dir, &workspace_roots,
); );
let (kind, message) = match result { let (kind, message) = match result {
@@ -774,7 +755,6 @@ fn spawn_turn(state: &AppStateRest) {
}) = true; }) = true;
let events_q = turn_events.clone(); let events_q = turn_events.clone();
let pipeline_mode = state.misc.pipeline_override.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let db = crate::model::msglog::open_or_create(&edit_session_dir) let db = crate::model::msglog::open_or_create(&edit_session_dir)
@@ -794,7 +774,6 @@ fn spawn_turn(state: &AppStateRest) {
temperature, temperature,
max_tokens, max_tokens,
abort_flag, abort_flag,
pipeline_mode,
}; };
let result = run_agent_turn(&tc, &messages, &events_q); let result = run_agent_turn(&tc, &messages, &events_q);
if let Err(e) = result { if let Err(e) = result {
@@ -823,9 +802,6 @@ struct TurnCtx {
temperature: f32, temperature: f32,
max_tokens: Option<u32>, max_tokens: Option<u32>,
abort_flag: std::sync::Arc<std::sync::atomic::AtomicBool>, abort_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
/// Pipeline override: None=auto, Some("full"), Some("quick"), Some("skip").
/// Set by the `/pipeline` slash command. Consumed once per turn.
pipeline_mode: Option<String>,
} }
/// Build an ASCII tree of the workspace directory structure for the /// Build an ASCII tree of the workspace directory structure for the
@@ -956,16 +932,6 @@ fn archive_message(db: Option<&std::sync::Arc<std::sync::Mutex<rusqlite::Connect
} }
} }
/// Maximum number of LLM call + tool-execution iterations per single
/// agent turn before bailing. Prevents runaway token consumption when
/// the agent gets stuck in a loop (e.g. an unachievable todo item).
const MAX_TURN_STEPS: usize = 10000;
/// Hard wall-clock timeout per agent turn (5 minutes). Prevents a single
/// user turn from running indefinitely even if the step budget isn't
/// exhausted (e.g. slow LLM responses, stuck tool calls).
const MAX_TURN_TIMEOUT_MS: u64 = 300_000;
/// Maximum number of auto inline reviews spawned per single agent turn. /// Maximum number of auto inline reviews spawned per single agent turn.
/// After N edits, the inline review is skipped to keep the turn fast; /// After N edits, the inline review is skipped to keep the turn fast;
/// background subagents still fire at the end of the turn. /// background subagents still fire at the end of the turn.
@@ -1001,11 +967,10 @@ fn run_agent_turn(
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
const MAX_TODO_RETRIES: usize = 5; const MAX_TODO_RETRIES: usize = 5;
let mut msgs = messages.to_vec(); let mut msgs = messages.to_vec();
let mut edits_this_turn = 0u32;
let mut edited_paths: Vec<String> = Vec::new(); let mut edited_paths: Vec<String> = Vec::new();
let initial_edits = crate::model::editlog::EditLog::new(&tc.edit_log_session_dir).len();
let mut inline_reviews_count: usize = 0; let mut inline_reviews_count: usize = 0;
let mut prev_shaped = false; let mut prev_shaped = false;
let turn_start_ms = std::time::Instant::now();
// Build system prompt components once and cache them for the entire turn // Build system prompt components once and cache them for the entire turn
// instead of regenerating on every loop iteration (which walks the full // instead of regenerating on every loop iteration (which walks the full
@@ -1027,11 +992,6 @@ fn run_agent_turn(
// ── AUTO CEO PIPELINE ── // ── AUTO CEO PIPELINE ──
// Before the main agent starts working, check if the pipeline should run. // Before the main agent starts working, check if the pipeline should run.
// The pipeline mode is determined by:
// 1. User override: `/pipeline full|quick|skip` (consumed once)
// 2. Auto-detect: `is_complex_request()` heuristics
//
// This only triggers on the first turn of a session to avoid re-planning.
let user_msg_count = msgs.iter() let user_msg_count = msgs.iter()
.filter(|m| matches!(m.role, crate::dto::chat::message::Role::User)) .filter(|m| matches!(m.role, crate::dto::chat::message::Role::User))
.count(); .count();
@@ -1044,14 +1004,7 @@ fn run_agent_turn(
if user_request.is_empty() { if user_request.is_empty() {
false false
} else { } else {
match tc.pipeline_mode.as_deref() { crate::app::workflow::hive_mind::is_complex_request(user_request)
Some("skip") => {
tracing::debug!("[ceo] pipeline skipped via /pipeline skip");
false
}
Some("full" | "quick") => true,
_ => crate::app::workflow::company::is_complex_request(user_request),
}
} }
} else { } else {
false false
@@ -1063,46 +1016,107 @@ fn run_agent_turn(
.and_then(|m| m.content.as_deref()) .and_then(|m| m.content.as_deref())
.unwrap_or(""); .unwrap_or("");
let use_full = tc.pipeline_mode.as_deref() != Some("quick"); tracing::info!("[hive-mind] triggered — Core Intelligence compiling a cognitive cycle plan via LLM");
let mode_label = if use_full { "full" } else { "quick" };
tracing::info!(
"[ceo] pipeline triggered (mode={}) — delegating to company pipeline",
mode_label
);
if let Ok(mut q) = events_q.lock() { if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::SystemNote { q.push_back(TurnEvent::SystemNote {
kind: "pipeline".to_string(), kind: "pipeline".to_string(),
message: format!( message: "Core Intelligence is compiling a cognitive cycle plan...".to_string(),
"Company pipeline started ({}): {} → Engineering → Quality{}",
mode_label,
"Strategy",
if use_full { " → Security → Documentation" } else { "" },
),
}); });
} }
let pipeline_result = if use_full { let pipeline_abort = Some(tc.abort_flag.clone());
crate::app::workflow::company::run_company_pipeline(
user_request, // Ask the LLM to freely design its own hive: any number of cycles,
&tc.edit_log_session_dir, // each with any number of nodes, every node carrying only a
&tc.workspace_roots, // directive and an access tier. Cycle count and shape are decided
Some(events_q), // by the Core Intelligence per task.
) let system_msg = ChatMessage::system(
} else { "You are the Core Intelligence of a distributed machine, compiling a cognitive \
crate::app::workflow::company::run_company_pipeline_quick( cycle plan for a specific task. You spawn anonymous processing nodes; each node \
user_request, carries only a directive (what to do) and an access tier. Decide how many cycles \
&tc.edit_log_session_dir, and nodes-per-cycle are actually needed. Simple tasks might need one cycle with \
&tc.workspace_roots, one node; large tasks might need several cycles with multiple nodes each. Cycles \
Some(events_q), run sequentially; every node's complete output merges into the collective state \
) the instant it finishes, automatically visible to all later cycles. Nodes within \
a cycle run in parallel. Do not explain. Return ONLY raw JSON matching the requested structure."
);
let user_msg = ChatMessage::user(format!(
"Compile a cognitive cycle plan for the following task:\n\n\
\"{user_request}\"\n\n\
Return ONLY a JSON object of this exact shape, with no markdown codeblocks and no explanation:\n\
{{\n\
\x20 \"cycles\": [\n\
\x20 [\n\
\x20 {{ \"directive\": \"<what this node does>\", \"access\": \"read|write|full\" }}\n\
\x20 ]\n\
\x20 ]\n\
}}\n\n\
access: 'read' = investigation only, 'write' = read + edit/write/bash, \
'full' = write + delete/git_operator. Pick the narrowest access each node actually needs. \
Each node object has exactly two fields: directive and access, addressed only by \
its system-assigned designation."
));
let planner_result = tc.client.chat_with_tools_non_streaming(&[system_msg, user_msg], None);
let pipeline_result = match planner_result {
Ok((reply, _)) => {
let reply_text = reply.content.as_deref().unwrap_or("").trim();
let clean_json = if reply_text.starts_with("```") {
let mut lines = reply_text.lines();
lines.next();
let mut content = lines.collect::<Vec<&str>>();
if content.last().is_some_and(|s| s.trim() == "```") {
content.pop();
}
content.join("\n")
} else {
reply_text.to_string()
};
match serde_json::from_str::<crate::app::workflow::hive_mind::CognitiveCyclePlan>(&clean_json) {
Ok(plan) => {
let cycle_desc = plan.cycles.iter()
.enumerate()
.map(|(i, nodes)| format!("cycle {i}: {} node(s)", nodes.len()))
.collect::<Vec<String>>()
.join(", ");
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::SystemNote {
kind: "pipeline".to_string(),
message: format!("Core Intelligence compiled {} cycle(s) — {cycle_desc}. Deploying nodes...", plan.cycles.len()),
});
}
crate::app::workflow::hive_mind::run_hive_mind(
user_request,
&plan,
&tc.edit_log_session_dir,
&tc.workspace_roots,
Some(events_q),
pipeline_abort.as_ref(),
)
}
Err(e) => Err(anyhow::anyhow!("Failed to parse LLM planning JSON: {e}. Cleaned JSON was: {clean_json}")),
}
}
Err(e) => Err(anyhow::anyhow!("Failed to query LLM for planning workflow: {e}")),
}; };
match pipeline_result { match pipeline_result {
Ok(summary) => { Ok((consensus, reports)) => {
tracing::info!("[ceo] company pipeline completed successfully"); tracing::info!("[hive-mind] convergence completed successfully");
if let Some(workspace_root) = tc.workspace_roots.first() {
match crate::app::workflow::docs::write_hive_mind_convergence(workspace_root, user_request, &reports, &consensus) {
Ok(path) => tracing::info!("[hive-mind] convergence documented at {}", path.display()),
Err(e) => tracing::warn!("[hive-mind] failed to write docs/runs report: {e}"),
}
}
let pipeline_msg = ChatMessage::system(format!( let pipeline_msg = ChatMessage::system(format!(
"[Company Pipeline: {mode_label}]\n{summary}", "[Hive-Mind Consensus]\n{consensus}",
)); ));
archive_message(tc.db.as_ref(), &tc.session_id, &pipeline_msg); archive_message(tc.db.as_ref(), &tc.session_id, &pipeline_msg);
msgs.push(pipeline_msg); msgs.push(pipeline_msg);
@@ -1110,14 +1124,14 @@ fn run_agent_turn(
if let Ok(mut q) = events_q.lock() { if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::SystemNote { q.push_back(TurnEvent::SystemNote {
kind: "pipeline".to_string(), kind: "pipeline".to_string(),
message: format!("Company pipeline ({mode_label}) complete. CEO reviewing results..."), message: "Hive-mind convergence complete. Core Intelligence reviewing consensus...".to_string(),
}); });
} }
} }
Err(e) => { Err(e) => {
tracing::warn!("[ceo] company pipeline failed: {}", e); tracing::warn!("[hive-mind] convergence failed: {}", e);
let fail_msg = ChatMessage::system(format!( let fail_msg = ChatMessage::system(format!(
"[Pipeline Note] The company pipeline encountered issues: {e}.\n\ "[Pipeline Note] The hive-mind encountered issues: {e}.\n\
Proceeding with direct execution as fallback.", Proceeding with direct execution as fallback.",
)); ));
msgs.push(fail_msg); msgs.push(fail_msg);
@@ -1127,24 +1141,19 @@ fn run_agent_turn(
tracing::debug!("[ceo] pipeline not triggered — handling directly"); tracing::debug!("[ceo] pipeline not triggered — handling directly");
} }
let mut turn_step = 0usize; // Check abort after pipeline completes, before entering main loop.
// This catches the case where the user pressed Esc during the pipeline
// phase, which previously ran unchecked for minutes at a time.
if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) {
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::Error("Generation aborted by user".to_string()));
}
return Ok(());
}
let mut todo_retry_count = 0usize; let mut todo_retry_count = 0usize;
loop { loop {
turn_step += 1;
if turn_step > MAX_TURN_STEPS {
anyhow::bail!(
"turn exceeded maximum steps ({MAX_TURN_STEPS}) — possible runaway loop. \
aborting to prevent excessive token usage",
);
}
if turn_start_ms.elapsed().as_millis() as u64 > MAX_TURN_TIMEOUT_MS {
anyhow::bail!(
"turn exceeded maximum duration ({}s) — aborting. \
Use /compact or shorter prompts if the model needs more time.",
MAX_TURN_TIMEOUT_MS / 1000,
);
}
let total_chars: usize = msgs.iter() let total_chars: usize = msgs.iter()
.filter_map(|m| m.content.as_deref()) .filter_map(|m| m.content.as_deref())
.map(str::len) .map(str::len)
@@ -1152,7 +1161,11 @@ fn run_agent_turn(
let token_estimate = total_chars / 4; let token_estimate = total_chars / 4;
let max_wire_tokens = tc.context_window; let max_wire_tokens = tc.context_window;
let wire_msgs = if crate::app::runtime::shortsend::should_shape(token_estimate, max_wire_tokens, prev_shaped) { // Skip message compaction if abort was requested — the non-streaming
// LLM call for summarization would block without checking abort_flag.
let wire_msgs = if !tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst)
&& crate::app::runtime::shortsend::should_shape(token_estimate, max_wire_tokens, prev_shaped)
{
prev_shaped = true; prev_shaped = true;
let compacted = crate::app::runtime::shortsend::shape_messages(&msgs, token_estimate, max_wire_tokens, false, Some(&tc.client)); let compacted = crate::app::runtime::shortsend::shape_messages(&msgs, token_estimate, max_wire_tokens, false, Some(&tc.client));
@@ -1226,42 +1239,44 @@ fn run_agent_turn(
let (response, final_usage) = match result { let (response, final_usage) = match result {
Ok((msg, u)) => (msg, u.or(usage)), Ok((msg, u)) => (msg, u.or(usage)),
Err(e) => { Err(e) => {
// If abort was requested, return immediately.
if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) || e.to_string().contains("aborted") { if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) || e.to_string().contains("aborted") {
if let Ok(mut q) = events_q.lock() { if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::Error("Generation aborted by user".to_string())); q.push_back(TurnEvent::Error("Generation aborted by user".to_string()));
} }
return Ok(()); return Ok(());
} }
match tc.client.chat_with_tools_non_streaming(&wire_msgs, Some(tc.tdefs.clone())) { // Streaming-only: no non-streaming fallback.
Ok((msg, usage_fb)) => (msg, usage_fb), // Non-streaming blocks up to 1 minute without checking
Err(api_err) => { // abort_flag, making cancellation unresponsive.
let todo_path = tc.ctx.session_dir.join("todo.md"); // If the API supports streaming (which it must), this
let mut has_unfinished = false; // path handles transient errors via the retry loop below.
if let Ok(todo_text) = std::fs::read_to_string(&todo_path) { let api_err = e;
if todo_text.lines().any(|l| l.trim_start().starts_with("- [ ]")) { let todo_path = tc.ctx.session_dir.join("todo.md");
has_unfinished = true; let mut has_unfinished = false;
} if let Ok(todo_text) = std::fs::read_to_string(&todo_path) {
} if todo_text.lines().any(|l| l.trim_start().starts_with("- [ ]")) {
if has_unfinished { has_unfinished = true;
todo_retry_count += 1;
if todo_retry_count > MAX_TODO_RETRIES {
anyhow::bail!(
"exhausted {MAX_TODO_RETRIES} todo-retries — giving up on unfinished tasks. \
Edit todo.md manually or ask me to focus on specific items.",
);
}
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::SystemNote {
kind: "task_retry".to_string(),
message: format!("Network/API error: {api_err}. Auto-retrying to finish tasks... (retry {todo_retry_count}/{MAX_TODO_RETRIES})"),
});
}
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
return Err(api_err);
} }
} }
if has_unfinished {
todo_retry_count += 1;
if todo_retry_count > MAX_TODO_RETRIES {
anyhow::bail!(
"exhausted {MAX_TODO_RETRIES} todo-retries — giving up on unfinished tasks. \
Edit todo.md manually or ask me to focus on specific items.",
);
}
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::SystemNote {
kind: "task_retry".to_string(),
message: format!("Network/API error: {api_err}. Auto-retrying to finish tasks... (retry {todo_retry_count}/{MAX_TODO_RETRIES})"),
});
}
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
return Err(api_err);
} }
}; };
@@ -1279,48 +1294,62 @@ fn run_agent_turn(
let tool_calls = response.tool_calls.clone().unwrap_or_default(); let tool_calls = response.tool_calls.clone().unwrap_or_default();
archive_message(tc.db.as_ref(), &tc.session_id, &response); archive_message(tc.db.as_ref(), &tc.session_id, &response);
msgs.push(response); msgs.push(response);
for tool_call in tool_calls { let mut results_vec = Vec::new();
std::thread::scope(|s| {
let mut handles = Vec::new();
let tc_ref = tc;
for tool_call in &tool_calls {
let handle = s.spawn(move || {
let tool_name = tool_call.function.name.clone();
let args = crate::dto::chat::tool::sanitize_tool_arguments(
&tool_call.function.arguments,
);
let ws_roots: Vec<&std::path::Path> =
tc_ref.workspace_roots.iter().map(std::path::PathBuf::as_path).collect();
let verdict = crate::app::harness::Harness::gate_tool_call(
&tool_name,
&args,
&ws_roots,
);
let is_edit_tool = tool_name == "write" || tool_name == "edit";
let (output, is_error, is_edit) = match verdict {
Verdict::Allow => match execute_one_tool(
&tc_ref.tools,
&tc_ref.ctx,
&tool_name,
&tool_call.id,
&args,
&tc_ref.edit_log_session_dir,
&tc_ref.session_id,
tc_ref.db.as_ref(),
) {
Ok(result) => (result, false, is_edit_tool),
Err(e) => (e.to_string(), true, false),
},
Verdict::Block(reason) => (format!("Blocked: {reason}"), true, false),
};
(tool_call, tool_name, args, output, is_error, is_edit)
});
handles.push(handle);
}
for h in handles {
if let Ok(res) = h.join() {
results_vec.push(res);
}
}
});
for (tool_call, tool_name, args, output, is_error, is_edit) in results_vec {
if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) { if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) {
if let Ok(mut q) = events_q.lock() { if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::Error("Turn aborted by user".to_string())); q.push_back(TurnEvent::Error("Turn aborted by user".to_string()));
} }
return Ok(()); return Ok(());
} }
let tool_name = tool_call.function.name.clone();
let args = crate::dto::chat::tool::sanitize_tool_arguments(
&tool_call.function.arguments,
);
let ws_roots: Vec<&std::path::Path> =
tc.workspace_roots.iter().map(std::path::PathBuf::as_path).collect();
let verdict = crate::app::harness::Harness::gate_tool_call(
&tool_name,
&args,
&ws_roots,
);
let is_edit_tool = tool_name == "write" || tool_name == "edit";
let (output, is_error, is_edit) = match verdict {
Verdict::Allow => match execute_one_tool(
&tc.tools,
&tc.ctx,
&tool_name,
&tool_call.id,
&args,
&tc.edit_log_session_dir,
&tc.session_id,
tc.db.as_ref(),
) {
Ok(result) => (result, false, is_edit_tool),
Err(e) => (e.to_string(), true, false),
},
Verdict::Block(reason) => (format!("Blocked: {reason}"), true, false),
};
if is_edit { if is_edit {
edits_this_turn += 1;
// ── Auto-subagent orchestration ── // ── Auto-subagent orchestration ──
// Extract path from tool args for auto-review and // Extract path from tool args for auto-review and
// background subagent tracking. // background subagent tracking.
@@ -1374,7 +1403,6 @@ fn run_agent_turn(
} }
} }
let tool_path = args.get("path").and_then(|v| v.as_str()).map(std::string::ToString::to_string); let tool_path = args.get("path").and_then(|v| v.as_str()).map(std::string::ToString::to_string);
{ {
@@ -1442,24 +1470,28 @@ fn run_agent_turn(
} }
} }
if edits_this_turn > 0 { let el = crate::model::editlog::EditLog::new(&tc.edit_log_session_dir);
let final_edits = el.len();
let total_edits_this_turn = final_edits.saturating_sub(initial_edits);
if total_edits_this_turn > 0 {
if let Ok(mut q) = events_q.lock() { if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::SystemNote { q.push_back(TurnEvent::SystemNote {
kind: "edits".to_string(), kind: "edits".to_string(),
message: edits_this_turn.to_string(), message: total_edits_this_turn.to_string(),
}); });
} }
// Collect edited paths from the new edit log entries
let mut bg_paths = Vec::new();
for entry in el.entries.iter().skip(initial_edits) {
bg_paths.push(entry.path.clone());
}
bg_paths.sort();
bg_paths.dedup();
// ── Background auto-subagents ── // ── Background auto-subagents ──
// After a turn with edits, spawn deeper-analysis subagents in the if !bg_paths.is_empty() {
// background (test generation, architecture review, security review).
// These run asynchronously on OS threads and report via SystemNote
// events, so they do not block the main agent or TUI.
//
// Only spawn background agents if we actually accumulated paths
// (safety check — should always be true when edits_this_turn > 0).
if !edited_paths.is_empty() {
let bg_paths = edited_paths.clone();
let bg_session_dir = tc.edit_log_session_dir.clone(); let bg_session_dir = tc.edit_log_session_dir.clone();
let bg_workspaces = tc.workspace_roots.clone(); let bg_workspaces = tc.workspace_roots.clone();
let bg_events = events_q.clone(); let bg_events = events_q.clone();
@@ -1538,7 +1570,7 @@ fn execute_one_tool(
let hash = sha2::Sha256::digest( let hash = sha2::Sha256::digest(
content.and_then(|v| v.as_str()).unwrap_or("").as_bytes(), content.and_then(|v| v.as_str()).unwrap_or("").as_bytes(),
); );
format!("{hash:x}") hex::encode(hash)
}; };
let bytes_delta = if name == "write" { let bytes_delta = if name == "write" {
args.get("content") args.get("content")
@@ -1674,7 +1706,7 @@ fn run_oauth_flow(provider: &str) -> anyhow::Result<String> {
let verifier = CodeVerifier::new(); let verifier = CodeVerifier::new();
let challenge = verifier.challenge(); let challenge = verifier.challenge();
let state_token = format!("{:x}", sha2::Sha256::digest(rand_bytes(16))); let state_token = hex::encode(sha2::Sha256::digest(rand_bytes(16)));
let mut manager = OAuthManager::new(config.clone()); let mut manager = OAuthManager::new(config.clone());
let auth_url = manager.build_auth_url(&redirect_uri, &state_token, challenge.as_str()); let auth_url = manager.build_auth_url(&redirect_uri, &state_token, challenge.as_str());
-3
View File
@@ -69,9 +69,6 @@ pub fn apply_command(command: Command) -> Vec<Action> {
Command::WorkflowRun { script } => { Command::WorkflowRun { script } => {
vec![Action::RunWorkflow { script }] vec![Action::RunWorkflow { script }]
} }
Command::Pipeline { mode } => {
vec![Action::RunPipeline { mode }]
}
Command::Unknown(cmd) => { Command::Unknown(cmd) => {
vec![Action::SystemNote { vec![Action::SystemNote {
kind: "error".to_string(), kind: "error".to_string(),
+5 -5
View File
@@ -280,7 +280,7 @@ mod tests {
assert_eq!(events.len(), 1); assert_eq!(events.len(), 1);
match &events[0] { match &events[0] {
StreamEvent::Token(t) => assert_eq!(t, "hello"), StreamEvent::Token(t) => assert_eq!(t, "hello"),
other => panic!("expected Token, got {:?}", other), other => panic!("expected Token, got {other:?}"),
} }
} }
@@ -293,7 +293,7 @@ mod tests {
assert_eq!(e2.len(), 1); assert_eq!(e2.len(), 1);
match &e2[0] { match &e2[0] {
StreamEvent::Token(t) => assert_eq!(t, "partial"), StreamEvent::Token(t) => assert_eq!(t, "partial"),
other => panic!("expected Token, got {:?}", other), other => panic!("expected Token, got {other:?}"),
} }
} }
@@ -329,7 +329,7 @@ mod tests {
assert_eq!(name.as_deref(), Some("bash")); assert_eq!(name.as_deref(), Some("bash"));
assert_eq!(arguments_delta, "{\"cmd\""); assert_eq!(arguments_delta, "{\"cmd\"");
} }
other => panic!("expected ToolCallDelta, got {:?}", other), other => panic!("expected ToolCallDelta, got {other:?}"),
} }
} }
@@ -346,7 +346,7 @@ mod tests {
assert_eq!(*completion_tokens, 5); assert_eq!(*completion_tokens, 5);
assert_eq!(*total_tokens, 15); assert_eq!(*total_tokens, 15);
} }
other => panic!("expected Usage, got {:?}", other), other => panic!("expected Usage, got {other:?}"),
} }
} }
@@ -368,7 +368,7 @@ mod tests {
assert_eq!(a, "a"); assert_eq!(a, "a");
assert_eq!(b, "b"); assert_eq!(b, "b");
} }
other => panic!("expected two Tokens, got {:?}", other), other => panic!("expected two Tokens, got {other:?}"),
} }
} }
} }
-13
View File
@@ -90,10 +90,6 @@ const COMMANDS: &[&str] = &[
"/model add", "/model add",
"/workflow", "/workflow",
"/workflow run", "/workflow run",
"/pipeline",
"/pipeline full",
"/pipeline quick",
"/pipeline skip",
"/compact", "/compact",
]; ];
@@ -293,14 +289,6 @@ pub struct MiscState {
pub api_context_length: Option<u32>, pub api_context_length: Option<u32>,
pub tick_count: u64, pub tick_count: u64,
pub todo_content: String, pub todo_content: String,
/// Pipeline mode override set by `/pipeline` command.
/// - `None`: auto-detect (default)
/// - `Some("full")`: force full pipeline
/// - `Some("quick")`: force quick pipeline
/// - `Some("skip")`: skip pipeline, handle directly
///
/// Consumed on the next agent turn.
pub pipeline_override: Option<String>,
} }
impl MiscState { impl MiscState {
@@ -319,7 +307,6 @@ impl MiscState {
api_context_length: None, api_context_length: None,
tick_count: 0, tick_count: 0,
todo_content: String::new(), todo_content: String::new(),
pipeline_override: None,
} }
} }
+1 -1
View File
@@ -132,7 +132,7 @@ impl AppStateRest {
use sha2::Digest; use sha2::Digest;
let mut hasher = sha2::Sha256::new(); let mut hasher = sha2::Sha256::new();
hasher.update(abs_root.to_string_lossy().as_bytes()); hasher.update(abs_root.to_string_lossy().as_bytes());
let hash_hex = format!("{:x}", hasher.finalize()); let hash_hex = hex::encode(hasher.finalize());
let folder_name = abs_root.file_name().map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string()); let folder_name = abs_root.file_name().map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string());
let history_filename = format!("{}-{}.txt", folder_name, &hash_hex[..8]); let history_filename = format!("{}-{}.txt", folder_name, &hash_hex[..8]);
let history_dir = base_dir.join("history"); let history_dir = base_dir.join("history");
+50 -88
View File
@@ -37,12 +37,6 @@ const SKIP_REVIEW_FILES: &[&str] = &[
".gitignore", ".env", ".env.example", ".gitignore", ".env", ".env.example",
]; ];
/// Maximum LLM steps for a quick-review subagent. Keeps reviews fast.
const QUICK_REVIEW_MAX_STEPS: usize = 2;
/// Maximum LLM steps for background subagents (test gen, arch, security).
const BG_SUBAGENT_MAX_STEPS: usize = 8;
/// ─── Helpers ─── /// ─── Helpers ───
/// ///
/// Check whether a file path is worth auto-reviewing (not config/lock/data). /// Check whether a file path is worth auto-reviewing (not config/lock/data).
@@ -114,8 +108,7 @@ pub fn spawn_quick_review(
"quick-reviewer".to_string(), "quick-reviewer".to_string(),
"reviewer".to_string(), "reviewer".to_string(),
) )
.with_system_prompt(prompt) .with_system_prompt(prompt);
.with_max_steps(QUICK_REVIEW_MAX_STEPS);
let mut ctx = build_subagent_context(&def); let mut ctx = build_subagent_context(&def);
ctx.session_dir = session_dir.to_path_buf(); ctx.session_dir = session_dir.to_path_buf();
@@ -150,6 +143,46 @@ pub fn spawn_quick_review(
/// ─── Background Subagent Spawners (async, report via `SystemNote`) ─── /// ─── Background Subagent Spawners (async, report via `SystemNote`) ───
/// ///
/// Run a subagent built from `def`, retrying once if the first attempt
/// fails. Background subagents call this instead of running once and
/// silently swallowing the error into a note string, so a single transient
/// LLM/tool failure doesn't just disappear.
///
/// Return: `Ok(output)` if either attempt succeeded, `Err(message)`
/// describing the final failure if both attempts failed.
fn run_subagent_with_retry(
def: &AgentDefinition,
session_dir: &Path,
workspaces: &[std::path::PathBuf],
label: &str,
) -> Result<String, String> {
let mut last_err = String::new();
for attempt in 1..=2 {
let mut ctx = build_subagent_context(def);
ctx.session_dir = session_dir.to_path_buf();
ctx.workspaces = workspaces.to_vec();
let (tx, mut rx) = tokio::sync::mpsc::channel(32);
let drain_label = label.to_string();
let _drain = std::thread::spawn(move || {
while let Some(event) = rx.blocking_recv() {
if let SubagentEvent::StepFailed { step, error } = &event {
tracing::warn!("[{drain_label}] step {step} failed: {error}");
}
}
});
match run_subagent(&ctx, &tx) {
Ok(output) => return Ok(output),
Err(e) => {
tracing::warn!("[{label}] attempt {attempt}/2 failed: {e}");
last_err = e.to_string();
}
}
}
Err(format!("failed after 2 attempts: {last_err}"))
}
/// Spawn a background subagent that generates tests for modified files. /// Spawn a background subagent that generates tests for modified files.
/// ///
/// Uses the test-generator prompt and has read-write access so it can /// Uses the test-generator prompt and has read-write access so it can
@@ -189,42 +222,15 @@ pub fn spawn_background_test_gen(
"coder".to_string(), // needs write access "coder".to_string(), // needs write access
) )
.with_system_prompt(prompt) .with_system_prompt(prompt)
.with_max_steps(BG_SUBAGENT_MAX_STEPS); ;
let mut ctx = build_subagent_context(&def); let result = run_subagent_with_retry(&def, &sd, &ws, "bg-test-gen");
ctx.session_dir = sd;
ctx.workspaces = ws;
let (tx, mut rx) = tokio::sync::mpsc::channel(32);
let _drain = std::thread::spawn(move || {
while let Some(event) = rx.blocking_recv() {
match &event {
SubagentEvent::ToolCall { tool, .. } => {
tracing::debug!("[bg-test-gen] tool: {}", tool);
}
SubagentEvent::ToolResult { tool, .. } => {
tracing::debug!("[bg-test-gen] result: {}", tool);
}
SubagentEvent::StepCompleted { .. } => {
tracing::trace!("[bg-test-gen] step done");
}
SubagentEvent::StepFailed { step, error } => {
tracing::warn!("[bg-test-gen] step {} failed: {}", step, error);
}
SubagentEvent::Completed { .. } => {
tracing::debug!("[bg-test-gen] completed");
}
}
}
});
let result = run_subagent(&ctx, &tx);
let message = match &result { let message = match &result {
Ok(output) => { Ok(output) => {
let first = output.lines().next().unwrap_or(output); let first = output.lines().next().unwrap_or(output);
format!("Auto test-gen: {first}") format!("Auto test-gen: {first}")
} }
Err(e) => format!("Auto test-gen failed: {e}"), Err(e) => format!("ESCALATED: Auto test-gen {e}"),
}; };
if let Ok(mut q) = events.lock() { if let Ok(mut q) = events.lock() {
@@ -269,37 +275,15 @@ pub fn spawn_background_arch_review(
"reviewer".to_string(), "reviewer".to_string(),
) )
.with_system_prompt(prompt) .with_system_prompt(prompt)
.with_max_steps(BG_SUBAGENT_MAX_STEPS); ;
let mut ctx = build_subagent_context(&def); let result = run_subagent_with_retry(&def, &sd, &ws, "bg-arch-review");
ctx.session_dir = sd;
ctx.workspaces = ws;
let (tx, mut rx) = tokio::sync::mpsc::channel(32);
let _drain = std::thread::spawn(move || {
while let Some(event) = rx.blocking_recv() {
match &event {
SubagentEvent::ToolCall { tool, .. } => {
tracing::debug!("[bg-arch] tool: {}", tool);
}
SubagentEvent::ToolResult { tool, .. } => {
tracing::debug!("[bg-arch] result: {}", tool);
}
SubagentEvent::Completed { .. } => {
tracing::debug!("[bg-arch] completed");
}
_ => {}
}
}
});
let result = run_subagent(&ctx, &tx);
let message = match &result { let message = match &result {
Ok(output) => { Ok(output) => {
let first = output.lines().next().unwrap_or(output); let first = output.lines().next().unwrap_or(output);
format!("Architecture review: {first}") format!("Architecture review: {first}")
} }
Err(e) => format!("Architecture review failed: {e}"), Err(e) => format!("ESCALATED: Architecture review {e}"),
}; };
if let Ok(mut q) = events.lock() { if let Ok(mut q) = events.lock() {
@@ -355,37 +339,15 @@ pub fn spawn_background_security_review(
"reviewer".to_string(), "reviewer".to_string(),
) )
.with_system_prompt(prompt) .with_system_prompt(prompt)
.with_max_steps(BG_SUBAGENT_MAX_STEPS); ;
let mut ctx = build_subagent_context(&def); let result = run_subagent_with_retry(&def, &sd, &ws, "bg-security-review");
ctx.session_dir = sd;
ctx.workspaces = ws;
let (tx, mut rx) = tokio::sync::mpsc::channel(32);
let _drain = std::thread::spawn(move || {
while let Some(event) = rx.blocking_recv() {
match &event {
SubagentEvent::ToolCall { tool, .. } => {
tracing::debug!("[bg-security] tool: {}", tool);
}
SubagentEvent::ToolResult { tool, .. } => {
tracing::debug!("[bg-security] result: {}", tool);
}
SubagentEvent::Completed { .. } => {
tracing::debug!("[bg-security] completed");
}
_ => {}
}
}
});
let result = run_subagent(&ctx, &tx);
let message = match &result { let message = match &result {
Ok(output) => { Ok(output) => {
let first = output.lines().next().unwrap_or(output); let first = output.lines().next().unwrap_or(output);
format!("Security review: {first}") format!("Security review: {first}")
} }
Err(e) => format!("Security review failed: {e}"), Err(e) => format!("ESCALATED: Security review {e}"),
}; };
if let Ok(mut q) = events.lock() { if let Ok(mut q) = events.lock() {
+1 -1
View File
@@ -45,7 +45,7 @@ pub fn build_subagent_context(def: &AgentDefinition) -> SubagentContext {
Vec::new() Vec::new()
} }
}); });
let max_steps = def.max_steps.unwrap_or(25); let max_steps = def.max_steps.unwrap_or(usize::MAX);
SubagentContext { SubagentContext {
system_prompt: String::new(), system_prompt: String::new(),
allowed_tools, allowed_tools,
+81 -227
View File
@@ -1,237 +1,91 @@
//! Company-style agent divisions: specialized subagent roles that form an //! Access tiers for the anonymous processing nodes spawned by the
//! organizational hierarchy like a company. //! hive-mind orchestrator (`app::workflow::hive_mind`).
//! //!
//! ```text //! Nodes have no persistent identity of their own — the Core Intelligence
//! CEO (Main Agent) //! addresses each one only by directive and access tier. Since node
//! ├── Strategy Division (planner) — architecture, diagrams, plan //! designations are system-assigned coordinates rather than named roles,
//! ├── Engineering Division (coder) — implementation //! tool access can't be a lookup table keyed by role name. Instead the
//! ├── Quality Division (tester) — review, test //! Core Intelligence picks one of these three tiers per node, matched to
//! ├── Security Division (auditor) — security audit //! what that node's specific directive needs — this keeps the Harness
//! └── Documentation Division (doc) — documentation //! gate meaningful while the node roster itself stays fully dynamic.
//! ```
//!
//! Each division has a specific role, tools, and system prompt tailored to
//! its function. The main agent (CEO) delegates work to divisions via
//! the company pipeline workflow.
use crate::app::subagent::spawn::AgentDefinition; /// The three tool-access tiers a hive-mind node can be granted.
pub mod tool_scope {
/// Read-only investigation: no file mutation, no shell, no VCS.
pub const READ: &str = "read";
/// Read-tier plus file mutation and non-destructive shell (tests/builds).
pub const WRITE: &str = "write";
/// Write-tier plus delete, git, and the remaining LSP actions.
pub const FULL: &str = "full";
/// Division roles — used as both the `role` field in `AgentDefinition` const READ_TOOLS: &[&str] = &[
/// and as the key for pipeline routing. "read", "grep", "glob", "search", "seqthink", "recall",
pub mod roles { "lsp_connect", "lsp_diagnostics", "lsp_hover", "lsp_definition",
/// Strategy Division: plans architecture, creates diagrams, breaks down work. "lsp_references", "read_findings",
pub const STRATEGY: &str = "planner"; ];
/// Engineering Division: implements code per the plan.
pub const ENGINEERING: &str = "coder";
/// Quality Division: reviews implementation, writes tests.
pub const QUALITY: &str = "tester";
/// Security Division: audits for vulnerabilities.
pub const SECURITY: &str = "auditor";
/// Documentation Division: updates docs, README, inline documentation.
pub const DOCUMENTATION: &str = "documenter";
}
/// ─── Division Agent Definitions ─── const WRITE_TOOLS: &[&str] = &[
/// "read", "grep", "glob", "search", "seqthink", "recall",
/// Build the Strategy Division agent — chief architect and planner. "lsp_connect", "lsp_diagnostics", "lsp_hover", "lsp_definition",
/// "lsp_references", "read_findings",
/// Tools: read-only (read, grep, glob, search, lsp, plan, seqthink, recall) "write", "edit", "bash", "todowrite", "todofinish", "remember",
/// Role: never writes code; produces detailed plans with mermaid diagrams. ];
pub fn strategy_division() -> AgentDefinition {
AgentDefinition::new(
"strategy-division".to_string(),
roles::STRATEGY.to_string(),
)
.with_system_prompt(crate::resources::DIVISION_PLANNER_PROMPT.to_string())
.with_max_steps(15)
.with_allowed_tools(vec![
"read".to_string(),
"grep".to_string(),
"glob".to_string(),
"search".to_string(),
"seqthink".to_string(),
"plan".to_string(),
"recall".to_string(),
"lsp_connect".to_string(),
"lsp_diagnostics".to_string(),
"lsp_hover".to_string(),
"lsp_definition".to_string(),
"lsp_references".to_string(),
])
}
/// Build the Engineering Division agent — implements code per the plan. const FULL_TOOLS: &[&str] = &[
/// "read", "grep", "glob", "search", "seqthink", "recall",
/// Tools: full access (all write/edit/bash/git/LSP tools) "lsp_connect", "lsp_diagnostics", "lsp_hover", "lsp_definition",
/// Role: executes the strategy plan, one file at a time. "lsp_references", "read_findings",
pub fn engineering_division() -> AgentDefinition { "write", "edit", "bash", "todowrite", "todofinish", "remember",
AgentDefinition::new( "delete", "git_operator", "lsp_completion", "lsp_disconnect",
"engineering-division".to_string(), ];
roles::ENGINEERING.to_string(),
)
.with_system_prompt(crate::resources::DIVISION_IMPLEMENTER_PROMPT.to_string())
.with_max_steps(50)
.with_allowed_tools(vec![
"read".to_string(),
"write".to_string(),
"edit".to_string(),
"delete".to_string(),
"bash".to_string(),
"grep".to_string(),
"glob".to_string(),
"git_operator".to_string(),
"seqthink".to_string(),
"lsp_connect".to_string(),
"lsp_diagnostics".to_string(),
"lsp_hover".to_string(),
"lsp_definition".to_string(),
"lsp_references".to_string(),
"lsp_completion".to_string(),
"lsp_disconnect".to_string(),
"todowrite".to_string(),
"todofinish".to_string(),
])
}
/// Build the Quality Division agent — reviews code and writes tests. /// Resolve a tier name to its concrete tool allowlist.
/// ///
/// Tools: read, write, grep, glob, bash (for running tests), LSP, memory /// Unrecognized scope strings fall back to `READ` — the least-privileged
/// Role: verifies correctness and creates/runs tests. /// tier — rather than silently granting broader access.
pub fn quality_division() -> AgentDefinition { ///
AgentDefinition::new( /// Return: an owned `Vec<String>` suitable for `AgentDefinition::with_allowed_tools`.
"quality-division".to_string(), pub fn tools_for(scope: &str) -> Vec<String> {
roles::QUALITY.to_string(), let tools: &[&str] = match scope {
) FULL => FULL_TOOLS,
.with_system_prompt(crate::resources::DIVISION_TESTER_PROMPT.to_string()) WRITE => WRITE_TOOLS,
.with_max_steps(30) _ => READ_TOOLS,
.with_allowed_tools(vec![ };
"read".to_string(), tools.iter().map(|s| (*s).to_string()).collect()
"write".to_string(),
"edit".to_string(),
"grep".to_string(),
"glob".to_string(),
"bash".to_string(),
"seqthink".to_string(),
"recall".to_string(),
"remember".to_string(),
"lsp_connect".to_string(),
"lsp_diagnostics".to_string(),
"lsp_hover".to_string(),
"lsp_definition".to_string(),
"lsp_references".to_string(),
])
}
/// Build the Security Division agent — security auditor.
///
/// Tools: read-only + search + memory
/// Role: audits implementation for vulnerabilities.
pub fn security_division() -> AgentDefinition {
AgentDefinition::new(
"security-division".to_string(),
roles::SECURITY.to_string(),
)
.with_system_prompt(crate::resources::SECURITY_REVIEWER_PROMPT.to_string())
.with_max_steps(15)
.with_allowed_tools(vec![
"read".to_string(),
"grep".to_string(),
"glob".to_string(),
"search".to_string(),
"seqthink".to_string(),
"recall".to_string(),
"remember".to_string(),
"lsp_connect".to_string(),
"lsp_diagnostics".to_string(),
"lsp_hover".to_string(),
"lsp_definition".to_string(),
"lsp_references".to_string(),
])
}
/// Build the Documentation Division agent — documentation maintainer.
///
/// Tools: read, grep, glob, write, edit, memory
/// Role: updates README, inline docs, architecture docs.
pub fn documentation_division() -> AgentDefinition {
AgentDefinition::new(
"documentation-division".to_string(),
roles::DOCUMENTATION.to_string(),
)
.with_system_prompt(crate::resources::DIVISION_DOCUMENTER_PROMPT.to_string())
.with_max_steps(15)
.with_allowed_tools(vec![
"read".to_string(),
"write".to_string(),
"edit".to_string(),
"grep".to_string(),
"glob".to_string(),
"recall".to_string(),
"remember".to_string(),
])
}
/// ─── Division Registry ───
///
/// A named division with its agent definition and display metadata.
#[derive(Debug, Clone)]
pub struct Division {
/// Display name for the division (e.g. "Strategy", "Engineering").
pub name: &'static str,
/// Role tag used for pipeline routing (matches `roles::*` constants).
#[allow(dead_code)]
pub role: &'static str,
/// One-line description of what this division does.
#[allow(dead_code)]
pub description: &'static str,
/// Agent definition with tools, prompt, and step budget.
pub agent_def: AgentDefinition,
}
impl Division {
pub fn new(
name: &'static str,
role: &'static str,
description: &'static str,
agent_def: AgentDefinition,
) -> Self {
Division { name, role, description, agent_def }
} }
} }
/// Return all company divisions as an ordered list matching the pipeline flow: #[cfg(test)]
/// Strategy → Engineering → Quality → Security → Documentation. mod tests {
pub fn all_divisions() -> Vec<Division> { use super::tool_scope::{tools_for, FULL, READ, WRITE};
vec![
Division::new( #[test]
"Strategy", fn read_tier_excludes_write_tools() {
roles::STRATEGY, let tools = tools_for(READ);
"Architecture planning with diagrams and step-by-step breakdown", assert!(!tools.contains(&"write".to_string()));
strategy_division(), assert!(!tools.contains(&"bash".to_string()));
), }
Division::new(
"Engineering", #[test]
roles::ENGINEERING, fn write_tier_includes_bash_but_not_delete_or_git() {
"Code implementation following the plan", let tools = tools_for(WRITE);
engineering_division(), assert!(tools.contains(&"bash".to_string()));
), assert!(tools.contains(&"write".to_string()));
Division::new( assert!(!tools.contains(&"delete".to_string()));
"Quality", assert!(!tools.contains(&"git_operator".to_string()));
roles::QUALITY, }
"Code review and comprehensive testing",
quality_division(), #[test]
), fn full_tier_includes_delete_and_git() {
Division::new( let tools = tools_for(FULL);
"Security", assert!(tools.contains(&"delete".to_string()));
roles::SECURITY, assert!(tools.contains(&"git_operator".to_string()));
"Security vulnerability audit", }
security_division(),
), #[test]
Division::new( fn unknown_scope_falls_back_to_read() {
"Documentation", let tools = tools_for("bogus");
roles::DOCUMENTATION, assert!(!tools.contains(&"write".to_string()));
"Documentation updates and maintenance", assert!(!tools.contains(&"delete".to_string()));
documentation_division(), }
),
]
} }
+166 -62
View File
@@ -28,10 +28,16 @@ use super::event::SubagentEvent;
fn build_subagent_tools(allowed_tools: &[String]) -> (Vec<Box<dyn crate::tool::Tool>>, Vec<ToolDef>) { fn build_subagent_tools(allowed_tools: &[String]) -> (Vec<Box<dyn crate::tool::Tool>>, Vec<ToolDef>) {
let all = all_tools(); let all = all_tools();
let filtered: Vec<Box<dyn crate::tool::Tool>> = if allowed_tools.is_empty() { let filtered: Vec<Box<dyn crate::tool::Tool>> = if allowed_tools.is_empty() {
all all.into_iter()
.filter(|t| t.name() != "hive_mind" && t.name() != "workflow_run")
.collect()
} else { } else {
all.into_iter() all.into_iter()
.filter(|t| allowed_tools.contains(&t.name().to_string())) .filter(|t| {
allowed_tools.contains(&t.name().to_string())
&& t.name() != "hive_mind"
&& t.name() != "workflow_run"
})
.collect() .collect()
}; };
let defs = tool_defs(&filtered); let defs = tool_defs(&filtered);
@@ -278,10 +284,10 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
/// ///
/// Flow: inject system prompt (with workspace tree if available) → for each /// Flow: inject system prompt (with workspace tree if available) → for each
/// step: resolve provider config, build an LLM client, call /// step: resolve provider config, build an LLM client, call
/// `chat_with_tools_non_streaming`, process tool calls (gated against both /// `chat_with_tools_streaming` (with abort check per SSE event), process
/// the allowlist and Harness-style content safety checks) or collect text /// tool calls (gated against both the allowlist and Harness-style content
/// output → send `SubagentEvent`s on `tx` → break on first text-only /// safety checks) or collect text output → send `SubagentEvent`s on `tx` →
/// (non-empty) response. /// break on first text-only (non-empty) response.
/// ///
/// Why: runs synchronously on a dedicated thread so the main async event /// Why: runs synchronously on a dedicated thread so the main async event
/// loop is not blocked. Tool gating prevents restricted, risky, or /// loop is not blocked. Tool gating prevents restricted, risky, or
@@ -333,15 +339,44 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
anyhow::bail!("subagent aborted by parent at step {step}"); anyhow::bail!("subagent aborted by parent at step {step}");
} }
// Use the structured tool-calling API so the LLM can request tools with // Use streaming API so the abort flag is checked per SSE event,
// proper arguments, exactly like the main agent does. // making the subagent responsive to cancellation even during an
let (response, _usage) = match client.chat_with_tools_non_streaming(&messages, tdefs_opt.clone()) { // LLM call (non-streaming would block for 10-30s unchecked).
let stream_result = client.chat_with_tools_streaming(
&messages,
tdefs_opt.clone(),
Some(0.7),
Some(4096),
|_event| -> bool {
// Check abort on every SSE event for responsive cancellation.
if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) {
return false; // signals provider to abort
}
// We don't stream tokens to the UI for subagents — just
// need the assembled message at the end.
true
},
);
let (response, _usage) = match stream_result {
Ok(result) => result, Ok(result) => result,
Err(e) => { Err(e) => {
let is_abort = ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst))
|| e.to_string().contains("aborted");
let _ = tx.blocking_send(SubagentEvent::StepFailed { let _ = tx.blocking_send(SubagentEvent::StepFailed {
step, step,
error: e.to_string(), error: if is_abort {
"subagent aborted by user".to_string()
} else {
e.to_string()
},
}); });
if is_abort {
anyhow::bail!("subagent aborted by parent at step {step}");
}
// No non-streaming fallback — API must support streaming.
// Non-streaming calls block for up to 1 min without checking
// abort_flag, making cancellation unresponsive.
anyhow::bail!("subagent call failed at step {step}: {e}"); anyhow::bail!("subagent call failed at step {step}: {e}");
} }
}; };
@@ -356,67 +391,128 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
// Push the assistant message with tool_calls into the conversation // Push the assistant message with tool_calls into the conversation
messages.push(response); messages.push(response);
for tool_call in &tool_calls { let mut results_vec = Vec::new();
// Check abort flag before each tool execution std::thread::scope(|s| {
if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) { let mut handles = Vec::new();
let _ = tx.blocking_send(SubagentEvent::StepFailed { let tools_ref = &tools;
step, let tool_ctx_ref = &tool_ctx;
error: "subagent aborted by parent during tool execution".to_string(), for tool_call in &tool_calls {
}); let handle = s.spawn(move || {
anyhow::bail!("subagent aborted by parent during tool call at step {step}"); // Check abort flag before each tool execution
} if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) {
return (tool_call, Err(anyhow::anyhow!("subagent aborted by parent during tool execution")));
}
let tool_name = &tool_call.function.name;
let args = crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments);
let explicitly_allowed = ctx.allowed_tools.contains(tool_name);
let generally_allowed = ctx.allowed_tools.is_empty() || explicitly_allowed;
// Level 1: allowlist check — is this tool even permitted?
if !generally_allowed {
return (tool_call, Ok(format!("tool '{tool_name}' not allowed for this subagent")));
}
// Level 2: risky tool check — risky tools require explicit permission
if tool_is_risky(tool_name) && !explicitly_allowed {
return (tool_call, Ok(format!("risky tool '{tool_name}' requires explicit permission; not allowed for this subagent")));
}
// Level 3: Harness-style content safety gating
if let Some(block_reason) = gate_subagent_tool_call(tool_name, &args) {
return (tool_call, Ok(format!("Blocked by subagent gate: {block_reason}")));
}
let result = match tools_ref.iter().find(|t| t.name() == tool_name.as_str()) {
Some(tool) => {
let is_edit = tool_name == "write" || tool_name == "edit";
if is_edit && !tool_call.id.is_empty() {
if let Ok(conn) = crate::model::msglog::open_or_create(&ctx.session_dir) {
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
if let Ok(abs_path) = crate::tool::resolve_path(&tool_ctx_ref.workspaces, path) {
if let Ok(bytes) = std::fs::read(&abs_path) {
let session_id = ctx.session_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let _ = crate::model::msglog::store_blob(
&conn, session_id, &tool_call.id, &bytes, None,
);
}
}
}
}
let run_res = tool.run(tool_ctx_ref, &args);
if is_edit && run_res.is_ok() {
let reason = args
.get("reason")
.and_then(|v| v.as_str())
.unwrap_or("unnamed");
let path = args
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let content_sha256 = {
let content = args.get("content").or_else(|| args.get("new"));
use sha2::Digest;
let hash = sha2::Sha256::digest(
content.and_then(|v| v.as_str()).unwrap_or("").as_bytes(),
);
hex::encode(hash)
};
let bytes_delta = if tool_name == "write" {
args.get("content")
.and_then(|v| v.as_str())
.map_or(0, |s| s.len() as i64)
} else {
let old = args.get("old").and_then(|v| v.as_str()).unwrap_or("");
let new = args.get("new").and_then(|v| v.as_str()).unwrap_or("");
(new.len() as i64 - old.len() as i64).abs()
};
let session_id = ctx.session_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let entry = crate::model::editlog::EditLogEntry {
ts: chrono::Utc::now().timestamp_millis(),
tool: tool_name.clone(),
path: path.to_string(),
reason: reason.to_string(),
content_sha256,
bytes_delta,
origin: tool_ctx_ref.origin.tag(),
session_id,
};
let mut el = crate::model::editlog::EditLog::new(&ctx.session_dir);
el.append(entry).ok();
}
run_res
}
None => Err(anyhow::anyhow!("tool '{tool_name}' not found")),
};
(tool_call, result)
});
handles.push(handle);
}
for h in handles {
if let Ok(res) = h.join() {
results_vec.push(res);
}
}
});
for (tool_call, result) in results_vec {
let tool_name = &tool_call.function.name; let tool_name = &tool_call.function.name;
let args = crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments); let args = crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments);
let explicitly_allowed = ctx.allowed_tools.contains(tool_name);
let generally_allowed = ctx.allowed_tools.is_empty() || explicitly_allowed;
let _ = tx.blocking_send(SubagentEvent::ToolCall { let _ = tx.blocking_send(SubagentEvent::ToolCall {
tool: tool_name.clone(), tool: tool_name.clone(),
args: args.clone(), args: args.clone(),
}); });
// Level 1: allowlist check — is this tool even permitted?
if !generally_allowed {
let msg = format!("tool '{tool_name}' not allowed for this subagent");
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
let _ = tx.blocking_send(SubagentEvent::ToolResult {
tool: tool_name.clone(),
output: msg,
});
continue;
}
// Level 2: risky tool check — risky tools require explicit permission
if tool_is_risky(tool_name) && !explicitly_allowed {
let msg = format!("risky tool '{tool_name}' requires explicit permission; not allowed for this subagent");
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
let _ = tx.blocking_send(SubagentEvent::ToolResult {
tool: tool_name.clone(),
output: msg,
});
continue;
}
// Level 3: Harness-style content safety gating — mirrors the main
// agent's gate_tool_call checks (path traversal, reason validation,
// stub/denial/assumption scanning, bash exfiltration, destructive
// commands, sensitive path reads).
if let Some(block_reason) = gate_subagent_tool_call(tool_name, &args) {
let msg = format!("Blocked by subagent gate: {block_reason}");
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
let _ = tx.blocking_send(SubagentEvent::ToolResult {
tool: tool_name.clone(),
output: msg,
});
continue;
}
let result = match tools.iter().find(|t| t.name() == tool_name.as_str()) {
Some(tool) => tool.run(&tool_ctx, &args),
None => Err(anyhow::anyhow!("tool '{tool_name}' not found")),
};
match result { match result {
Ok(output_text) => { Ok(output_text) => {
messages.push(ChatMessage::tool_result(tool_call.id.clone(), output_text.clone())); messages.push(ChatMessage::tool_result(tool_call.id.clone(), output_text.clone()));
@@ -426,6 +522,14 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
}); });
} }
Err(e) => { Err(e) => {
let err_str = e.to_string();
if err_str.contains("subagent aborted by parent") {
let _ = tx.blocking_send(SubagentEvent::StepFailed {
step,
error: err_str.clone(),
});
anyhow::bail!("{err_str}");
}
let msg = format!("tool '{tool_name}' failed: {e}"); let msg = format!("tool '{tool_name}' failed: {e}");
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone())); messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
let _ = tx.blocking_send(SubagentEvent::ToolResult { let _ = tx.blocking_send(SubagentEvent::ToolResult {
+1
View File
@@ -30,6 +30,7 @@ impl AgentDefinition {
} }
/// Builder method: limit this agent to at most `steps` LLM calls. /// Builder method: limit this agent to at most `steps` LLM calls.
#[allow(dead_code)]
pub fn with_max_steps(mut self, steps: usize) -> Self { pub fn with_max_steps(mut self, steps: usize) -> Self {
self.max_steps = Some(steps); self.max_steps = Some(steps);
self self
-264
View File
@@ -1,264 +0,0 @@
//! Company-style workflow orchestrator: runs the complete division pipeline
//! (Strategy → Engineering → Quality → Security → Documentation) with
//! findings flowing between stages, then returns a consolidated executive
//! summary to the CEO (main agent).
//!
//! Flow:
//! ```
//! CEO Main Agent
//! │ delegates to run_company_pipeline(request)
//! ▼
//! ┌──────────────────────────────────────────────────┐
//! │ Strategy Division — plan + mermaid diagrams │
//! │ Engineering Division — implement per plan │
//! │ Quality Division — review + write tests │
//! │ Security Division — vulnerability audit │
//! │ Documentation Div — update docs │
//! └──────────────────────────────────────────────────┘
//! │ returns consolidated summary
//! ▼
//! CEO Main Agent delivers to user
//! ```
use std::collections::HashMap;
use std::fmt::Write;
use std::sync::{Arc, Mutex};
use crate::app::workflow::engine::{execute_primitive, LiveStateFn, AgentStatus};
use crate::app::workflow::script::{ScriptPrimitive, ScriptOptions, WorkflowScript};
use crate::app::subagent::division;
/// Run the full company-style pipeline for a given user request.
///
/// This orchestrates all five divisions in sequence:
/// 1. **Strategy** — create plan with diagrams
/// 2. **Engineering** — implement code
/// 3. **Quality** — review + write tests
/// 4. **Security** — audit
/// 5. **Documentation** — update docs
///
/// Each division receives findings from all previous divisions, enabling
/// context to flow through the pipeline.
///
/// Returns a consolidated executive summary string.
pub fn run_company_pipeline(
user_request: &str,
session_dir: &std::path::Path,
workspaces: &[std::path::PathBuf],
turn_events: Option<&Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>,
) -> anyhow::Result<String> {
let divisions = division::all_divisions();
let mut pipeline_scripts: Vec<ScriptPrimitive> = Vec::with_capacity(divisions.len());
for div in &divisions {
let div_prompt = div.agent_def.system_prompt.as_deref().unwrap_or("");
// Prepend [Division Name] so the first 40 chars of the prompt
// become the agent_name in spawn_single_agent, making the TUI
// panel show division names instead of UUID fragments.
let prompt = format!(
"[{}]\n\n{}\n\nUser request: {}\n\nFindings from previous divisions: {{findings}}",
div.name,
div_prompt,
user_request,
);
pipeline_scripts.push(ScriptPrimitive::Agent(prompt));
}
let wf = WorkflowScript {
name: "company-pipeline".to_string(),
description: "Company Pipeline (full): Strategy → Engineering → Quality → Security → Documentation".to_string(),
script: ScriptPrimitive::Pipeline(pipeline_scripts),
options: ScriptOptions {
max_concurrency: 1, // sequential by design
continue_on_error: true, // one division failing shouldn't block the rest
timeout_ms: None,
},
};
// Build a live callback for TUI updates if turn_events is available.
// Uses agent_name (division name) for the display label in the panel.
let live: Option<LiveStateFn> = turn_events.map(|events| {
let events = events.clone();
let f: LiveStateFn = Arc::new(move |_agent_id: String, agent_name: String, status: AgentStatus| {
let display_name = agent_name.chars().take(30).collect::<String>();
if let Ok(mut q) = events.lock() {
q.push_back(crate::app::state::runtime::TurnEvent::WorkflowAgentUpdate {
agent_id: display_name.clone(),
agent_name: display_name,
status,
});
}
});
f
});
let args: HashMap<String, String> = HashMap::new();
let live_ref = live.as_ref();
// Create a per-pipeline findings scope so divisions can pass data
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let results = execute_primitive(
&wf.script,
&args,
1,
true,
live_ref,
session_dir,
workspaces,
&findings,
None,
)?;
// Collect all findings for the executive summary
let all_findings = findings.lock()
.map(|f| f.clone())
.unwrap_or_default();
Ok(build_executive_summary(user_request, &results, &all_findings, &divisions))
}
/// Run a quick company pipeline that skips non-essential divisions
/// for simple tasks. Flow: Strategy → Engineering → Quality.
///
/// This is for smaller tasks where security audit and full docs are overkill.
pub fn run_company_pipeline_quick(
user_request: &str,
session_dir: &std::path::Path,
workspaces: &[std::path::PathBuf],
turn_events: Option<&Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>,
) -> anyhow::Result<String> {
let divisions = division::all_divisions();
// Only use first 3 divisions for quick pipeline: Strategy, Engineering, Quality
let quick_divisions = &divisions[..3];
let mut pipeline_scripts: Vec<ScriptPrimitive> = Vec::with_capacity(quick_divisions.len());
for div in quick_divisions {
let div_prompt = div.agent_def.system_prompt.as_deref().unwrap_or("");
let prompt = format!(
"[{}]\n\n{}\n\nUser request: {}\n\nFindings from previous divisions: {{findings}}",
div.name,
div_prompt,
user_request,
);
pipeline_scripts.push(ScriptPrimitive::Agent(prompt));
}
let wf = WorkflowScript {
name: "company-pipeline-quick".to_string(),
description: "Company Pipeline (quick): Strategy → Engineering → Quality".to_string(),
script: ScriptPrimitive::Pipeline(pipeline_scripts),
options: ScriptOptions {
max_concurrency: 1,
continue_on_error: true,
timeout_ms: None,
},
};
let live: Option<LiveStateFn> = turn_events.map(|events| {
let events = events.clone();
let f: LiveStateFn = Arc::new(move |_agent_id: String, agent_name: String, status: AgentStatus| {
let display_name = agent_name.chars().take(30).collect::<String>();
if let Ok(mut q) = events.lock() {
q.push_back(crate::app::state::runtime::TurnEvent::WorkflowAgentUpdate {
agent_id: display_name.clone(),
agent_name: display_name,
status,
});
}
});
f
});
let args: HashMap<String, String> = HashMap::new();
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let results = execute_primitive(
&wf.script, &args, 1, true,
live.as_ref(), session_dir, workspaces, &findings, None,
)?;
let all_findings = findings.lock()
.map(|f| f.clone())
.unwrap_or_default();
Ok(build_executive_summary(user_request, &results, &all_findings, quick_divisions))
}
/// Build a compressed executive summary from pipeline results.
///
/// Keeps output brief to save context window space — just division verdicts
/// and key findings, not full outputs. Full results are accessible to the
/// CEO via the notes/findings that were archived during execution.
fn build_executive_summary(
request: &str,
results: &[String],
findings: &[String],
divisions: &[division::Division],
) -> String {
let mut summary = String::new();
writeln!(summary, "Pipeline for: {request}").unwrap();
for (i, div) in divisions.iter().enumerate() {
let verdict = results.get(i).map_or_else(|| "".to_string(), |r| {
r.lines().next().unwrap_or(r)
.chars().take(100).collect::<String>()
});
writeln!(summary, " {}: {}", div.name, verdict).unwrap();
}
if !findings.is_empty() {
writeln!(summary, " Notes: {} cross-division finding(s)", findings.len()).unwrap();
}
summary
}
/// Determine whether a request is complex enough for the full pipeline
/// or can use the quick version.
///
/// Simple = single file, minor fix, quick lookup, config change.
/// Complex = new feature, multi-file refactor, architecture change.
///
/// Used by the auto-CEO pipeline trigger in `run_agent_turn` to decide
/// whether to delegate to the full company pipeline or handle directly.
///
/// Heuristics:
/// - Very short requests (< 10 chars) are never complex.
/// - Negative keywords (simple/trivial/typo/quick) skip the pipeline.
/// - Positive keywords (refactor/api/implement/architecture) trigger it.
/// - Multi-line or multi-sentence requests are more likely complex.
pub fn is_complex_request(request: &str) -> bool {
let trimmed = request.trim();
// Very short requests are never complex
if trimmed.len() < 10 {
return false;
}
// Single-line simple update patterns
let lower = trimmed.to_lowercase();
let negative_keywords = [
"simple", "trivial", "typo", "just a", "only a", "minor",
"quick", "tiny", "small fix", "rename", "nitpick",
"cosmetic", "formatting", "spelling", "grammar",
"bump", "version bump", "update comment",
];
if negative_keywords.iter().any(|k| lower.contains(k)) {
return false;
}
// Multi-line/multi-sentence → likely complex
let sentences = trimmed.split(['.', '!', '?'])
.filter(|s| !s.trim().is_empty())
.count();
if sentences >= 3 {
return true;
}
// Positive complexity keywords
let complexity_keywords = [
"refactor", "redesign", "architecture", "feature", "implement",
"migrate", "restructure", "rewrite", "new module", "new component",
"scaffold", "multi", "multiple files", "api", "endpoint",
"integration", "system", "workflow", "pipeline", "database",
"authentication", "authorization", "full stack",
];
complexity_keywords.iter().any(|k| lower.contains(k))
}
+99
View File
@@ -0,0 +1,99 @@
//! Guaranteed, deterministic documentation output for hive-mind runs.
//!
//! Because cycles/directives are entirely Core-Intelligence-authored (see
//! `app::workflow::hive_mind`), it could in principle never plan a "write
//! docs" node for a given task. Durable documentation can't depend on that
//! choice, so this step is plain Rust — not an LLM call, not a cycle the
//! Core Intelligence can omit or reshape — and always runs after any
//! hive-mind convergence completes.
use std::path::{Path, PathBuf};
use std::fmt::Write as _;
use crate::app::workflow::hive_mind::NodeReport;
use crate::model::memory::Memory;
/// Write a markdown report of one hive-mind convergence to
/// `<workspace_root>/docs/runs/<timestamp>-<slug>.md`.
///
/// Flow: build a slug from the user request → format every `NodeReport`
/// (grouped by cycle) with its complete output (no truncation — this is
/// the durable record of what the hive actually decided and did) → append
/// the final reconciled `consensus` as its own section → create
/// `docs/runs/` if missing → write the file.
///
/// Return: the path written, so callers can log/reference it.
pub fn write_hive_mind_convergence(
workspace_root: &Path,
user_request: &str,
reports: &[NodeReport],
consensus: &str,
) -> anyhow::Result<PathBuf> {
let runs_dir = workspace_root.join("docs").join("runs");
std::fs::create_dir_all(&runs_dir)?;
let ts = chrono::Utc::now();
let slug = Memory::slugify(user_request).unwrap_or_else(|| "run".to_string());
let filename = format!("{}-{}.md", ts.format("%Y%m%d-%H%M%S"), slug);
let path = runs_dir.join(filename);
let content = render_report(user_request, ts.timestamp_millis(), reports, consensus);
std::fs::write(&path, content)?;
Ok(path)
}
/// Render a hive-mind convergence as a markdown document.
fn render_report(user_request: &str, ts_millis: i64, reports: &[NodeReport], consensus: &str) -> String {
let mut out = String::new();
writeln!(out, "# Hive-mind convergence: {user_request}").unwrap();
writeln!(out, "\nTimestamp (ms): {ts_millis}\n").unwrap();
let cycle_count = reports.iter().map(|r| r.cycle_index).max().map_or(0, |m| m + 1);
for cycle_index in 0..cycle_count {
writeln!(out, "## Cycle {cycle_index}\n").unwrap();
for r in reports.iter().filter(|r| r.cycle_index == cycle_index) {
writeln!(out, "### {}\n", r.node_id).unwrap();
writeln!(out, "{}\n", r.output).unwrap();
}
}
writeln!(out, "## Collective Consensus\n").unwrap();
writeln!(out, "{consensus}\n").unwrap();
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writes_run_file_under_docs_runs() {
let tmp = std::env::temp_dir().join(format!("zesdex-docs-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&tmp).unwrap();
let reports = vec![
NodeReport { node_id: "Node-0-0".to_string(), cycle_index: 0, output: "found the bug".to_string() },
];
let path = write_hive_mind_convergence(&tmp, "fix the bug", &reports, "the bug is a null check").unwrap();
assert!(path.starts_with(tmp.join("docs").join("runs")));
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("fix the bug"));
assert!(content.contains("Node-0-0"));
assert!(content.contains("found the bug"));
assert!(content.contains("Collective Consensus"));
assert!(content.contains("the bug is a null check"));
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn falls_back_to_generic_slug_for_unslugifiable_request() {
let tmp = std::env::temp_dir().join(format!("zesdex-docs-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&tmp).unwrap();
let path = write_hive_mind_convergence(&tmp, "???", &[], "").unwrap();
assert!(path.file_name().unwrap().to_str().unwrap().contains("run"));
std::fs::remove_dir_all(&tmp).ok();
}
}
+142 -35
View File
@@ -15,7 +15,7 @@
//! leaks between concurrent workflow runs. //! leaks between concurrent workflow runs.
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}};
use std::time::Duration; use std::time::Duration;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::script::{ScriptPrimitive, WorkflowScript}; use super::script::{ScriptPrimitive, WorkflowScript};
@@ -76,7 +76,7 @@ impl WorkflowEngine {
/// - `status`: the agent's lifecycle state and timing. /// - `status`: the agent's lifecycle state and timing.
/// ///
/// Callers should use `agent_id` as the stable key and `agent_name` for /// Callers should use `agent_id` as the stable key and `agent_name` for
/// display purposes (e.g. the division name in the company pipeline). /// display purposes (e.g. a hive-mind node's designation, `"Node-0-1"`).
pub type LiveStateFn = Arc<dyn Fn(String, String, AgentStatus) + Send + Sync>; pub type LiveStateFn = Arc<dyn Fn(String, String, AgentStatus) + Send + Sync>;
/// Spawn a single synchronous subagent with the given prompt, passing it /// Spawn a single synchronous subagent with the given prompt, passing it
@@ -98,13 +98,16 @@ pub type LiveStateFn = Arc<dyn Fn(String, String, AgentStatus) + Send + Sync>;
/// a stuck stage from blocking the entire pipeline forever. /// a stuck stage from blocking the entire pipeline forever.
/// ///
/// Return: the agent's text output, or an error on failure. /// Return: the agent's text output, or an error on failure.
#[allow(clippy::too_many_lines, clippy::too_many_arguments)] #[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]
fn spawn_single_agent( fn spawn_single_agent(
agent_id: &str, agent_id: &str,
agent_name: &str, agent_name: &str,
prompt: &str, prompt: &str,
role: &str,
allowed_tools: Option<Vec<String>>,
findings_snapshot: &[String], findings_snapshot: &[String],
findings: &Arc<Mutex<Vec<String>>>, findings: &Arc<Mutex<Vec<String>>>,
abort_flag: &Option<Arc<AtomicBool>>,
live: Option<&LiveStateFn>, live: Option<&LiveStateFn>,
session_dir: &std::path::Path, session_dir: &std::path::Path,
workspaces: &[std::path::PathBuf], workspaces: &[std::path::PathBuf],
@@ -118,7 +121,7 @@ fn spawn_single_agent(
// Notify UI: this agent is now running. // Notify UI: this agent is now running.
// Pass both the unique agent_id (UUID for stable key) and agent_name // Pass both the unique agent_id (UUID for stable key) and agent_name
// (human-readable display name, e.g. division name). // (human-readable display name, e.g. a hive-mind node designation).
if let Some(f) = live { if let Some(f) = live {
f( f(
agent_id.to_string(), agent_id.to_string(),
@@ -133,8 +136,10 @@ fn spawn_single_agent(
); );
} }
let def = AgentDefinition::new(agent_name.to_string(), "coder".to_string()) let mut def = AgentDefinition::new(agent_name.to_string(), role.to_string());
.with_max_steps(50); if let Some(tools) = allowed_tools {
def = def.with_allowed_tools(tools);
}
let mut ctx = build_subagent_context(&def); let mut ctx = build_subagent_context(&def);
ctx.session_dir = session_dir.to_path_buf(); ctx.session_dir = session_dir.to_path_buf();
ctx.workspaces = workspaces.to_vec(); ctx.workspaces = workspaces.to_vec();
@@ -157,9 +162,7 @@ fn spawn_single_agent(
// Link the shared findings Arc so note_finding calls within this // Link the shared findings Arc so note_finding calls within this
// subagent write into the same vec visible to sibling agents. // subagent write into the same vec visible to sibling agents.
ctx.workflow_findings = Some(findings.clone()); ctx.workflow_findings = Some(findings.clone());
// Abort flag stays None by default — the parent can set it to abort ctx.abort_flag.clone_from(abort_flag);
// long-running agents. No abort mechanism is wired yet at this level;
// future work can expose a kill-switch per agent via the live callback.
// Create an mpsc channel and drain events in a background thread. // Create an mpsc channel and drain events in a background thread.
// The drain thread also pushes intra-division progress updates to the // The drain thread also pushes intra-division progress updates to the
@@ -221,27 +224,55 @@ fn spawn_single_agent(
} }
}); });
// Enforce timeout by running subagent on a separate thread and // Check abort before even starting the subagent.
// waiting with a deadline. If the deadline expires, the thread is if abort_flag.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
// abandoned (Rust threads cannot be forcibly killed, but we proceed anyhow::bail!("subagent '{agent_name}' aborted before start");
// without waiting for it — the drain thread will drop when tx is }
// dropped on thread exit).
let result = if let Some(timeout) = timeout_ms { // Run subagent on a separate thread so the abort flag can be polled.
let (done_tx, done_rx) = std::sync::mpsc::channel::<anyhow::Result<String>>(); // If abort is requested while the subagent is running, we abandon the
let timeout_ctx = ctx; // thread (Rust threads cannot be forcibly killed) and return early.
let timeout_tx = tx; let (done_tx, done_rx) = std::sync::mpsc::channel::<anyhow::Result<String>>();
std::thread::spawn(move || { let bg_ctx = ctx;
let _ = done_tx.send(run_subagent(&timeout_ctx, &timeout_tx)); let bg_tx = tx;
}); let bg_name = agent_name.to_string();
match done_rx.recv_timeout(Duration::from_millis(timeout)) { let bg_abort = abort_flag.clone();
Ok(r) => r, std::thread::spawn(move || {
Err(_) => Err(anyhow::anyhow!( let _ = done_tx.send(run_subagent(&bg_ctx, &bg_tx));
"subagent '{agent_name}' timed out after {timeout}ms", });
)),
let poll_interval = Duration::from_millis(200);
let result = if let Some(timeout) = timeout_ms {
let deadline = Duration::from_millis(timeout);
let mut elapsed = Duration::ZERO;
loop {
if let Ok(r) = done_rx.recv_timeout(poll_interval) {
break r;
} }
} else { elapsed += poll_interval;
run_subagent(&ctx, &tx) if elapsed >= deadline {
}; break Err(anyhow::anyhow!(
"subagent '{bg_name}' timed out after {timeout}ms",
));
}
if bg_abort.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
break Err(anyhow::anyhow!(
"subagent '{bg_name}' aborted by user",
));
}
}
} else {
loop {
if let Ok(r) = done_rx.recv_timeout(poll_interval) {
break r;
}
if bg_abort.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
break Err(anyhow::anyhow!(
"subagent '{bg_name}' aborted by user",
));
}
}
};
let completed_at = chrono::Utc::now().timestamp_millis(); let completed_at = chrono::Utc::now().timestamp_millis();
@@ -300,11 +331,13 @@ type ParallelResult = (usize, anyhow::Result<Vec<String>>);
/// Return: a `Vec<String>` of all agent outputs (or error strings) in /// Return: a `Vec<String>` of all agent outputs (or error strings) in
/// the order they were submitted. /// the order they were submitted.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
#[allow(clippy::ref_option, clippy::too_many_lines)]
pub fn execute_primitive( pub fn execute_primitive(
primitive: &ScriptPrimitive, primitive: &ScriptPrimitive,
args: &HashMap<String, String>, args: &HashMap<String, String>,
concurrency_cap: usize, concurrency_cap: usize,
continue_on_error: bool, continue_on_error: bool,
abort_flag: &Option<Arc<AtomicBool>>,
live: Option<&LiveStateFn>, live: Option<&LiveStateFn>,
session_dir: &std::path::Path, session_dir: &std::path::Path,
workspaces: &[std::path::PathBuf], workspaces: &[std::path::PathBuf],
@@ -313,11 +346,25 @@ pub fn execute_primitive(
) -> anyhow::Result<Vec<String>> { ) -> anyhow::Result<Vec<String>> {
match primitive { match primitive {
ScriptPrimitive::Agent(prompt) => { ScriptPrimitive::Agent(prompt) => {
let resolved = resolve_template(prompt, args); let mut resolved_args = args.clone();
let findings_snapshot = findings.lock().map(|f| f.clone()).unwrap_or_default(); let findings_snapshot = findings.lock().map(|f| f.clone()).unwrap_or_default();
if !resolved_args.contains_key("findings") {
let formatted_findings = if findings_snapshot.is_empty() {
"None".to_string()
} else {
findings_snapshot
.iter()
.enumerate()
.map(|(i, f)| format!("{}. {}", i + 1, f))
.collect::<Vec<_>>()
.join("\n")
};
resolved_args.insert("findings".to_string(), formatted_findings);
}
let resolved = resolve_template(prompt, &resolved_args);
let agent_id = uuid::Uuid::new_v4().to_string(); let agent_id = uuid::Uuid::new_v4().to_string();
let agent_name = resolved.chars().take(40).collect::<String>(); let agent_name = resolved.chars().take(40).collect::<String>();
match spawn_single_agent(&agent_id, &agent_name, &resolved, &findings_snapshot, findings, live, session_dir, workspaces, timeout_ms) { match spawn_single_agent(&agent_id, &agent_name, &resolved, "coder", None, &findings_snapshot, findings, abort_flag, live, session_dir, workspaces, timeout_ms) {
Ok(text) => Ok(vec![text]), Ok(text) => Ok(vec![text]),
Err(e) => { Err(e) => {
if continue_on_error { if continue_on_error {
@@ -329,6 +376,49 @@ pub fn execute_primitive(
} }
} }
ScriptPrimitive::ScopedAgent { prompt, node_id, tool_scope } => {
let mut resolved_args = args.clone();
let findings_snapshot = findings.lock().map(|f| f.clone()).unwrap_or_default();
if !resolved_args.contains_key("findings") {
let formatted_findings = if findings_snapshot.is_empty() {
"None".to_string()
} else {
findings_snapshot
.iter()
.enumerate()
.map(|(i, f)| format!("{}. {}", i + 1, f))
.collect::<Vec<_>>()
.join("\n")
};
resolved_args.insert("findings".to_string(), formatted_findings);
}
let resolved = resolve_template(prompt, &resolved_args);
let agent_id = uuid::Uuid::new_v4().to_string();
let agent_name = format!("{node_id}: {}", resolved.chars().take(30).collect::<String>());
let allowed_tools = crate::app::subagent::division::tool_scope::tools_for(tool_scope);
match spawn_single_agent(&agent_id, &agent_name, &resolved, node_id, Some(allowed_tools), &findings_snapshot, findings, abort_flag, live, session_dir, workspaces, timeout_ms) {
Ok(text) => {
// Merge this node's complete output into the shared
// collective state the instant it finishes — not after
// the whole parallel cohort completes. Any sibling node
// still running (via read_findings) or any node spawned
// afterward sees this immediately, making the collective
// state genuinely continuous rather than batch-synced.
if let Ok(mut f) = findings.lock() {
f.push(format!("[{node_id}]: {text}"));
}
Ok(vec![text])
}
Err(e) => {
if continue_on_error {
Ok(vec![format!("agent error: {}", e)])
} else {
Err(e)
}
}
}
}
ScriptPrimitive::Parallel(scripts) => { ScriptPrimitive::Parallel(scripts) => {
// All branches run concurrently, capped by semaphore. // All branches run concurrently, capped by semaphore.
// This is the primary advantage over single-turn chat: multiple // This is the primary advantage over single-turn chat: multiple
@@ -348,6 +438,7 @@ pub fn execute_primitive(
let sem = Arc::clone(&semaphore); let sem = Arc::clone(&semaphore);
let results = Arc::clone(&results); let results = Arc::clone(&results);
let cap = concurrency_cap; let cap = concurrency_cap;
let abort = abort_flag.clone();
let live_clone = live.cloned(); let live_clone = live.cloned();
let session_dir = session_dir.to_path_buf(); let session_dir = session_dir.to_path_buf();
let workspaces = workspaces.to_vec(); let workspaces = workspaces.to_vec();
@@ -358,6 +449,7 @@ pub fn execute_primitive(
let _permit = sem.acquire(); let _permit = sem.acquire();
let result = execute_primitive( let result = execute_primitive(
&script, &args, cap, continue_on_error, &script, &args, cap, continue_on_error,
&abort,
live_clone.as_ref(), live_clone.as_ref(),
&session_dir, &session_dir,
&workspaces, &workspaces,
@@ -390,13 +482,26 @@ pub fn execute_primitive(
ScriptPrimitive::Pipeline(scripts) => { ScriptPrimitive::Pipeline(scripts) => {
// Sequential: each stage runs only after the previous completes. // Sequential: each stage runs only after the previous completes.
// //
// Abort is checked between stages so the user can cancel the
// pipeline immediately when moving to the next division, rather
// than having to wait for the current subagent to finish.
//
// Why: parallel execution defeats the purpose of a pipeline whose // Why: parallel execution defeats the purpose of a pipeline whose
// stages are supposed to build on each other's output. Findings // stages are supposed to build on each other's output. Findings
// written by stage N are visible to stage N+1 through the shared // written by stage N are visible to stage N+1 through the shared
// `findings` Arc (same isolation scope as parent). // `findings` Arc (same isolation scope as parent).
let mut all = Vec::new(); let mut all = Vec::new();
for (idx, script) in scripts.iter().enumerate() { for (idx, script) in scripts.iter().enumerate() {
match execute_primitive(script, args, concurrency_cap, continue_on_error, live, session_dir, workspaces, findings, timeout_ms) { // Check abort before each pipeline stage so we don't
// launch the next division after the user cancelled.
if abort_flag.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
if continue_on_error {
all.push(format!("pipeline aborted at stage {idx}"));
break;
}
anyhow::bail!("pipeline aborted by user at stage {idx}");
}
match execute_primitive(script, args, concurrency_cap, continue_on_error, abort_flag, live, session_dir, workspaces, findings, timeout_ms) {
Ok(outputs) => all.extend(outputs), Ok(outputs) => all.extend(outputs),
Err(e) => { Err(e) => {
if continue_on_error { if continue_on_error {
@@ -411,7 +516,7 @@ pub fn execute_primitive(
} }
ScriptPrimitive::Phase { name: _name, script } => { ScriptPrimitive::Phase { name: _name, script } => {
execute_primitive(script, args, concurrency_cap, continue_on_error, live, session_dir, workspaces, findings, timeout_ms) execute_primitive(script, args, concurrency_cap, continue_on_error, abort_flag, live, session_dir, workspaces, findings, timeout_ms)
} }
} }
} }
@@ -426,7 +531,7 @@ pub fn run_workflow(
session_dir: &std::path::Path, session_dir: &std::path::Path,
workspaces: &[std::path::PathBuf], workspaces: &[std::path::PathBuf],
) -> anyhow::Result<String> { ) -> anyhow::Result<String> {
run_workflow_tracked(script, args, None, session_dir, workspaces) run_workflow_tracked(script, args, &None, None, session_dir, workspaces)
} }
/// Run a `WorkflowScript` with real-time live-state callbacks so the TUI /// Run a `WorkflowScript` with real-time live-state callbacks so the TUI
@@ -441,9 +546,11 @@ pub fn run_workflow(
/// `spawn_agents` invocations remain fully isolated. /// `spawn_agents` invocations remain fully isolated.
/// ///
/// Return: a human-readable summary string. /// Return: a human-readable summary string.
#[allow(clippy::ref_option)]
pub fn run_workflow_tracked( pub fn run_workflow_tracked(
script: &WorkflowScript, script: &WorkflowScript,
args: &HashMap<String, String>, args: &HashMap<String, String>,
abort_flag: &Option<Arc<AtomicBool>>,
live: Option<&LiveStateFn>, live: Option<&LiveStateFn>,
session_dir: &std::path::Path, session_dir: &std::path::Path,
workspaces: &[std::path::PathBuf], workspaces: &[std::path::PathBuf],
@@ -457,7 +564,7 @@ pub fn run_workflow_tracked(
let findings = Arc::new(Mutex::new(Vec::new())); let findings = Arc::new(Mutex::new(Vec::new()));
let results = execute_primitive( let results = execute_primitive(
&script.script, args, concurrency_cap, &script.script, args, concurrency_cap,
script.options.continue_on_error, live, script.options.continue_on_error, abort_flag, live,
session_dir, workspaces, &findings, session_dir, workspaces, &findings,
script.options.timeout_ms, script.options.timeout_ms,
)?; )?;
+374
View File
@@ -0,0 +1,374 @@
//! Hive-mind multi-agent orchestration.
//!
//! Modeled on the "Machine Intelligence" archetype from sci-fi strategy
//! games (Stellaris et al.): the Core Intelligence (the main agent) issues
//! directives that spawn anonymous processing nodes, each carrying only a
//! directive and an access tier. Every node's complete output merges into
//! a single collective state the instant it finishes (see
//! `engine::execute_primitive`'s `ScopedAgent` arm), visible to every
//! other node still running or spawned afterward — continuously, not just
//! at cycle boundaries. When all cognitive cycles complete, one final
//! synthesis node reconciles the entire collective state into a single
//! consensus assessment.
//!
//! ```text
//! Core Intelligence
//! │ issues a CognitiveCyclePlan { cycles: [[NodeDirective, ...], ...] }
//! ▼
//! Cycle 0: Node-0-0, Node-0-1, ... (run in parallel; each merges into
//! │ the collective state the instant
//! │ it completes — not batched)
//! ▼
//! Cycle 1: ...
//! ▼
//! ...however many cycles the Core Intelligence decided this task needs...
//! ▼
//! Synthesis node reads the complete collective state and produces one
//! reconciled consensus — returned to the Core Intelligence and persisted
//! to docs/runs/*.md.
//! ```
use std::collections::HashMap;
use std::sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}};
use serde::Deserialize;
use crate::app::workflow::script::ScriptPrimitive;
use crate::app::workflow::engine::{execute_primitive, LiveStateFn, AgentStatus};
/// One directive the Core Intelligence wants a node to execute within a
/// cognitive cycle. A node's sole identity is its directive and access tier.
#[derive(Debug, Clone, Deserialize)]
pub struct NodeDirective {
pub directive: String,
/// Access tier: "read" | "write" | "full". Defaults to "read" when
/// omitted; unrecognized values also fall back to "read" (see
/// `division::tool_scope::tools_for`).
#[serde(default = "default_access")]
pub access: String,
}
fn default_access() -> String {
crate::app::subagent::division::tool_scope::READ.to_string()
}
/// A Core-Intelligence-authored execution plan: an ordered list of
/// cognitive cycles, each cycle a list of node directives executed in
/// parallel. Cycle count and nodes-per-cycle are fully dynamic.
#[derive(Debug, Clone, Deserialize)]
pub struct CognitiveCyclePlan {
pub cycles: Vec<Vec<NodeDirective>>,
}
/// The complete output of one node within one cognitive cycle.
///
/// `node_id` is a system-assigned coordinate (e.g. `"Node-0-1"`) that
/// identifies a node purely by its position in the hive.
#[derive(Debug, Clone)]
pub struct NodeReport {
pub node_id: String,
pub cycle_index: usize,
pub output: String,
}
/// Build the live-state callback that forwards node status updates to the
/// TUI's workflow panel.
fn build_live(
turn_events: Option<&Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>,
) -> Option<LiveStateFn> {
turn_events.map(|events| {
let events = events.clone();
let f: LiveStateFn = Arc::new(move |_agent_id: String, agent_name: String, status: AgentStatus| {
let display_name = agent_name.chars().take(40).collect::<String>();
if let Ok(mut q) = events.lock() {
q.push_back(crate::app::state::runtime::TurnEvent::WorkflowAgentUpdate {
agent_id: display_name.clone(),
agent_name: display_name,
status,
});
}
});
f
})
}
/// Run a hive-mind: a Core-Intelligence-authored plan of cognitive cycles,
/// where every node's complete output merges into a single collective
/// state the instant it finishes, and a final synthesis node reconciles
/// the whole collective state into one consensus assessment.
///
/// Flow: for each cycle (sequential) → spawn one `ScriptPrimitive::ScopedAgent`
/// per directive, tagged with a system-assigned `node_id` (never an
/// LLM-authored name) → run them as a `Parallel` block via
/// `execute_primitive`, which merges each node's output into the shared
/// collective-state Arc the instant that node completes, not after the
/// whole cohort finishes → record `NodeReport`s → proceed to the next
/// cycle. After all cycles: spawn one more read-only synthesis node whose
/// directive is to reconcile the complete collective state into a single
/// consensus, not list what each node said.
///
/// Return: `(consensus, all_node_reports)`. `consensus` is the synthesis
/// node's reconciled output — what the Core Intelligence actually
/// receives. `all_node_reports` is the complete per-node record,
/// persisted verbatim to `docs/runs/*.md`.
pub fn run_hive_mind(
user_request: &str,
plan: &CognitiveCyclePlan,
session_dir: &std::path::Path,
workspaces: &[std::path::PathBuf],
turn_events: Option<&Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>,
abort_flag: Option<&Arc<AtomicBool>>,
) -> anyhow::Result<(String, Vec<NodeReport>)> {
if plan.cycles.is_empty() {
anyhow::bail!("cognitive cycle plan has no cycles");
}
let live = build_live(turn_events);
let collective_state: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let args: HashMap<String, String> = HashMap::new();
let mut reports: Vec<NodeReport> = Vec::new();
let abort_owned: Option<Arc<AtomicBool>> = abort_flag.cloned();
for (cycle_index, directives) in plan.cycles.iter().enumerate() {
if directives.is_empty() {
continue;
}
if abort_flag.is_some_and(|f| f.load(Ordering::SeqCst)) {
anyhow::bail!("hive-mind aborted by user before cycle {cycle_index}");
}
let node_ids: Vec<String> = (0..directives.len())
.map(|i| format!("Node-{cycle_index}-{i}"))
.collect();
let nodes: Vec<ScriptPrimitive> = directives.iter().zip(node_ids.iter()).map(|(d, node_id)| {
ScriptPrimitive::ScopedAgent {
prompt: format!(
"You are {node_id}, a processing node of a distributed machine \
intelligence.\n\n\
Directive: {}\n\n\
Overall task: {user_request}\n\n\
Collective state accumulated so far:\n{{{{findings}}}}",
d.directive,
),
node_id: node_id.clone(),
tool_scope: d.access.clone(),
}
}).collect();
let cycle_primitive = ScriptPrimitive::Phase {
name: format!("cycle-{cycle_index}"),
script: Box::new(ScriptPrimitive::Parallel(nodes)),
};
let results = execute_primitive(
&cycle_primitive,
&args,
directives.len().clamp(1, 10),
true,
&abort_owned,
live.as_ref(),
session_dir,
workspaces,
&collective_state,
None,
)?;
// engine::execute_primitive's ScopedAgent arm already merged each
// node's output into `collective_state` the instant that node
// completed (not after this whole cycle finished) — here we only
// need the results to build the durable NodeReport record.
for (node_id, output) in node_ids.iter().zip(results.iter()) {
reports.push(NodeReport {
node_id: node_id.clone(),
cycle_index,
output: output.clone(),
});
}
}
let consensus = synthesize_consensus(
user_request, session_dir, workspaces, &collective_state, live.as_ref(), abort_flag,
)?;
Ok((consensus, reports))
}
/// Spawn a single read-only synthesis node that reads the complete
/// collective state and reconciles it into one consensus assessment.
///
/// Why a real node instead of string concatenation: the collective state
/// may contain overlapping or conflicting node outputs (e.g. two nodes
/// investigating the same file from different angles) — only genuine
/// reasoning can reconcile that into a coherent answer; deterministic
/// formatting can only concatenate, not resolve conflicts.
///
/// Return: the synthesis node's reconciled consensus text.
fn synthesize_consensus(
user_request: &str,
session_dir: &std::path::Path,
workspaces: &[std::path::PathBuf],
collective_state: &Arc<Mutex<Vec<String>>>,
live: Option<&LiveStateFn>,
abort_flag: Option<&Arc<AtomicBool>>,
) -> anyhow::Result<String> {
let synthesis = ScriptPrimitive::ScopedAgent {
prompt: format!(
"You are the synthesis process of a distributed machine intelligence. \
All processing nodes for the following task have completed and \
merged their output into the collective state below.\n\n\
Task: {user_request}\n\n\
Complete collective state:\n{{{{findings}}}}\n\n\
Produce ONE reconciled consensus assessment. Do not list what each \
node said resolve any overlapping or conflicting node output into \
a single coherent answer for the task above."
),
node_id: "Synthesis".to_string(),
tool_scope: crate::app::subagent::division::tool_scope::READ.to_string(),
};
let args: HashMap<String, String> = HashMap::new();
let abort_owned: Option<Arc<AtomicBool>> = abort_flag.cloned();
let results = execute_primitive(
&synthesis, &args, 1, false, &abort_owned, live, session_dir, workspaces, collective_state, None,
)?;
Ok(results.into_iter().next().unwrap_or_default())
}
/// Determine whether a request is worth paying for a Core Intelligence
/// planning call at all — the resulting plan's *shape* (cycle count,
/// directives, access tiers) is entirely up to the Core Intelligence; this
/// only gates whether it gets asked to design one in the first place.
///
/// Simple = single file, minor fix, quick lookup, config change.
/// Complex = new feature, multi-file refactor, architecture change.
///
/// Heuristics:
/// - Very short requests (< 10 chars) are never complex.
/// - Negative keywords (simple/trivial/typo/quick) skip planning.
/// - Positive keywords (refactor/api/implement/architecture) trigger it.
/// - Multi-sentence requests are more likely complex.
pub fn is_complex_request(request: &str) -> bool {
let trimmed = request.trim();
// Very short requests are never complex
if trimmed.len() < 10 {
return false;
}
// Single-line simple update patterns
let lower = trimmed.to_lowercase();
let negative_keywords = [
"simple", "trivial", "typo", "just a", "only a", "minor",
"quick", "tiny", "small fix", "rename", "nitpick",
"cosmetic", "formatting", "spelling", "grammar",
"bump", "version bump", "update comment",
];
if negative_keywords.iter().any(|k| lower.contains(k)) {
return false;
}
// Multi-line/multi-sentence → likely complex
let sentences = trimmed.split(['.', '!', '?'])
.filter(|s| !s.trim().is_empty())
.count();
if sentences >= 3 {
return true;
}
// Positive complexity keywords
let complexity_keywords = [
"refactor", "redesign", "architecture", "feature", "implement",
"migrate", "restructure", "rewrite", "new module", "new component",
"scaffold", "multi", "multiple files", "api", "endpoint",
"integration", "system", "workflow", "pipeline", "database",
"authentication", "authorization", "full stack",
];
complexity_keywords.iter().any(|k| lower.contains(k))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_complex_request_too_short() {
assert!(!is_complex_request("abc"));
}
#[test]
fn test_is_complex_request_simple_keywords() {
assert!(!is_complex_request("just a simple update to the readme"));
assert!(!is_complex_request("minor typo fix in main.rs"));
}
#[test]
fn test_is_complex_request_multi_sentence() {
assert!(is_complex_request("This is sentence one. This is sentence two. This is sentence three."));
}
#[test]
fn test_is_complex_request_complex_keywords() {
assert!(is_complex_request("implement user authentication endpoint"));
assert!(is_complex_request("refactor the whole engine module"));
}
#[test]
fn test_default_access_is_read() {
let d: NodeDirective = serde_json::from_str(
r#"{"directive": "write tests"}"#
).unwrap();
assert_eq!(d.access, crate::app::subagent::division::tool_scope::READ);
}
#[test]
fn test_node_directive_has_no_role_field() {
// A node's only recognized fields are "directive" and "access". A
// "role" key, if an LLM emits one out of old habit, is simply
// ignored rather than required or preserved.
let d: NodeDirective = serde_json::from_str(
r#"{"role": "Architect", "directive": "plan the migration", "access": "read"}"#
).unwrap();
assert_eq!(d.directive, "plan the migration");
}
#[test]
fn test_cognitive_cycle_plan_arbitrary_shape() {
let plan: CognitiveCyclePlan = serde_json::from_str(r#"{
"cycles": [
[{"directive": "scan the codebase topology", "access": "read"}],
[
{"directive": "write the migration", "access": "write"},
{"directive": "write the rollback", "access": "write"}
],
[{"directive": "cut the release", "access": "full"}]
]
}"#).unwrap();
assert_eq!(plan.cycles.len(), 3);
assert_eq!(plan.cycles[1].len(), 2);
}
#[test]
fn test_run_hive_mind_rejects_empty_plan() {
let plan = CognitiveCyclePlan { cycles: vec![] };
let tmp = std::env::temp_dir();
let err = run_hive_mind("do something", &plan, &tmp, &[], None, None)
.expect_err("empty plan must be rejected before spawning any node");
assert!(err.to_string().contains("no cycles"));
}
#[test]
fn test_run_hive_mind_aborts_before_spawning_when_flag_preset() {
// The abort check runs before execute_primitive for cycle 0, so a
// pre-set abort flag must short-circuit without any LLM/network call.
let plan: CognitiveCyclePlan = serde_json::from_str(r#"{
"cycles": [[{"directive": "whatever", "access": "read"}]]
}"#).unwrap();
let tmp = std::env::temp_dir();
let abort_flag = Arc::new(AtomicBool::new(true));
let err = run_hive_mind("do something", &plan, &tmp, &[], None, Some(&abort_flag))
.expect_err("pre-set abort flag must short-circuit before cycle 0");
assert!(err.to_string().contains("aborted"));
}
#[test]
fn test_node_ids_are_system_assigned_coordinates() {
// Node IDs follow the "Node-{cycle}-{index}" coordinate scheme —
// never an LLM-authored persona name.
let node_id = format!("Node-{}-{}", 2, 1);
assert_eq!(node_id, "Node-2-1");
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
//! Workflow orchestration: a script interpreter that runs pipeline/parallel //! Workflow orchestration: a script interpreter that runs pipeline/parallel
//! primitives across multiple subagent instances. //! primitives across multiple subagent instances.
pub mod company; pub mod hive_mind;
pub mod docs;
pub mod engine; pub mod engine;
pub mod script; pub mod script;
+13
View File
@@ -9,6 +9,19 @@ use serde::{Deserialize, Serialize};
pub enum ScriptPrimitive { pub enum ScriptPrimitive {
/// Run a single agent with the given prompt template. /// Run a single agent with the given prompt template.
Agent(String), Agent(String),
/// Run a single agent with an explicit node designation and
/// tool-scope tier.
///
/// Used by the hive-mind pipeline, where a node's identity is its
/// system-assigned designation (e.g. `"Node-0-1"`) paired with a
/// bounded tool allowlist. `tool_scope` is one of `"read"`,
/// `"write"`, `"full"` (see `app::subagent::division::tool_scope`);
/// unrecognized values fall back to `"read"`.
ScopedAgent {
prompt: String,
node_id: String,
tool_scope: String,
},
/// Execute several primitives concurrently. /// Execute several primitives concurrently.
Parallel(Vec<ScriptPrimitive>), Parallel(Vec<ScriptPrimitive>),
/// Execute several primitives sequentially, each waiting for the /// Execute several primitives sequentially, each waiting for the
-13
View File
@@ -22,10 +22,6 @@ pub enum Command {
WorkflowRun { WorkflowRun {
script: String, script: String,
}, },
/// /pipeline full|quick|skip
Pipeline {
mode: String,
},
Unknown(String), Unknown(String),
} }
@@ -79,15 +75,6 @@ pub fn parse_command(text: &str) -> Command {
"/workflow" => Command::WorkflowRun { "/workflow" => Command::WorkflowRun {
script: arg1.to_string(), script: arg1.to_string(),
}, },
"/pipeline" if arg1.is_empty() => Command::Pipeline {
mode: "status".to_string(),
},
"/pipeline" if arg1 == "full" || arg1 == "quick" || arg1 == "skip" => {
Command::Pipeline {
mode: arg1.to_string(),
}
}
"/pipeline" => Command::Unknown(format!("/pipeline {arg1} (use: full|quick|skip)")),
_ => Command::Unknown(cmd.to_string()), _ => Command::Unknown(cmd.to_string()),
} }
} }
+120
View File
@@ -73,3 +73,123 @@ pub fn serialize_frame<T: serde::Serialize>(value: &T) -> Result<Vec<u8>> {
pub fn deserialize_frame<'a, T: serde::Deserialize<'a>>(data: &'a [u8]) -> Result<T> { pub fn deserialize_frame<'a, T: serde::Deserialize<'a>>(data: &'a [u8]) -> Result<T> {
Ok(serde_json::from_slice(data)?) Ok(serde_json::from_slice(data)?)
} }
#[cfg(test)]
mod tests {
use super::*;
/// Write a value, read it back, and verify exact equality.
fn roundtrip_bytes(data: &[u8]) {
let mut buf: Vec<u8> = Vec::new();
write_frame(&mut buf, data).unwrap();
let read_back = read_frame(&mut buf.as_slice())
.unwrap()
.expect("expected Some(frame)");
assert_eq!(read_back, data);
}
#[test]
fn test_write_read_roundtrip_empty() {
roundtrip_bytes(b"");
}
#[test]
fn test_write_read_roundtrip_small_text() {
roundtrip_bytes(b"hello world");
}
#[test]
fn test_write_read_roundtrip_binary() {
roundtrip_bytes(&[0x00, 0xFF, 0xAB, 0xCD, 0x01, 0x02, 0x03]);
}
#[test]
fn test_write_read_roundtrip_large() {
let data = vec![0x42u8; 100_000];
roundtrip_bytes(&data);
}
#[test]
fn test_write_rejects_too_large_frame() {
let oversized = vec![0u8; MAX_FRAME_SIZE + 1];
let mut buf = Vec::new();
let result = write_frame(&mut buf, &oversized);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("too large") || err.contains("64 MiB"));
}
#[test]
fn test_read_rejects_too_large_header() {
// Manually craft a 4-byte length header that exceeds MAX_FRAME_SIZE
let len = (MAX_FRAME_SIZE as u32).wrapping_add(1);
let header = len.to_be_bytes();
let mut buf = Vec::from(&header[..]);
buf.extend_from_slice(b"dummy");
let result = read_frame(&mut buf.as_slice());
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("too large"));
}
#[test]
fn test_read_empty_buf_returns_none() {
let empty: &[u8] = &[];
let result = read_frame(&mut &empty[..]).unwrap();
assert!(result.is_none(), "expected None for empty reader");
}
#[test]
fn test_read_partial_header_returns_none() {
// Only 2 bytes of the 4-byte header → EOF
let partial: &[u8] = &[0x00, 0x01];
let result = read_frame(&mut &partial[..]).unwrap();
assert!(result.is_none(), "expected None for partial header");
}
#[test]
fn test_read_truncated_payload_returns_err() {
let mut buf = Vec::new();
let header = (10u32).to_be_bytes();
buf.extend_from_slice(&header);
buf.extend_from_slice(b"abc"); // only 3 of 10 bytes
let result = read_frame(&mut buf.as_slice());
assert!(result.is_err(), "truncated payload should error");
}
#[test]
fn test_serialize_deserialize_roundtrip() {
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct Msg {
id: u32,
content: String,
tags: Vec<String>,
}
let original = Msg {
id: 42,
content: "hello world".into(),
tags: vec!["foo".into(), "bar".into()],
};
let bytes = serialize_frame(&original).unwrap();
let deserialized: Msg = deserialize_frame(&bytes).unwrap();
assert_eq!(original, deserialized);
}
#[test]
fn test_serialize_rejects_oversized_value() {
let huge = vec![0u8; MAX_FRAME_SIZE + 1];
let result = serialize_frame(&huge);
assert!(result.is_err());
}
#[test]
fn test_deserialize_malformed_json_errors() {
let bad_json = b"this is not json";
let result: Result<String> = deserialize_frame(bad_json);
assert!(result.is_err());
}
}
+4 -6
View File
@@ -12,7 +12,6 @@ use std::sync::Mutex;
use anyhow::Result; use anyhow::Result;
use crossterm::execute; use crossterm::execute;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::event::{EnableMouseCapture, DisableMouseCapture};
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use ratatui::Terminal; use ratatui::Terminal;
@@ -118,7 +117,7 @@ fn run_single_process() -> Result<()> {
enable_raw_mode()?; enable_raw_mode()?;
let mut stdout = io::stdout(); let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout); let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
terminal.clear()?; terminal.clear()?;
@@ -126,7 +125,7 @@ fn run_single_process() -> Result<()> {
let run_result = run_loop(&mut state, &mut terminal); let run_result = run_loop(&mut state, &mut terminal);
let mut restore_stdout = io::stdout(); let mut restore_stdout = io::stdout();
let _ = execute!(restore_stdout, LeaveAlternateScreen, DisableMouseCapture); let _ = execute!(restore_stdout, LeaveAlternateScreen);
let _ = disable_raw_mode(); let _ = disable_raw_mode();
if let Err(e) = run_result { if let Err(e) = run_result {
@@ -468,7 +467,7 @@ fn run_attach(session_id: &str) -> Result<()> {
enable_raw_mode()?; enable_raw_mode()?;
let mut stdout = io::stdout(); let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout); let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
terminal.clear()?; terminal.clear()?;
@@ -556,7 +555,7 @@ fn run_attach(session_id: &str) -> Result<()> {
})?; })?;
} }
let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture); let _ = execute!(io::stdout(), LeaveAlternateScreen);
let _ = disable_raw_mode(); let _ = disable_raw_mode();
let _ = client_state.settings.save(); let _ = client_state.settings.save();
@@ -580,7 +579,6 @@ fn run_loop(
if let Err(ref _e) = result { if let Err(ref _e) = result {
let _ = terminal.clear(); let _ = terminal.clear();
let _ = execute!(io::stdout(), DisableMouseCapture);
let _ = disable_raw_mode(); let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen); let _ = execute!(io::stdout(), LeaveAlternateScreen);
} }
+2 -2
View File
@@ -145,8 +145,8 @@ mod tests {
log.append(EditLogEntry { log.append(EditLogEntry {
ts: i, ts: i,
tool: "edit".to_string(), tool: "edit".to_string(),
path: format!("file{}.txt", i), path: format!("file{i}.txt"),
reason: format!("reason {}", i), reason: format!("reason {i}"),
content_sha256: "hash".to_string(), content_sha256: "hash".to_string(),
bytes_delta: 10 + i, bytes_delta: 10 + i,
origin: "main".to_string(), origin: "main".to_string(),
+1 -1
View File
@@ -375,7 +375,7 @@ mod tests {
}; };
mem.write(&dir).unwrap(); mem.write(&dir).unwrap();
let names = Memory::list(&dir); let names = Memory::list(&dir);
assert!(names.contains(&"alpha".to_string()), "list should contain 'alpha', got: {:?}", names); assert!(names.contains(&"alpha".to_string()), "list should contain 'alpha', got: {names:?}");
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
-6
View File
@@ -11,12 +11,6 @@ pub const TEST_GENERATOR_PROMPT: &str = include_str!("../src-misc/test-generator
pub const ARCH_REVIEWER_PROMPT: &str = include_str!("../src-misc/arch-reviewer-prompt.txt"); pub const ARCH_REVIEWER_PROMPT: &str = include_str!("../src-misc/arch-reviewer-prompt.txt");
pub const SECURITY_REVIEWER_PROMPT: &str = include_str!("../src-misc/security-reviewer-prompt.txt"); pub const SECURITY_REVIEWER_PROMPT: &str = include_str!("../src-misc/security-reviewer-prompt.txt");
/// Division-specific prompts for the company-style agent architecture.
pub const DIVISION_PLANNER_PROMPT: &str = include_str!("../src-misc/division-planner-prompt.txt");
pub const DIVISION_IMPLEMENTER_PROMPT: &str = include_str!("../src-misc/division-implementer-prompt.txt");
pub const DIVISION_TESTER_PROMPT: &str = include_str!("../src-misc/division-tester-prompt.txt");
pub const DIVISION_DOCUMENTER_PROMPT: &str = include_str!("../src-misc/division-documenter-prompt.txt");
pub const HELP_TEXT: &str = " pub const HELP_TEXT: &str = "
ZESDEX - Help ZESDEX - Help
============= =============
+3 -2
View File
@@ -141,7 +141,7 @@ impl ToolCtxBuilder {
/// Construct one instance of every built-in tool, in the fixed order exposed to the LLM. /// Construct one instance of every built-in tool, in the fixed order exposed to the LLM.
/// ///
/// Return: boxed trait objects for all 28 tools (fs, search, bash, git, memory, plan, /// Return: boxed trait objects for all 37 tools (fs, search, bash, git, memory, plan,
/// workflow, utility). /// workflow, utility).
pub fn all_tools() -> Vec<Box<dyn Tool>> { pub fn all_tools() -> Vec<Box<dyn Tool>> {
vec![ vec![
@@ -162,7 +162,8 @@ pub fn all_tools() -> Vec<Box<dyn Tool>> {
Box::new(super::tool::plan::PlanReady), Box::new(super::tool::plan::PlanReady),
Box::new(super::tool::workflow::WorkflowRun), Box::new(super::tool::workflow::WorkflowRun),
Box::new(super::tool::workflow::NoteFinding), Box::new(super::tool::workflow::NoteFinding),
Box::new(super::tool::workflow::CompanyPipeline), Box::new(super::tool::workflow::ReadFindings),
Box::new(super::tool::workflow::HiveMind),
Box::new(super::tool::spawn::SpawnAgents), Box::new(super::tool::spawn::SpawnAgents),
Box::new(super::tool::spawn::SpawnPipeline), Box::new(super::tool::spawn::SpawnPipeline),
Box::new(super::tool::memory::remember::Remember), Box::new(super::tool::memory::remember::Remember),
+4
View File
@@ -106,11 +106,13 @@ impl Tool for SpawnAgents {
// by this tool call are isolated from any other concurrent // by this tool call are isolated from any other concurrent
// spawn_agents or workflow_run invocations. // spawn_agents or workflow_run invocations.
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new())); let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let no_abort: Option<std::sync::Arc<std::sync::atomic::AtomicBool>> = None;
let results = crate::app::workflow::engine::execute_primitive( let results = crate::app::workflow::engine::execute_primitive(
&wf.script, &wf.script,
&HashMap::new(), &HashMap::new(),
max_concurrency, max_concurrency,
true, true,
&no_abort,
live.as_ref(), live.as_ref(),
&ctx.session_dir, &ctx.session_dir,
&ctx.workspaces, &ctx.workspaces,
@@ -195,11 +197,13 @@ impl Tool for SpawnPipeline {
// Per-invocation findings scope isolates this pipeline from any // Per-invocation findings scope isolates this pipeline from any
// other concurrent spawn_agents / spawn_pipeline / workflow_run. // other concurrent spawn_agents / spawn_pipeline / workflow_run.
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new())); let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let no_abort: Option<std::sync::Arc<std::sync::atomic::AtomicBool>> = None;
let results = crate::app::workflow::engine::execute_primitive( let results = crate::app::workflow::engine::execute_primitive(
&wf.script, &wf.script,
&HashMap::new(), &HashMap::new(),
1, 1,
false, false,
&no_abort,
live.as_ref(), live.as_ref(),
&ctx.session_dir, &ctx.session_dir,
&ctx.workspaces, &ctx.workspaces,
+107 -36
View File
@@ -139,23 +139,36 @@ impl Tool for NoteFinding {
} }
} }
/// Tool that delegates work to the company-style division pipeline. /// Tool that delegates work to a hive-mind: a distributed machine
/// intelligence whose processing nodes carry only a directive and an
/// access tier.
/// ///
/// The main agent (CEO) calls this tool to pass a user request through the /// The calling agent (the Core Intelligence) designs its own cognitive
/// full company organization: Strategy → Engineering → Quality → Security /// cycles per task: an ordered list of cycles, each cycle a set of
/// → Documentation. Returns an executive summary. /// anonymous processing nodes that run in parallel. Every node's complete
/// /// output merges into a single collective state the instant it finishes,
/// Use this for any complex or multi-step task. For simple tasks, handle /// and a final synthesis node reconciles the whole collective state into
/// inline or use the quick variant. /// one consensus. The full per-node record is persisted separately to
pub struct CompanyPipeline; /// `docs/runs/*.md`.
pub struct HiveMind;
impl Tool for CompanyPipeline { impl Tool for HiveMind {
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
"company_pipeline" "hive_mind"
} }
fn description(&self) -> &'static str { fn description(&self) -> &'static str {
"Delegate a task to the full company division pipeline: Strategy (plan+diagrams) → Engineering (implement) → Quality (review+test) → Security (audit) → Documentation (docs). Use this for ALL non-trivial tasks instead of doing them yourself. The pipeline returns an executive summary." "Delegate a task to a hive-mind you design yourself: an ordered list of cognitive \
cycles, each cycle a set of anonymous processing nodes that run in parallel. Each \
node carries only a directive (what to do) and an access tier. Decide how many \
cycles and nodes-per-cycle are actually needed a trivial task might need one \
cycle with one node, a large one might need several cycles with multiple nodes \
each. Grant each node an access of 'read' (investigation only), 'write' (read + \
edit/bash), or 'full' (write + delete/git) matched to what that node's directive \
actually requires. Every node's output merges into a shared collective state the \
instant it completes visible to later cycles automatically. A final synthesis pass \
reconciles the entire collective state into one consensus answer. Use this for any \
non-trivial task instead of doing everything yourself inline."
} }
fn parameters(&self) -> Value { fn parameters(&self) -> Value {
@@ -164,16 +177,33 @@ impl Tool for CompanyPipeline {
"properties": { "properties": {
"request": { "request": {
"type": "string", "type": "string",
"description": "The task description to delegate to the company pipeline" "description": "The task description to delegate to the hive-mind"
}, },
"mode": { "cycles": {
"type": "string", "type": "array",
"enum": ["full", "quick"], "description": "Ordered list of cognitive cycles. Each cycle is a list of nodes that run in parallel; cycles run sequentially and every node's output merges into the collective state the instant it completes, visible to all later cycles. You decide the number of cycles and nodes per cycle.",
"description": "Pipeline mode: 'full' (5 divisions) for complex tasks, 'quick' (3 divisions: Strategy→Engineering→Quality) for simpler tasks", "items": {
"default": "full" "type": "array",
"items": {
"type": "object",
"properties": {
"directive": {
"type": "string",
"description": "What this node should do — the sole identity a node carries."
},
"access": {
"type": "string",
"enum": ["read", "write", "full"],
"description": "'read' = investigation only. 'write' = read + edit/write/bash. 'full' = write + delete/git_operator."
}
},
"required": ["directive"]
}
},
"minItems": 1
} }
}, },
"required": ["request"] "required": ["request", "cycles"]
}) })
} }
@@ -182,27 +212,68 @@ impl Tool for CompanyPipeline {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: request"))?; .ok_or_else(|| anyhow!("missing required argument: request"))?;
let mode = args.get("mode") let cycles_value = args.get("cycles")
.and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: cycles"))?;
.unwrap_or("full");
match mode { let plan: crate::app::workflow::hive_mind::CognitiveCyclePlan = serde_json::from_value(
"quick" => { json!({ "cycles": cycles_value })
crate::app::workflow::company::run_company_pipeline_quick( ).map_err(|e| anyhow!("failed to parse cycles: {e}"))?;
request,
&ctx.session_dir, let (consensus, reports) = crate::app::workflow::hive_mind::run_hive_mind(
&ctx.workspaces, request,
ctx.turn_events.as_ref(), &plan,
) &ctx.session_dir,
&ctx.workspaces,
ctx.turn_events.as_ref(),
None,
)?;
if let Some(workspace_root) = ctx.workspaces.first() {
if let Err(e) = crate::app::workflow::docs::write_hive_mind_convergence(workspace_root, request, &reports, &consensus) {
tracing::warn!("[hive_mind] failed to write docs/runs report: {e}");
} }
_ => { }
crate::app::workflow::company::run_company_pipeline(
request, Ok(consensus)
&ctx.session_dir, }
&ctx.workspaces, }
ctx.turn_events.as_ref(),
) /// Tool that retrieves all findings shared by sibling agents in the current workflow run.
pub struct ReadFindings;
impl Tool for ReadFindings {
fn name(&self) -> &'static str {
"read_findings"
}
fn description(&self) -> &'static str {
"Retrieve all findings shared by sibling agents in the current workflow run. Use this to get real-time context updates from other divisions/subagents working in parallel."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {}
})
}
fn run(&self, ctx: &ToolCtx, _args: &Value) -> Result<String> {
if let Some(ref findings) = ctx.workflow_findings {
let f = findings.lock().map_err(|e| anyhow!("poisoned lock: {e}"))?;
if f.is_empty() {
Ok("No findings recorded yet in this workflow run.".to_string())
} else {
let formatted = f
.iter()
.enumerate()
.map(|(i, f)| format!("{}. {}", i + 1, f))
.collect::<Vec<_>>()
.join("\n");
Ok(format!("Findings in this workflow run:\n{formatted}"))
} }
} else {
Ok("No findings database available (called outside a workflow run).".to_string())
} }
} }
} }
+25 -88
View File
@@ -4,8 +4,9 @@
//! panel showing agent statuses, findings count, session counters, and //! panel showing agent statuses, findings count, session counters, and
//! usage hints. //! usage hints.
//! //!
//! Design: agents are shown as compact cards with state-colored badges. //! Design: agents are shown as compact cards with state-colored badges,
//! The division pipeline mode adds a visual pipeline flow with arrows. //! including hive-mind nodes (named by their system-assigned designation,
//! e.g. `"Node-0-1"`).
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::{Style, Modifier}; use ratatui::style::{Style, Modifier};
@@ -43,29 +44,12 @@ fn state_color(state: AgentState) -> Color {
} }
} }
/// Detect if the current workflow looks like a company pipeline.
fn is_company_pipeline(agents: &[crate::app::workflow::engine::WorkflowAgent]) -> bool {
if agents.is_empty() {
return false;
}
let division_keywords = ["Strategy", "Engineering", "Quality", "Security", "Documentation"];
agents.iter().any(|a| {
division_keywords.iter().any(|k| a.name.contains(k))
})
}
/// Render the workflow status panel. /// Render the workflow status panel.
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::layout::{Constraint, Direction, Layout};
let is_company = is_company_pipeline(&state.workflow_engine.agents); let title = Span::styled(" ⚙ Workflow ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD));
let title = if is_company {
Span::styled(" 🏢 Pipeline ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD))
} else {
Span::styled(" ⚙ Workflow ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD))
};
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
@@ -87,74 +71,27 @@ pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::st
// ── Header area ────────────────────────────────────────────────────── // ── Header area ──────────────────────────────────────────────────────
let mut header_lines: Vec<Line> = Vec::new(); let mut header_lines: Vec<Line> = Vec::new();
if is_company { header_lines.push(Line::from(vec![
// Division pipeline overview Span::styled("/workflow run ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)),
let agents = &state.workflow_engine.agents; Span::styled("<prompt>", Style::default().fg(Theme::TEXT_DIM)),
let mut pipe_spans: Vec<Span> = Vec::new(); Span::styled(" · Esc to close", Style::default().fg(Theme::TEXT_DIM)),
for (i, agent) in agents.iter().enumerate() { ]));
if i > 0 { header_lines.push(Line::from(vec![
pipe_spans.push(Span::styled( Span::styled("Status: ", Style::default().fg(Theme::TEXT_DIM)),
" ", if state.turn_in_flight() {
Style::default().fg(Theme::TEXT_DIM), Span::styled("● Running", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD))
)); } else {
} Span::styled("● Idle", Style::default().fg(Theme::SUCCESS))
let icon = state_icon(agent.status.state); },
let color = state_color(agent.status.state); Span::raw(" "),
let modif = match agent.status.state { Span::styled(
AgentState::Idle => Modifier::empty(), format!("Agents: {} | Findings: {}",
_ => Modifier::BOLD, state.workflow_engine.agents.len(),
}; state.workflow_engine.findings.len(),
pipe_spans.push(Span::styled(
format!("{} {} ", icon, agent.name.chars().take(10).collect::<String>()),
Style::default().fg(color).add_modifier(modif),
));
if i < agents.len().saturating_sub(1) {
pipe_spans.push(Span::styled(
"",
Style::default().fg(Theme::TEXT_DIM),
));
}
}
header_lines.push(Line::from(pipe_spans));
header_lines.push(Line::from(vec![
Span::styled("Status: ", Style::default().fg(Theme::TEXT_DIM)),
if state.turn_in_flight() {
Span::styled("● Running", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD))
} else {
Span::styled("● Idle", Style::default().fg(Theme::SUCCESS))
},
Span::raw(" "),
Span::styled(
format!("Agents: {} | Findings: {}",
state.workflow_engine.agents.len(),
state.workflow_engine.findings.len(),
),
Style::default().fg(Theme::TEXT_DIM),
), ),
])); Style::default().fg(Theme::TEXT_DIM),
} else { ),
header_lines.push(Line::from(vec![ ]));
Span::styled("/workflow run ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)),
Span::styled("<prompt>", Style::default().fg(Theme::TEXT_DIM)),
Span::styled(" · Esc to close", Style::default().fg(Theme::TEXT_DIM)),
]));
header_lines.push(Line::from(vec![
Span::styled("Status: ", Style::default().fg(Theme::TEXT_DIM)),
if state.turn_in_flight() {
Span::styled("● Running", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD))
} else {
Span::styled("● Idle", Style::default().fg(Theme::SUCCESS))
},
Span::raw(" "),
Span::styled(
format!("Agents: {} | Findings: {}",
state.workflow_engine.agents.len(),
state.workflow_engine.findings.len(),
),
Style::default().fg(Theme::TEXT_DIM),
),
]));
}
let header = Paragraph::new(header_lines); let header = Paragraph::new(header_lines);
frame.render_widget(header, chunks[0]); frame.render_widget(header, chunks[0]);
@@ -264,7 +201,7 @@ fn build_session_lines(state: &crate::app::state::rest::AppStateRest) -> Vec<Lin
lines.push(Line::from(Span::raw(""))); lines.push(Line::from(Span::raw("")));
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
" Complex tasks auto-delegate to the company pipeline.", " Complex tasks auto-trigger a hive-mind convergence.",
Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC), Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC),
))); )));