Licensor: 合同会社みやび (Miyabi G.K.) 非商用・教育目的のみ無料。商用利用は別途ライセンス。 4年後に Apache 2.0 に自動移行。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1950 lines
70 KiB
Rust
1950 lines
70 KiB
Rust
//! Miyabi CLI - Main entry point
|
|
|
|
use chrono::Duration as ChronoDuration;
|
|
use clap::{Parser, Subcommand, ValueEnum};
|
|
use miyabi_core::{FeatureFlagManager, RulesLoader};
|
|
use std::collections::HashMap;
|
|
use std::io::{self, BufRead, BufReader, Write};
|
|
use std::net::{TcpListener, TcpStream};
|
|
use std::path::PathBuf;
|
|
use tracing_subscriber::EnvFilter;
|
|
|
|
/// Global feature flags manager
|
|
static FEATURE_FLAGS: std::sync::OnceLock<FeatureFlagManager> = std::sync::OnceLock::new();
|
|
|
|
/// Get the global feature flags manager
|
|
pub fn feature_flags() -> &'static FeatureFlagManager {
|
|
FEATURE_FLAGS.get_or_init(|| {
|
|
let manager = FeatureFlagManager::new();
|
|
// Default feature flags
|
|
manager.set_flag("extended_thinking", true);
|
|
manager.set_flag("auto_save_sessions", true);
|
|
manager.set_flag("syntax_highlighting", true);
|
|
manager.set_flag("vim_mode", false);
|
|
manager
|
|
})
|
|
}
|
|
|
|
#[derive(Parser)]
|
|
#[command(name = "miyabi")]
|
|
#[command(author, version, about = "Miyabi - Autonomous AI Development Framework", long_about = None)]
|
|
struct Cli {
|
|
/// Model to use (overrides config)
|
|
#[arg(short, long)]
|
|
model: Option<String>,
|
|
|
|
/// Maximum tokens for responses (overrides config)
|
|
#[arg(long)]
|
|
max_tokens: Option<u32>,
|
|
|
|
/// Enable Extended Thinking (Claude 4.5+)
|
|
#[arg(long)]
|
|
thinking: bool,
|
|
|
|
/// Path to config file
|
|
#[arg(short, long)]
|
|
config: Option<PathBuf>,
|
|
|
|
/// Session ID to load on startup
|
|
#[arg(short, long)]
|
|
session: Option<String>,
|
|
|
|
#[command(subcommand)]
|
|
command: Option<Commands>,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum Commands {
|
|
/// Start the TUI interface
|
|
Tui,
|
|
/// Show status
|
|
Status,
|
|
/// Generate default config file
|
|
Init,
|
|
/// Manage sessions
|
|
Sessions {
|
|
/// Delete a session by ID
|
|
#[arg(short, long)]
|
|
delete: Option<String>,
|
|
/// Export a session to JSON file
|
|
#[arg(short, long)]
|
|
export: Option<String>,
|
|
/// Export a session to Markdown file
|
|
#[arg(short, long)]
|
|
markdown: Option<String>,
|
|
},
|
|
/// Show version and system information
|
|
Version,
|
|
/// Show project rules (.miyabirules)
|
|
Rules {
|
|
/// Show detailed rule information
|
|
#[arg(short, long)]
|
|
verbose: bool,
|
|
},
|
|
/// Run agent with a prompt (autonomous execution)
|
|
Agent {
|
|
/// The prompt to execute
|
|
prompt: String,
|
|
/// Maximum iterations (default: 10)
|
|
#[arg(long, default_value = "10")]
|
|
max_iterations: usize,
|
|
/// Auto-approve all tool executions
|
|
#[arg(long)]
|
|
auto_approve: bool,
|
|
/// Output format: text or json
|
|
#[arg(long, default_value = "text")]
|
|
format: String,
|
|
/// System prompt for the agent
|
|
#[arg(long)]
|
|
system: Option<String>,
|
|
},
|
|
/// Deterministic Task Protocol gate controls
|
|
Gate {
|
|
/// Output format
|
|
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
|
|
format: OutputFormat,
|
|
/// Path to the task ledger JSON file
|
|
#[arg(long, default_value = "project_memory/tasks.json")]
|
|
store_path: PathBuf,
|
|
/// Gate subcommand
|
|
#[command(subcommand)]
|
|
command: GateCommand,
|
|
},
|
|
/// OpenClaw integration - control OpenClaw agents
|
|
Openclaw {
|
|
/// OpenClaw subcommand
|
|
#[command(subcommand)]
|
|
command: OpenclawCommand,
|
|
},
|
|
/// Collaborator canvas control via collab CLI
|
|
Collab {
|
|
/// Canvas subcommand
|
|
#[command(subcommand)]
|
|
command: CollabCommand,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone, Debug, ValueEnum)]
|
|
enum OutputFormat {
|
|
Text,
|
|
Json,
|
|
}
|
|
|
|
#[derive(Clone, Debug, ValueEnum)]
|
|
enum CompletionModeArg {
|
|
GithubPr,
|
|
Manual,
|
|
ExternalOp,
|
|
}
|
|
|
|
#[derive(Clone, Debug, ValueEnum)]
|
|
enum ImpactRiskArg {
|
|
Low,
|
|
Medium,
|
|
High,
|
|
Critical,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum GateCommand {
|
|
/// Register a task in the execution ledger
|
|
Register {
|
|
/// GitHub issue number
|
|
#[arg(long, default_value_t = 0)]
|
|
issue: u64,
|
|
/// Task title
|
|
#[arg(long)]
|
|
title: String,
|
|
/// Explicit task ID (defaults to issue-N or slugified title)
|
|
#[arg(long)]
|
|
task_id: Option<String>,
|
|
/// Hard dependencies (comma separated)
|
|
#[arg(long, value_delimiter = ',')]
|
|
dependencies: Vec<String>,
|
|
/// Soft dependencies (comma separated)
|
|
#[arg(long, value_delimiter = ',')]
|
|
soft_dependencies: Vec<String>,
|
|
/// Priority score
|
|
#[arg(long, default_value_t = 0)]
|
|
priority: u32,
|
|
/// Completion mode
|
|
#[arg(long, value_enum, default_value_t = CompletionModeArg::GithubPr)]
|
|
completion_mode: CompletionModeArg,
|
|
},
|
|
/// Show status for one task or the whole ledger
|
|
Status {
|
|
/// Optional task ID
|
|
task_id: Option<String>,
|
|
},
|
|
/// Assign a task and acquire file locks
|
|
Assign {
|
|
task_id: String,
|
|
#[arg(long)]
|
|
agent: String,
|
|
#[arg(long)]
|
|
node: String,
|
|
#[arg(long, value_delimiter = ',', num_args = 1..)]
|
|
files: Vec<String>,
|
|
},
|
|
/// Record impact analysis
|
|
Impact {
|
|
task_id: String,
|
|
#[arg(long, value_enum)]
|
|
risk: ImpactRiskArg,
|
|
#[arg(long)]
|
|
approve: bool,
|
|
#[arg(long)]
|
|
symbols: usize,
|
|
#[arg(long, value_delimiter = ',')]
|
|
depth1: Vec<String>,
|
|
#[arg(long)]
|
|
analyzed_commit: Option<String>,
|
|
#[arg(long)]
|
|
input_hash: Option<String>,
|
|
},
|
|
/// Record branch creation
|
|
Branch { task_id: String, name: String },
|
|
/// Attach task context for execution
|
|
Attach { task_id: String },
|
|
/// Record PR creation
|
|
Pr { task_id: String, number: u64 },
|
|
/// Record merge verification
|
|
Merge { task_id: String, sha: String },
|
|
/// List active locks
|
|
Locks,
|
|
/// Show DAG levels
|
|
Dag,
|
|
/// Show dispatchable tasks
|
|
Dispatchable,
|
|
/// Serve a minimal web dashboard
|
|
Serve {
|
|
/// Port to bind the dashboard to
|
|
#[arg(long, default_value_t = 4848)]
|
|
port: u16,
|
|
},
|
|
/// Analyze recent event logs and extract learnings
|
|
Dream {
|
|
/// Analyze only recent events, e.g. 24h, 30m, 7d
|
|
#[arg(long)]
|
|
since: Option<String>,
|
|
/// Obsidian vault path for exported learnings
|
|
#[arg(long)]
|
|
vault_path: Option<PathBuf>,
|
|
/// Persist High learnings into docs/learnings/
|
|
#[arg(long)]
|
|
auto: bool,
|
|
},
|
|
}
|
|
|
|
/// Collab canvas subcommands — wraps the collab CLI at ~/.local/bin/collab
|
|
#[derive(Subcommand)]
|
|
enum CollabCommand {
|
|
/// List tiles on the canvas
|
|
List {
|
|
/// Output as JSON array
|
|
#[arg(long)]
|
|
json: bool,
|
|
/// Filter by tile type (note, code, term, image, graph)
|
|
#[arg(long)]
|
|
r#type: Option<String>,
|
|
/// Count only
|
|
#[arg(long)]
|
|
count: bool,
|
|
},
|
|
/// Add a tile to the canvas
|
|
Add {
|
|
/// Tile type (note, code, term, image, graph)
|
|
tile_type: String,
|
|
/// File to attach (required for note/code)
|
|
#[arg(long)]
|
|
file: Option<String>,
|
|
/// Position in grid units "x,y"
|
|
#[arg(long)]
|
|
pos: Option<String>,
|
|
/// Size in grid units "w,h"
|
|
#[arg(long)]
|
|
size: Option<String>,
|
|
/// Skip if tile with same file already exists
|
|
#[arg(long)]
|
|
idempotent: bool,
|
|
},
|
|
/// Remove a tile from the canvas
|
|
Rm {
|
|
/// Tile ID to remove
|
|
tile_id: String,
|
|
},
|
|
/// Move a tile to a new position
|
|
Move {
|
|
/// Tile ID to move
|
|
tile_id: String,
|
|
/// New position in grid units "x,y"
|
|
#[arg(long)]
|
|
pos: String,
|
|
},
|
|
/// Resize a tile
|
|
Resize {
|
|
/// Tile ID to resize
|
|
tile_id: String,
|
|
/// New size in grid units "w,h"
|
|
#[arg(long)]
|
|
size: String,
|
|
},
|
|
/// Get or set the canvas viewport
|
|
Viewport {
|
|
/// Set pan position "x,y"
|
|
#[arg(long)]
|
|
pan: Option<String>,
|
|
/// Set zoom level (e.g. 1.0)
|
|
#[arg(long)]
|
|
zoom: Option<f64>,
|
|
},
|
|
/// Show Collaborator connection status
|
|
Status,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum OpenclawCommand {
|
|
/// List all available agents
|
|
Agents,
|
|
/// Show OpenClaw status
|
|
Status,
|
|
/// Show detailed help for OpenClaw commands
|
|
Help,
|
|
/// Send a message to an agent
|
|
Send {
|
|
/// Agent name (e.g., maestro, kade, sakura)
|
|
agent: String,
|
|
/// Message to send
|
|
message: String,
|
|
},
|
|
/// Broadcast a message to all agents
|
|
Broadcast {
|
|
/// Message to broadcast
|
|
message: String,
|
|
},
|
|
/// Broadcast to a specific society
|
|
BroadcastSociety {
|
|
/// Society name (core, investment, content, marketing)
|
|
society: String,
|
|
/// Message to broadcast
|
|
message: String,
|
|
},
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> anyhow::Result<()> {
|
|
// Initialize logging
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(EnvFilter::from_default_env())
|
|
.init();
|
|
|
|
let cli = Cli::parse();
|
|
|
|
match cli.command {
|
|
Some(Commands::Tui) | None => {
|
|
// Run TUI
|
|
use crossterm::{
|
|
event::{DisableMouseCapture, EnableMouseCapture},
|
|
execute,
|
|
terminal::{
|
|
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
|
},
|
|
};
|
|
use miyabi_core::config::Config;
|
|
use miyabi_tui::App;
|
|
use ratatui::prelude::*;
|
|
use std::io;
|
|
|
|
// Load config (from custom path or default)
|
|
let mut config = if let Some(config_path) = &cli.config {
|
|
Config::load_from(config_path)?
|
|
} else {
|
|
Config::load().unwrap_or_default()
|
|
};
|
|
|
|
// Apply CLI overrides
|
|
if let Some(model) = &cli.model {
|
|
config.api.model = model.clone();
|
|
}
|
|
if let Some(max_tokens) = cli.max_tokens {
|
|
config.api.max_tokens = max_tokens;
|
|
}
|
|
if cli.thinking {
|
|
config.api.thinking = true;
|
|
}
|
|
|
|
enable_raw_mode()?;
|
|
let mut stdout = io::stdout();
|
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
|
let backend = CrosstermBackend::new(stdout);
|
|
let mut terminal = Terminal::new(backend)?;
|
|
|
|
let mut app = App::with_config(config);
|
|
|
|
// Load session if specified
|
|
if let Some(session_id) = &cli.session {
|
|
if let Err(e) = app.load_session(session_id) {
|
|
eprintln!("Warning: Failed to load session {}: {}", session_id, e);
|
|
}
|
|
}
|
|
|
|
let res = app.run(&mut terminal).await;
|
|
|
|
disable_raw_mode()?;
|
|
execute!(
|
|
terminal.backend_mut(),
|
|
LeaveAlternateScreen,
|
|
DisableMouseCapture
|
|
)?;
|
|
terminal.show_cursor()?;
|
|
|
|
if let Err(err) = res {
|
|
eprintln!("Error: {}", err);
|
|
}
|
|
}
|
|
Some(Commands::Status) => {
|
|
use miyabi_core::config::Config;
|
|
|
|
let config = Config::load().unwrap_or_default();
|
|
|
|
println!("Miyabi Status: Ready");
|
|
println!();
|
|
println!("Config: {}", Config::default_path().display());
|
|
println!("Sessions: {}", config.sessions_dir().display());
|
|
println!("Model: {}", config.api.model);
|
|
println!();
|
|
|
|
// Load and show rules info
|
|
let cwd = std::env::current_dir().unwrap_or_default();
|
|
let loader = RulesLoader::new(cwd);
|
|
match loader.load() {
|
|
Ok(Some(rules)) => {
|
|
println!("Rules: {} rules loaded", rules.rules.len());
|
|
if !rules.agent_preferences.is_empty() {
|
|
println!(
|
|
"Agents: {} agent preferences",
|
|
rules.agent_preferences.len()
|
|
);
|
|
}
|
|
}
|
|
Ok(None) => {
|
|
println!("Rules: No .miyabirules found");
|
|
}
|
|
Err(e) => {
|
|
println!("Rules: Error loading - {}", e);
|
|
}
|
|
}
|
|
|
|
// Show feature flags
|
|
let flags = feature_flags();
|
|
let all_flags = flags.get_all_flags();
|
|
let enabled_count = all_flags.iter().filter(|f| f.enabled).count();
|
|
println!("Flags: {}/{} enabled", enabled_count, all_flags.len());
|
|
}
|
|
Some(Commands::Init) => {
|
|
use miyabi_core::config::Config;
|
|
let path = Config::generate_default()?;
|
|
println!("Generated default config at: {:?}", path);
|
|
}
|
|
Some(Commands::Sessions {
|
|
delete,
|
|
export,
|
|
markdown,
|
|
}) => {
|
|
use miyabi_core::anthropic::{ContentBlock, Role};
|
|
use miyabi_core::config::Config;
|
|
use miyabi_core::session::SessionStorage;
|
|
|
|
let config = Config::load().unwrap_or_default();
|
|
let storage = SessionStorage::new(config.sessions_dir());
|
|
|
|
if let Some(id) = delete {
|
|
// Delete session
|
|
match storage.delete(&id) {
|
|
Ok(_) => println!("Deleted session: {}", id),
|
|
Err(e) => eprintln!("Failed to delete session {}: {}", id, e),
|
|
}
|
|
} else if let Some(id) = export {
|
|
// Export session to JSON
|
|
match storage.load(&id) {
|
|
Ok(session) => {
|
|
let filename = format!("{}.json", id);
|
|
let json = serde_json::to_string_pretty(&session)?;
|
|
std::fs::write(&filename, json)?;
|
|
println!("Exported session to: {}", filename);
|
|
}
|
|
Err(e) => eprintln!("Failed to load session {}: {}", id, e),
|
|
}
|
|
} else if let Some(id) = markdown {
|
|
// Export session to Markdown
|
|
match storage.load(&id) {
|
|
Ok(session) => {
|
|
let filename = format!("{}.md", id);
|
|
let mut md = String::new();
|
|
|
|
// Header
|
|
md.push_str(&format!("# Session: {}\n\n", session.title));
|
|
md.push_str(&format!("**Model**: {}\n", session.model));
|
|
md.push_str(&format!(
|
|
"**Date**: {}\n",
|
|
session.created_at.format("%Y-%m-%d %H:%M")
|
|
));
|
|
md.push_str(&format!("**Tokens**: {}\n\n", session.tokens_used));
|
|
md.push_str("---\n\n");
|
|
|
|
// Messages
|
|
for message in &session.messages {
|
|
let role = match message.role {
|
|
Role::User => "You",
|
|
Role::Assistant => "Assistant",
|
|
};
|
|
|
|
md.push_str(&format!("## {}\n\n", role));
|
|
|
|
for content in &message.content {
|
|
match content {
|
|
ContentBlock::Text { text } => {
|
|
md.push_str(text);
|
|
md.push_str("\n\n");
|
|
}
|
|
ContentBlock::ToolUse { name, input, .. } => {
|
|
md.push_str(&format!(
|
|
"**Tool**: {}\n```json\n{}\n```\n\n",
|
|
name, input
|
|
));
|
|
}
|
|
ContentBlock::ToolResult { content, .. } => {
|
|
md.push_str(&format!(
|
|
"**Result**:\n```\n{}\n```\n\n",
|
|
content
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
std::fs::write(&filename, md)?;
|
|
println!("Exported session to: {}", filename);
|
|
}
|
|
Err(e) => eprintln!("Failed to load session {}: {}", id, e),
|
|
}
|
|
} else {
|
|
// List all sessions
|
|
match storage.list() {
|
|
Ok(sessions) => {
|
|
if sessions.is_empty() {
|
|
println!("No sessions found.");
|
|
} else {
|
|
println!(
|
|
"{:<36} {:<20} {:<8} {:<10} Updated",
|
|
"ID", "Title", "Messages", "Tokens"
|
|
);
|
|
println!("{}", "-".repeat(90));
|
|
for session in sessions {
|
|
let updated = session.updated_at.format("%Y-%m-%d %H:%M");
|
|
println!(
|
|
"{:<36} {:<20} {:<8} {:<10} {}",
|
|
session.id,
|
|
truncate_str(&session.title, 18),
|
|
session.messages.len(),
|
|
session.tokens_used,
|
|
updated
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => eprintln!("Failed to list sessions: {}", e),
|
|
}
|
|
}
|
|
}
|
|
Some(Commands::Version) => {
|
|
use miyabi_core::config::Config;
|
|
|
|
let config = Config::load().unwrap_or_default();
|
|
|
|
println!("Miyabi v{}", env!("CARGO_PKG_VERSION"));
|
|
println!();
|
|
println!("Model: {}", config.api.model);
|
|
println!("Config: {}", Config::default_path().display());
|
|
println!("Sessions: {}", config.sessions_dir().display());
|
|
println!();
|
|
println!(
|
|
"Platform: {} ({})",
|
|
std::env::consts::OS,
|
|
std::env::consts::ARCH
|
|
);
|
|
}
|
|
Some(Commands::Rules { verbose }) => {
|
|
let cwd = std::env::current_dir().unwrap_or_default();
|
|
let loader = RulesLoader::new(cwd.clone());
|
|
|
|
match loader.load() {
|
|
Ok(Some(rules)) => {
|
|
println!("Project Rules (.miyabirules)");
|
|
println!("============================");
|
|
println!();
|
|
|
|
if rules.rules.is_empty() {
|
|
println!("No rules defined.");
|
|
} else {
|
|
println!("Rules ({}):", rules.rules.len());
|
|
for rule in &rules.rules {
|
|
let status = if rule.enabled { "✓" } else { "✗" };
|
|
let severity = match rule.severity.as_str() {
|
|
"error" => "🔴",
|
|
"warning" => "🟡",
|
|
_ => "🔵",
|
|
};
|
|
println!(
|
|
" {} {} {} - {}",
|
|
status, severity, rule.name, rule.suggestion
|
|
);
|
|
|
|
if verbose {
|
|
if let Some(pattern) = &rule.pattern {
|
|
println!(" Pattern: {}", pattern);
|
|
}
|
|
if !rule.file_extensions.is_empty() {
|
|
println!(
|
|
" Extensions: {}",
|
|
rule.file_extensions.join(", ")
|
|
);
|
|
}
|
|
println!();
|
|
}
|
|
}
|
|
}
|
|
|
|
if !rules.agent_preferences.is_empty() {
|
|
println!();
|
|
println!("Agent Preferences ({}):", rules.agent_preferences.len());
|
|
for (agent, prefs) in &rules.agent_preferences {
|
|
println!(" {}:", agent);
|
|
if let Some(style) = &prefs.style {
|
|
println!(" Style: {}", style);
|
|
}
|
|
if let Some(handling) = &prefs.error_handling {
|
|
println!(" Error Handling: {}", handling);
|
|
}
|
|
if let Some(score) = prefs.min_score {
|
|
println!(" Min Score: {}", score);
|
|
}
|
|
}
|
|
}
|
|
|
|
if verbose && !rules.settings.is_empty() {
|
|
println!();
|
|
println!("Settings:");
|
|
for (key, value) in &rules.settings {
|
|
println!(" {}: {}", key, value);
|
|
}
|
|
}
|
|
}
|
|
Ok(None) => {
|
|
println!(
|
|
"No .miyabirules file found in {} or parent directories.",
|
|
cwd.display()
|
|
);
|
|
println!();
|
|
println!("Create a .miyabirules file to define project-specific rules.");
|
|
println!("See: miyabi --help for more information.");
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error loading rules: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
Some(Commands::Agent {
|
|
prompt,
|
|
max_iterations,
|
|
auto_approve,
|
|
format,
|
|
system,
|
|
}) => {
|
|
use miyabi_core::{
|
|
config::Config, Agent, AgentConfig, AgentEvent, AnthropicClient, ExecutorRegistry,
|
|
};
|
|
use tokio::sync::mpsc;
|
|
|
|
// Load config
|
|
let mut config = if let Some(config_path) = &cli.config {
|
|
Config::load_from(config_path)?
|
|
} else {
|
|
Config::load().unwrap_or_default()
|
|
};
|
|
|
|
// Apply CLI overrides
|
|
if let Some(model) = &cli.model {
|
|
config.api.model = model.clone();
|
|
}
|
|
if let Some(max_tokens) = cli.max_tokens {
|
|
config.api.max_tokens = max_tokens;
|
|
}
|
|
if cli.thinking {
|
|
config.api.thinking = true;
|
|
}
|
|
|
|
// Get API key
|
|
let api_key = config
|
|
.api
|
|
.api_key
|
|
.clone()
|
|
.or_else(|| std::env::var("ANTHROPIC_API_KEY").ok())
|
|
.ok_or_else(|| {
|
|
anyhow::anyhow!("No API key found. Set ANTHROPIC_API_KEY or add to config.")
|
|
})?;
|
|
|
|
// Create client
|
|
let client = AnthropicClient::new(api_key)?
|
|
.with_model(&config.api.model)
|
|
.with_max_tokens(config.api.max_tokens)
|
|
.with_thinking(config.api.thinking);
|
|
|
|
// Create executor registry with standard tools
|
|
let registry = ExecutorRegistry::with_standard_tools();
|
|
|
|
// Configure agent
|
|
let agent_config = AgentConfig {
|
|
max_iterations,
|
|
max_tokens_per_turn: config.api.max_tokens,
|
|
require_approval: !auto_approve,
|
|
auto_approve_patterns: if auto_approve {
|
|
vec![
|
|
"read".to_string(),
|
|
"glob".to_string(),
|
|
"grep".to_string(),
|
|
"write".to_string(),
|
|
"edit".to_string(),
|
|
"bash".to_string(),
|
|
]
|
|
} else {
|
|
vec!["read".to_string(), "glob".to_string(), "grep".to_string()]
|
|
},
|
|
..Default::default()
|
|
};
|
|
|
|
// Create agent
|
|
let mut agent = Agent::new(client, registry).with_config(agent_config);
|
|
|
|
// Set system prompt
|
|
if let Some(sys) = system {
|
|
agent = agent.with_system_prompt(sys);
|
|
} else if let Some(sys) = config.api.system_prompt {
|
|
agent = agent.with_system_prompt(sys);
|
|
}
|
|
|
|
// Create event channel for progress
|
|
let (tx, mut rx) = mpsc::channel(100);
|
|
let agent = agent.with_event_channel(tx);
|
|
|
|
// Spawn agent execution
|
|
let agent_handle = tokio::spawn(async move { agent.run(&prompt).await });
|
|
|
|
// Process events
|
|
while let Some(event) = rx.recv().await {
|
|
match &event {
|
|
AgentEvent::Started { prompt } => {
|
|
if format != "json" {
|
|
eprintln!("🚀 Agent started with prompt: {}", truncate_str(prompt, 50));
|
|
}
|
|
}
|
|
AgentEvent::Thinking { iteration } => {
|
|
if format != "json" {
|
|
eprintln!("💭 Iteration {}", iteration + 1);
|
|
}
|
|
}
|
|
AgentEvent::ToolDetected { name, .. } => {
|
|
if format != "json" {
|
|
eprintln!("🔧 Tool detected: {}", name);
|
|
}
|
|
}
|
|
AgentEvent::ToolExecuting { name, .. } => {
|
|
if format != "json" {
|
|
eprintln!("⚡ Executing: {}", name);
|
|
}
|
|
}
|
|
AgentEvent::ToolCompleted { name, .. } => {
|
|
if format != "json" {
|
|
eprintln!("✅ Completed: {}", name);
|
|
}
|
|
}
|
|
AgentEvent::ToolFailed { name, error } => {
|
|
if format != "json" {
|
|
eprintln!("❌ Failed {}: {}", name, error);
|
|
}
|
|
}
|
|
AgentEvent::Completed { result } => {
|
|
if format == "json" {
|
|
println!("{}", serde_json::to_string_pretty(&result)?);
|
|
} else {
|
|
println!("\n{}", result.output);
|
|
eprintln!(
|
|
"\n📊 Stats: {} iterations, {} tool calls, {} tokens",
|
|
result.iterations, result.tool_calls, result.total_tokens
|
|
);
|
|
}
|
|
}
|
|
AgentEvent::Failed { error } => {
|
|
eprintln!("❌ Agent failed: {}", error);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// Wait for agent to complete
|
|
match agent_handle.await? {
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
eprintln!("Agent error: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
Some(Commands::Gate {
|
|
format,
|
|
store_path,
|
|
command,
|
|
}) => {
|
|
let code = handle_gate_command(&format, &store_path, command)?;
|
|
std::process::exit(code);
|
|
}
|
|
Some(Commands::Openclaw { command }) => {
|
|
use miyabi_core::openclaw::OpenClawClient;
|
|
use std::env;
|
|
|
|
// Get OpenClaw configuration
|
|
let gateway_url = env::var("OPENCLAW_GATEWAY_URL")
|
|
.unwrap_or_else(|_| "http://127.0.0.1:18789".to_string());
|
|
let token = env::var("OPENCLAW_TOKEN").unwrap_or_else(|_| {
|
|
// Try to read from openclaw.json
|
|
#[allow(unused_imports)]
|
|
use std::fs;
|
|
#[allow(unused_imports)]
|
|
use std::path::PathBuf;
|
|
|
|
let config_path = PathBuf::from(env::var("HOME").unwrap_or_default())
|
|
.join(".openclaw")
|
|
.join("openclaw.json");
|
|
|
|
// Fallback: try to read token from config
|
|
if let Ok(content) = fs::read_to_string(&config_path) {
|
|
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
|
|
if let Some(gateway) = json.get("gateway") {
|
|
if let Some(auth) = gateway.get("auth") {
|
|
if let Some(t) = auth.get("token") {
|
|
return t.as_str().unwrap_or("").to_string();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
String::new()
|
|
});
|
|
|
|
if token.is_empty() {
|
|
eprintln!("❌ Error: OPENCLAW_TOKEN not set");
|
|
eprintln!(" Set environment variable: export OPENCLAW_TOKEN=your_token");
|
|
eprintln!(" Or add to ~/.miyabi/config.toml");
|
|
return Ok(());
|
|
}
|
|
|
|
// Handle Status command separately to avoid borrowing issues
|
|
if let OpenclawCommand::Status = command {
|
|
let token_display = if token.len() > 4 {
|
|
format!("{}***", &token[..4])
|
|
} else {
|
|
"***".to_string()
|
|
};
|
|
println!("📊 OpenClaw Status:");
|
|
println!(" Gateway: {}", gateway_url);
|
|
println!(" Token: {}", token_display);
|
|
return Ok(());
|
|
}
|
|
|
|
let client = OpenClawClient::new(gateway_url, token);
|
|
|
|
match command {
|
|
OpenclawCommand::Agents => {
|
|
// List agents grouped by society
|
|
let agents = OpenClawClient::get_agents();
|
|
println!("🎭 Miyabi エージェント一覧 ({} agents):", agents.len());
|
|
println!();
|
|
|
|
// Group by society
|
|
let mut grouped: HashMap<&str, Vec<_>> = HashMap::new();
|
|
for agent in &agents {
|
|
grouped.entry(&agent.society).or_default().push(agent);
|
|
}
|
|
|
|
// Define society order
|
|
let society_order = vec!["Core", "Investment", "Content", "Marketing"];
|
|
|
|
for society in &society_order {
|
|
if let Some(society_agents) = grouped.get(*society) {
|
|
println!("【{} Society】", society);
|
|
for agent in society_agents {
|
|
println!(" {} {} ({})", agent.emoji, agent.name, agent.id);
|
|
println!(" Role: {}", agent.role);
|
|
}
|
|
println!();
|
|
}
|
|
}
|
|
}
|
|
OpenclawCommand::Send { agent, message } => {
|
|
// Send message
|
|
let resolved_agent = OpenClawClient::resolve_agent_alias(&agent);
|
|
match client.send(&resolved_agent, &message).await {
|
|
Ok(_msg) => {
|
|
println!("✓ メッセージを送信しました:");
|
|
println!(" Agent: {}", resolved_agent);
|
|
println!(" Message: {}", message);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("❌ 送信エラー: {}", e);
|
|
}
|
|
}
|
|
}
|
|
OpenclawCommand::Broadcast { message } => {
|
|
// Broadcast message to all core agents
|
|
match client.broadcast(&message).await {
|
|
Ok(results) => {
|
|
println!("✓ ブロードキャストを送信しました:");
|
|
println!(" Message: {}", message);
|
|
println!();
|
|
for result in results {
|
|
println!(" {}", result);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!("❌ ブロードキャストエラー: {}", e);
|
|
}
|
|
}
|
|
}
|
|
OpenclawCommand::BroadcastSociety { society, message } => {
|
|
// Broadcast to specific society
|
|
let society_agents = match society.to_lowercase().as_str() {
|
|
"core" => vec!["maestro", "kade", "sakura", "tsubaki", "botan", "nagare"],
|
|
"investment" => vec![
|
|
"scout",
|
|
"crystal",
|
|
"dealer",
|
|
"sentinel",
|
|
"architect",
|
|
"watchman",
|
|
"chart",
|
|
"fundy",
|
|
"scribe",
|
|
],
|
|
"content" => vec![
|
|
"tweeter",
|
|
"pen",
|
|
"vidpro",
|
|
"artist",
|
|
"optimizer",
|
|
"scheduler",
|
|
],
|
|
"marketing" => vec!["hiro", "kazoeru", "funnel", "adops"],
|
|
_ => {
|
|
eprintln!("❌ 不明なSociety: {}", society);
|
|
eprintln!(" 利用可能: core, investment, content, marketing");
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
println!("✓ {} Society にブロードキャスト:", society);
|
|
println!(" Message: {}", message);
|
|
println!();
|
|
|
|
for agent in society_agents {
|
|
match client.send(agent, &message).await {
|
|
Ok(_) => println!(" ✓ {}", agent),
|
|
Err(e) => eprintln!(" ❌ {}: {}", agent, e),
|
|
}
|
|
}
|
|
}
|
|
OpenclawCommand::Help => {
|
|
// Show detailed help
|
|
println!("📖 Miyabi OpenClaw CLI - 詳細ヘルプ");
|
|
println!();
|
|
println!("【基本コマンド】");
|
|
println!();
|
|
println!(" miyabi openclaw agents");
|
|
println!(" → 全エージェント一覧を表示 (Society別)");
|
|
println!();
|
|
println!(" miyabi openclaw status");
|
|
println!(" → OpenClaw Gatewayの状態を確認");
|
|
println!();
|
|
println!(" miyabi openclaw send <agent> <message>");
|
|
println!(" → 特定のエージェントにメッセージを送信");
|
|
println!();
|
|
println!(" miyabi openclaw broadcast <message>");
|
|
println!(" → 全コアエージェントにブロードキャスト");
|
|
println!();
|
|
println!(" miyabi openclaw broadcast-society <society> <message>");
|
|
println!(" → 特定のSocietyにブロードキャスト");
|
|
println!();
|
|
println!("【エージェントIDとエイリアス】");
|
|
println!();
|
|
println!(" Core Society (6):");
|
|
println!(" maestro しきるん🎭 - shikirun, conductor, orchestrator");
|
|
println!(" kade カエデ🍁 - kaede, creator, codegen, developer");
|
|
println!(" sakura サクラ🌸 - reviewer, qa, critic");
|
|
println!(" tsubaki ツバキ🌺 - integrator, pr-manager, merge-bot");
|
|
println!(" botan ボタン🌼 - deployer, release-manager, deployment");
|
|
println!(" nagare ながれるん🌊 - nagarerun, workflow, automation");
|
|
println!();
|
|
println!(" Investment Society (9):");
|
|
println!(" scout スカウト🔍 - researcher, explorer");
|
|
println!(" crystal クリスタル💎 - valuer, analyst");
|
|
println!(" dealer ディーラー🎰 - trader, executor");
|
|
println!(" sentinel センチネル🛡️ - risk-manager, guardian-rm");
|
|
println!(" architect アーキテクト🏗️ - portfolio-manager, allocator");
|
|
println!(" watchman ウォッチマン👁️ - news-monitor, sentinel-news");
|
|
println!(" chart チャート📈 - technical-analyst, chart-reader");
|
|
println!(" fundy ファンディ📊 - fundamental-analyst, value-investor");
|
|
println!(" scribe スクライブ📝 - reporter, documenter");
|
|
println!();
|
|
println!(" Content Society (6):");
|
|
println!(" tweeter ツイーター🐦 - twitter-specialist, x-poster");
|
|
println!(" pen ペン✒️ - writer, author");
|
|
println!(" vidpro ビッドプロ🎬 - video-producer, youtuber");
|
|
println!(" artist アーティスト🎨 - designer, visual-creator");
|
|
println!(" optimizer オプティマイザー🔧 - seo-specialist, seo-analyst");
|
|
println!(" scheduler スケジューラー📅 - calendar-manager, planner");
|
|
println!();
|
|
println!(" Marketing Society (4):");
|
|
println!(" hiro ヒロ🚀 - promoter, growth-hacker");
|
|
println!(" kazoeru カゾエル🔢 - metrics-tracker, data-analyst");
|
|
println!(" funnel ファネル🌪️ - conversion-optimizer, cro-specialist");
|
|
println!(" adops アドオプス📢 - ad-manager, media-buyer");
|
|
println!();
|
|
println!("【使用例】");
|
|
println!();
|
|
println!(" # 個別送信");
|
|
println!(" miyabi openclaw send maestro \"実装タスクを割り当てて\"");
|
|
println!(" miyabi openclaw send kade \"コードレビューお願いします\"");
|
|
println!(" miyabi openclaw send shikirun \"エイリアスでもOK\"");
|
|
println!();
|
|
println!(" # ブロードキャスト");
|
|
println!(" miyabi openclaw broadcast \"システムメンテナンス開始\"");
|
|
println!(" miyabi openclaw broadcast-society content \"新記事投稿\"");
|
|
println!();
|
|
println!("【環境変数】");
|
|
println!();
|
|
println!(
|
|
" OPENCLAW_GATEWAY_URL - Gateway URL (default: http://127.0.0.1:18789)"
|
|
);
|
|
println!(" OPENCLAW_TOKEN - Gateway認証トークン");
|
|
println!();
|
|
println!("【設定ファイル】");
|
|
println!();
|
|
println!(" ~/.openclaw/openclaw.json - 設定ファイルから自動読み込み");
|
|
println!();
|
|
println!(
|
|
"---
|
|
🌸 Miyabi Framework - OpenClaw Integration"
|
|
);
|
|
}
|
|
OpenclawCommand::Status => {
|
|
// Already handled above
|
|
unreachable!();
|
|
}
|
|
}
|
|
}
|
|
|
|
Some(Commands::Collab { command }) => {
|
|
use std::env;
|
|
use std::process::Command;
|
|
|
|
let collab_bin = {
|
|
let home = env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
|
format!("{}/.local/bin/collab", home)
|
|
};
|
|
|
|
let mut args: Vec<String> = Vec::new();
|
|
|
|
match command {
|
|
CollabCommand::List {
|
|
json,
|
|
r#type,
|
|
count,
|
|
} => {
|
|
args.push("tile".to_string());
|
|
args.push("list".to_string());
|
|
if json {
|
|
args.push("--json".to_string());
|
|
}
|
|
if count {
|
|
args.push("--count".to_string());
|
|
}
|
|
if let Some(t) = r#type {
|
|
args.push("--type".to_string());
|
|
args.push(t);
|
|
}
|
|
}
|
|
CollabCommand::Add {
|
|
tile_type,
|
|
file,
|
|
pos,
|
|
size,
|
|
idempotent,
|
|
} => {
|
|
args.push("tile".to_string());
|
|
args.push("add".to_string());
|
|
args.push(tile_type);
|
|
if let Some(f) = file {
|
|
args.push("--file".to_string());
|
|
args.push(f);
|
|
}
|
|
if let Some(p) = pos {
|
|
args.push("--pos".to_string());
|
|
args.push(p);
|
|
}
|
|
if let Some(s) = size {
|
|
args.push("--size".to_string());
|
|
args.push(s);
|
|
}
|
|
if idempotent {
|
|
args.push("--idempotent".to_string());
|
|
}
|
|
}
|
|
CollabCommand::Rm { tile_id } => {
|
|
args.push("tile".to_string());
|
|
args.push("rm".to_string());
|
|
args.push(tile_id);
|
|
}
|
|
CollabCommand::Move { tile_id, pos } => {
|
|
args.push("tile".to_string());
|
|
args.push("move".to_string());
|
|
args.push(tile_id);
|
|
args.push("--pos".to_string());
|
|
args.push(pos);
|
|
}
|
|
CollabCommand::Resize { tile_id, size } => {
|
|
args.push("tile".to_string());
|
|
args.push("resize".to_string());
|
|
args.push(tile_id);
|
|
args.push("--size".to_string());
|
|
args.push(size);
|
|
}
|
|
CollabCommand::Viewport { pan, zoom } => {
|
|
if pan.is_some() || zoom.is_some() {
|
|
args.push("viewport".to_string());
|
|
args.push("set".to_string());
|
|
if let Some(p) = pan {
|
|
args.push("--pan".to_string());
|
|
args.push(p);
|
|
}
|
|
if let Some(z) = zoom {
|
|
args.push("--zoom".to_string());
|
|
args.push(z.to_string());
|
|
}
|
|
} else {
|
|
args.push("viewport".to_string());
|
|
}
|
|
}
|
|
CollabCommand::Status => {
|
|
args.push("status".to_string());
|
|
}
|
|
}
|
|
|
|
let status = Command::new(&collab_bin).args(&args).status();
|
|
|
|
match status {
|
|
Ok(s) => {
|
|
if !s.success() {
|
|
std::process::exit(s.code().unwrap_or(1));
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!("error: failed to run collab CLI ({}): {}", collab_bin, e);
|
|
eprintln!(
|
|
" → Install collab CLI: https://github.com/ShunsukeHayashi/collab-cli"
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn handle_gate_command(
|
|
format: &OutputFormat,
|
|
store_path: &std::path::Path,
|
|
command: GateCommand,
|
|
) -> anyhow::Result<i32> {
|
|
use miyabi_core::protocol::{
|
|
DeterministicExecutionProtocol, ImpactInput, ProtocolError, RegisterTaskRequest,
|
|
StatusReport,
|
|
};
|
|
use miyabi_core::store::{CompletionMode, ImpactRiskLevel};
|
|
|
|
let protocol = DeterministicExecutionProtocol::from_store_path(store_path.to_path_buf());
|
|
let actor = "miyabi-cli";
|
|
let node = std::env::var("HOSTNAME")
|
|
.ok()
|
|
.filter(|value| !value.trim().is_empty())
|
|
.unwrap_or_else(|| "local".to_string());
|
|
|
|
let result = match command {
|
|
GateCommand::Register {
|
|
issue,
|
|
title,
|
|
task_id,
|
|
dependencies,
|
|
soft_dependencies,
|
|
priority,
|
|
completion_mode,
|
|
} => {
|
|
let task_id = task_id.unwrap_or_else(|| derive_task_id(issue, &title));
|
|
protocol
|
|
.register(
|
|
RegisterTaskRequest {
|
|
issue,
|
|
task_id,
|
|
title,
|
|
dependencies,
|
|
soft_dependencies,
|
|
priority,
|
|
completion_mode: match completion_mode {
|
|
CompletionModeArg::GithubPr => CompletionMode::GithubPr,
|
|
CompletionModeArg::Manual => CompletionMode::Manual,
|
|
CompletionModeArg::ExternalOp => CompletionMode::ExternalOp,
|
|
},
|
|
},
|
|
actor,
|
|
&node,
|
|
)
|
|
.map(|task| {
|
|
if matches!(format, OutputFormat::Json) {
|
|
println!("{}", serde_json::to_string_pretty(&task).unwrap());
|
|
} else {
|
|
println!("registered: {} ({})", task.id, task.title);
|
|
}
|
|
})
|
|
}
|
|
GateCommand::Status { task_id } => {
|
|
protocol
|
|
.status(task_id.as_deref())
|
|
.map(|status| match status {
|
|
StatusReport::Task(task) => {
|
|
if matches!(format, OutputFormat::Json) {
|
|
println!("{}", serde_json::to_string_pretty(&task).unwrap());
|
|
} else {
|
|
println!("{}: {:?} - {}", task.id, task.current_state, task.title);
|
|
}
|
|
}
|
|
StatusReport::Snapshot(snapshot) => {
|
|
if matches!(format, OutputFormat::Json) {
|
|
println!("{}", serde_json::to_string_pretty(&snapshot).unwrap());
|
|
} else {
|
|
println!("tasks: {}", snapshot.tasks.len());
|
|
for task in snapshot.tasks {
|
|
println!(" {} [{:?}] {}", task.id, task.current_state, task.title);
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
GateCommand::Assign {
|
|
task_id,
|
|
agent,
|
|
node: agent_node,
|
|
files,
|
|
} => protocol
|
|
.assign(&task_id, &agent, &agent_node, &files)
|
|
.map(|result| {
|
|
if matches!(format, OutputFormat::Json) {
|
|
println!("{}", serde_json::to_string_pretty(&result).unwrap());
|
|
} else {
|
|
println!("assigned: {} -> {}@{}", result.task.id, agent, agent_node);
|
|
}
|
|
}),
|
|
GateCommand::Impact {
|
|
task_id,
|
|
risk,
|
|
approve,
|
|
symbols,
|
|
depth1,
|
|
analyzed_commit,
|
|
input_hash,
|
|
} => protocol
|
|
.record_impact(
|
|
&task_id,
|
|
ImpactInput {
|
|
risk_level: match risk {
|
|
ImpactRiskArg::Low => ImpactRiskLevel::Low,
|
|
ImpactRiskArg::Medium => ImpactRiskLevel::Medium,
|
|
ImpactRiskArg::High => ImpactRiskLevel::High,
|
|
ImpactRiskArg::Critical => ImpactRiskLevel::Critical,
|
|
},
|
|
affected_symbols: symbols,
|
|
depth1,
|
|
analyzed_commit,
|
|
input_hash,
|
|
approve,
|
|
},
|
|
actor,
|
|
&node,
|
|
)
|
|
.map(|task| {
|
|
if matches!(format, OutputFormat::Json) {
|
|
println!("{}", serde_json::to_string_pretty(&task).unwrap());
|
|
} else {
|
|
println!("impact recorded: {}", task.id);
|
|
}
|
|
}),
|
|
GateCommand::Branch { task_id, name } => protocol
|
|
.record_branch(&task_id, &name, actor, &node)
|
|
.map(|task| {
|
|
if matches!(format, OutputFormat::Json) {
|
|
println!("{}", serde_json::to_string_pretty(&task).unwrap());
|
|
} else {
|
|
println!("branch recorded: {} -> {}", task.id, name);
|
|
}
|
|
}),
|
|
GateCommand::Attach { task_id } => {
|
|
protocol
|
|
.attach_context(&task_id, actor, &node)
|
|
.map(|attachments| {
|
|
if matches!(format, OutputFormat::Json) {
|
|
println!("{}", serde_json::to_string_pretty(&attachments).unwrap());
|
|
} else if attachments.is_empty() {
|
|
println!("no context attachments: {}", task_id);
|
|
} else {
|
|
println!("context attachments: {}", task_id);
|
|
if std::env::var_os("OBSIDIAN_VAULT_PATH").is_some() {
|
|
println!("obsidian search: enabled via OBSIDIAN_VAULT_PATH");
|
|
}
|
|
for attachment in attachments {
|
|
println!(
|
|
"--- [{}] {} ({} tokens)",
|
|
attachment.attachment_type,
|
|
attachment.source,
|
|
attachment.token_estimate
|
|
);
|
|
println!("{}", attachment.content);
|
|
}
|
|
}
|
|
})
|
|
}
|
|
GateCommand::Pr { task_id, number } => protocol
|
|
.record_pr(&task_id, number, actor, &node)
|
|
.map(|task| {
|
|
if matches!(format, OutputFormat::Json) {
|
|
println!("{}", serde_json::to_string_pretty(&task).unwrap());
|
|
} else {
|
|
println!("pr recorded: {} -> #{}", task.id, number);
|
|
}
|
|
}),
|
|
GateCommand::Merge { task_id, sha } => protocol
|
|
.record_merge(&task_id, &sha, actor, &node)
|
|
.map(|task| {
|
|
if matches!(format, OutputFormat::Json) {
|
|
println!("{}", serde_json::to_string_pretty(&task).unwrap());
|
|
} else {
|
|
println!("merge recorded: {} -> {}", task.id, sha);
|
|
}
|
|
}),
|
|
GateCommand::Locks => protocol.locks().map(|locks| {
|
|
if matches!(format, OutputFormat::Json) {
|
|
println!("{}", serde_json::to_string_pretty(&locks).unwrap());
|
|
} else if locks.is_empty() {
|
|
println!("no active locks");
|
|
} else {
|
|
for (file, lock) in locks {
|
|
println!("{} -> {}@{}", file, lock.agent, lock.node);
|
|
}
|
|
}
|
|
}),
|
|
GateCommand::Dag => protocol.dag().map(|report| {
|
|
if matches!(format, OutputFormat::Json) {
|
|
println!("{}", serde_json::to_string_pretty(&report).unwrap());
|
|
} else {
|
|
for (index, level) in report.levels.iter().enumerate() {
|
|
println!("level {}: {}", index, level.join(", "));
|
|
}
|
|
}
|
|
}),
|
|
GateCommand::Dispatchable => protocol.dispatchable().map(|report| {
|
|
if matches!(format, OutputFormat::Json) {
|
|
println!("{}", serde_json::to_string_pretty(&report).unwrap());
|
|
} else if report.tasks.is_empty() {
|
|
println!("no dispatchable tasks");
|
|
} else {
|
|
for task in report.tasks {
|
|
println!("{} [{}] {}", task.id, task.priority, task.title);
|
|
}
|
|
}
|
|
}),
|
|
GateCommand::Serve { port } => {
|
|
serve_dashboard(store_path, port)?;
|
|
Ok(())
|
|
}
|
|
GateCommand::Dream {
|
|
since,
|
|
vault_path,
|
|
auto,
|
|
} => {
|
|
let since = since
|
|
.as_deref()
|
|
.map(parse_gate_since)
|
|
.transpose()
|
|
.map_err(|error: anyhow::Error| ProtocolError::input(error.to_string()))?;
|
|
let repo_root =
|
|
std::env::current_dir().map_err(|error| ProtocolError::input(error.to_string()))?;
|
|
protocol
|
|
.dream(since, auto, &repo_root, actor, &node)
|
|
.and_then(|report| {
|
|
if matches!(format, OutputFormat::Json) {
|
|
println!("{}", serde_json::to_string_pretty(&report).unwrap());
|
|
} else {
|
|
print_dream_report(&report);
|
|
}
|
|
|
|
if auto {
|
|
let obsidian_written: Vec<PathBuf> = report
|
|
.learnings
|
|
.iter()
|
|
.filter(|learning| learning.importance == miyabi_core::Importance::High)
|
|
.map(|learning| {
|
|
miyabi_core::dream::obsidian_export(learning, vault_path.as_deref())
|
|
})
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(ProtocolError::Internal)?;
|
|
if !matches!(format, OutputFormat::Json) {
|
|
if obsidian_written.is_empty() {
|
|
println!("obsidian notes written: none");
|
|
} else {
|
|
println!("obsidian notes written: {}", obsidian_written.len());
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
})
|
|
}
|
|
};
|
|
|
|
Ok(match result {
|
|
Ok(()) => 0,
|
|
Err(ProtocolError::GateRejected(message)) => {
|
|
emit_gate_error(format, "gate_rejected", &message);
|
|
1
|
|
}
|
|
Err(ProtocolError::DependencyBlocked(message)) => {
|
|
emit_gate_error(format, "gate_rejected", &message);
|
|
1
|
|
}
|
|
Err(ProtocolError::Input(message)) => {
|
|
emit_gate_error(format, "input_error", &message);
|
|
2
|
|
}
|
|
Err(ProtocolError::Internal(error)) => {
|
|
emit_gate_error(format, "internal_error", &error.to_string());
|
|
1
|
|
}
|
|
})
|
|
}
|
|
|
|
fn parse_gate_since(input: &str) -> anyhow::Result<ChronoDuration> {
|
|
let trimmed = input.trim();
|
|
if trimmed.len() < 2 {
|
|
return Err(anyhow::anyhow!("invalid --since value: {trimmed}"));
|
|
}
|
|
|
|
let (number, unit) = trimmed.split_at(trimmed.len() - 1);
|
|
let value: i64 = number
|
|
.parse()
|
|
.map_err(|_| anyhow::anyhow!("invalid --since value: {trimmed}"))?;
|
|
|
|
match unit {
|
|
"s" => Ok(ChronoDuration::seconds(value)),
|
|
"m" => Ok(ChronoDuration::minutes(value)),
|
|
"h" => Ok(ChronoDuration::hours(value)),
|
|
"d" => Ok(ChronoDuration::days(value)),
|
|
_ => Err(anyhow::anyhow!(
|
|
"unsupported --since unit: {unit} (use s, m, h, d)"
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn print_dream_report(report: &miyabi_core::DreamReport) {
|
|
println!("events processed: {}", report.events_processed);
|
|
|
|
if report.patterns.gate_rejections.is_empty() {
|
|
println!("gate rejections: none");
|
|
} else {
|
|
println!("gate rejections:");
|
|
let mut gates: Vec<_> = report.patterns.gate_rejections.iter().collect();
|
|
gates.sort_by(|left, right| left.0.cmp(right.0));
|
|
for (gate, count) in gates {
|
|
println!(" {} -> {}", gate, count);
|
|
}
|
|
}
|
|
|
|
if report.patterns.lock_conflicts.is_empty() {
|
|
println!("lock conflicts: none");
|
|
} else {
|
|
println!("lock conflicts:");
|
|
let mut files: Vec<_> = report.patterns.lock_conflicts.iter().collect();
|
|
files.sort_by(|left, right| left.0.cmp(right.0));
|
|
for (file, count) in files {
|
|
println!(" {} -> {}", file, count);
|
|
}
|
|
}
|
|
|
|
if report.patterns.completion_times.is_empty() {
|
|
println!("completion times: none");
|
|
} else {
|
|
println!("completion times:");
|
|
for (task_id, duration) in &report.patterns.completion_times {
|
|
println!(" {} -> {}s", task_id, duration.as_secs());
|
|
}
|
|
}
|
|
|
|
if report.learnings.is_empty() {
|
|
println!("learnings: none");
|
|
} else {
|
|
println!("learnings:");
|
|
for learning in &report.learnings {
|
|
println!(
|
|
" [{:?}] {}{}",
|
|
learning.importance,
|
|
learning.title,
|
|
learning
|
|
.related_task
|
|
.as_deref()
|
|
.map(|task| format!(" ({task})"))
|
|
.unwrap_or_default()
|
|
);
|
|
println!(" {}", learning.content);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn emit_gate_error(format: &OutputFormat, kind: &str, message: &str) {
|
|
if matches!(format, OutputFormat::Json) {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&serde_json::json!({
|
|
"error": kind,
|
|
"message": message,
|
|
}))
|
|
.unwrap()
|
|
);
|
|
} else {
|
|
eprintln!("{}: {}", kind, message);
|
|
}
|
|
}
|
|
|
|
const POLARIS_DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Polaris Dashboard</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: light;
|
|
--bg: #f4f7fb;
|
|
--panel: #ffffff;
|
|
--text: #162033;
|
|
--muted: #667085;
|
|
--border: #d6dfeb;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
color: var(--text);
|
|
background: linear-gradient(180deg, #eef4ff 0%, #f8fafc 55%, #f4f7fb 100%);
|
|
}
|
|
.shell {
|
|
max-width: 1120px;
|
|
margin: 0 auto;
|
|
padding: 16px;
|
|
}
|
|
header, .panel {
|
|
background: rgba(255, 255, 255, 0.9);
|
|
border: 1px solid var(--border);
|
|
border-radius: 18px;
|
|
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.06);
|
|
}
|
|
header {
|
|
padding: 20px;
|
|
margin-bottom: 16px;
|
|
}
|
|
h1 {
|
|
margin: 0 0 8px;
|
|
font-size: clamp(1.6rem, 3vw, 2.4rem);
|
|
}
|
|
h2 {
|
|
margin: 0 0 12px;
|
|
font-size: 1.05rem;
|
|
}
|
|
.subtitle, .meta {
|
|
margin: 0;
|
|
color: var(--muted);
|
|
}
|
|
.grid {
|
|
display: grid;
|
|
gap: 16px;
|
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
}
|
|
.panel {
|
|
padding: 16px;
|
|
}
|
|
.task-list, .dag-list, .lock-list {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: grid;
|
|
gap: 10px;
|
|
}
|
|
.task-item, .dag-item, .lock-item {
|
|
padding: 12px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 14px;
|
|
background: #fbfdff;
|
|
}
|
|
.task-top {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 6px;
|
|
}
|
|
.task-title {
|
|
font-weight: 600;
|
|
word-break: break-word;
|
|
}
|
|
.task-meta, .dag-meta, .lock-meta {
|
|
font-size: 0.9rem;
|
|
color: var(--muted);
|
|
}
|
|
.badge {
|
|
display: inline-flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-width: 92px;
|
|
padding: 4px 10px;
|
|
border-radius: 999px;
|
|
color: #ffffff;
|
|
font-size: 0.82rem;
|
|
font-weight: 700;
|
|
text-transform: lowercase;
|
|
}
|
|
.empty {
|
|
color: var(--muted);
|
|
font-style: italic;
|
|
}
|
|
@media (max-width: 640px) {
|
|
.shell { padding: 12px; }
|
|
header, .panel { border-radius: 14px; }
|
|
.task-top { align-items: flex-start; flex-direction: column; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="shell">
|
|
<header>
|
|
<h1>Polaris Dashboard</h1>
|
|
<p class="subtitle">Deterministic Task Protocol live view</p>
|
|
<p class="meta" id="meta">Loading...</p>
|
|
</header>
|
|
<section class="grid">
|
|
<article class="panel">
|
|
<h2>Tasks</h2>
|
|
<ul id="tasks" class="task-list"><li class="empty">Loading tasks...</li></ul>
|
|
</article>
|
|
<article class="panel">
|
|
<h2>DAG Levels</h2>
|
|
<ul id="dag" class="dag-list"><li class="empty">Loading DAG...</li></ul>
|
|
</article>
|
|
<article class="panel">
|
|
<h2>File Locks</h2>
|
|
<ul id="locks" class="lock-list"><li class="empty">Loading locks...</li></ul>
|
|
</article>
|
|
</section>
|
|
</div>
|
|
<script>
|
|
const stateColors = {
|
|
pending: "#6b7280",
|
|
implementing: "#2563eb",
|
|
merged: "#15803d",
|
|
done: "#15803d",
|
|
blocked: "#dc2626"
|
|
};
|
|
|
|
function setEmpty(id, message) {
|
|
document.getElementById(id).innerHTML = '<li class="empty">' + message + '</li>';
|
|
}
|
|
|
|
function colorForState(state) {
|
|
return stateColors[state] || "#7c3aed";
|
|
}
|
|
|
|
function renderTasks(snapshot) {
|
|
const tasks = Array.isArray(snapshot.tasks) ? snapshot.tasks : [];
|
|
const el = document.getElementById("tasks");
|
|
if (tasks.length === 0) {
|
|
setEmpty("tasks", "No tasks in snapshot");
|
|
return;
|
|
}
|
|
|
|
el.innerHTML = "";
|
|
for (const task of tasks) {
|
|
const item = document.createElement("li");
|
|
item.className = "task-item";
|
|
|
|
const top = document.createElement("div");
|
|
top.className = "task-top";
|
|
|
|
const title = document.createElement("div");
|
|
title.className = "task-title";
|
|
title.textContent = task.title + " (" + task.id + ")";
|
|
|
|
const badge = document.createElement("span");
|
|
badge.className = "badge";
|
|
badge.style.background = colorForState(task.current_state);
|
|
badge.textContent = task.current_state;
|
|
|
|
top.appendChild(title);
|
|
top.appendChild(badge);
|
|
|
|
const meta = document.createElement("div");
|
|
meta.className = "task-meta";
|
|
const deps = Array.isArray(task.dependencies) && task.dependencies.length > 0
|
|
? task.dependencies.join(", ")
|
|
: "none";
|
|
meta.textContent = "priority " + task.priority + " | deps: " + deps;
|
|
|
|
item.appendChild(top);
|
|
item.appendChild(meta);
|
|
el.appendChild(item);
|
|
}
|
|
}
|
|
|
|
function renderDag(report) {
|
|
const levels = Array.isArray(report.levels) ? report.levels : [];
|
|
const el = document.getElementById("dag");
|
|
if (levels.length === 0) {
|
|
setEmpty("dag", "No DAG levels available");
|
|
return;
|
|
}
|
|
|
|
el.innerHTML = "";
|
|
levels.forEach((level, index) => {
|
|
const item = document.createElement("li");
|
|
item.className = "dag-item";
|
|
|
|
const title = document.createElement("div");
|
|
title.className = "task-title";
|
|
title.textContent = "Level " + index;
|
|
|
|
const meta = document.createElement("div");
|
|
meta.className = "dag-meta";
|
|
meta.textContent = Array.isArray(level) && level.length > 0 ? level.join(", ") : "empty";
|
|
|
|
item.appendChild(title);
|
|
item.appendChild(meta);
|
|
el.appendChild(item);
|
|
});
|
|
}
|
|
|
|
function renderLocks(locks) {
|
|
const entries = Object.entries(locks || {});
|
|
const el = document.getElementById("locks");
|
|
if (entries.length === 0) {
|
|
setEmpty("locks", "No active file locks");
|
|
return;
|
|
}
|
|
|
|
el.innerHTML = "";
|
|
for (const [file, lock] of entries) {
|
|
const item = document.createElement("li");
|
|
item.className = "lock-item";
|
|
|
|
const title = document.createElement("div");
|
|
title.className = "task-title";
|
|
title.textContent = file;
|
|
|
|
const meta = document.createElement("div");
|
|
meta.className = "lock-meta";
|
|
meta.textContent = lock.agent + "@" + lock.node + " | task: " + lock.task_id;
|
|
|
|
item.appendChild(title);
|
|
item.appendChild(meta);
|
|
el.appendChild(item);
|
|
}
|
|
}
|
|
|
|
async function refresh() {
|
|
try {
|
|
const [statusRes, locksRes, dagRes] = await Promise.all([
|
|
fetch("/api/status"),
|
|
fetch("/api/locks"),
|
|
fetch("/api/dag")
|
|
]);
|
|
|
|
if (!statusRes.ok || !locksRes.ok || !dagRes.ok) {
|
|
throw new Error("API request failed");
|
|
}
|
|
|
|
const [status, locks, dag] = await Promise.all([
|
|
statusRes.json(),
|
|
locksRes.json(),
|
|
dagRes.json()
|
|
]);
|
|
|
|
renderTasks(status);
|
|
renderLocks(locks);
|
|
renderDag(dag);
|
|
|
|
document.getElementById("meta").textContent =
|
|
"Snapshot version " + status.version + " | updated " + status.generated_at +
|
|
" | auto-refresh every 3s";
|
|
} catch (error) {
|
|
document.getElementById("meta").textContent = "Refresh failed: " + error.message;
|
|
setEmpty("tasks", "Failed to load tasks");
|
|
setEmpty("dag", "Failed to load DAG");
|
|
setEmpty("locks", "Failed to load locks");
|
|
}
|
|
}
|
|
|
|
refresh();
|
|
setInterval(refresh, 3000);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"##;
|
|
|
|
fn serve_dashboard(store_path: &std::path::Path, port: u16) -> anyhow::Result<()> {
|
|
let protocol = miyabi_core::protocol::DeterministicExecutionProtocol::from_store_path(
|
|
store_path.to_path_buf(),
|
|
);
|
|
let listener = TcpListener::bind(("127.0.0.1", port))?;
|
|
println!("Polaris Dashboard listening on http://127.0.0.1:{port}");
|
|
|
|
for stream in listener.incoming() {
|
|
match stream {
|
|
Ok(mut stream) => {
|
|
if let Err(error) = handle_dashboard_connection(&protocol, &mut stream) {
|
|
eprintln!("dashboard request error: {error}");
|
|
}
|
|
}
|
|
Err(error) => eprintln!("dashboard accept error: {error}"),
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn handle_dashboard_connection(
|
|
protocol: &miyabi_core::protocol::DeterministicExecutionProtocol,
|
|
stream: &mut TcpStream,
|
|
) -> anyhow::Result<()> {
|
|
let mut request_line = String::new();
|
|
let mut reader = BufReader::new(stream.try_clone()?);
|
|
reader.read_line(&mut request_line)?;
|
|
|
|
let mut parts = request_line.split_whitespace();
|
|
let method = parts.next().unwrap_or_default();
|
|
let path = parts.next().unwrap_or("/");
|
|
|
|
if method != "GET" {
|
|
write_http_response(
|
|
stream,
|
|
"405 Method Not Allowed",
|
|
"text/plain; charset=utf-8",
|
|
b"method not allowed",
|
|
)?;
|
|
return Ok(());
|
|
}
|
|
|
|
match path {
|
|
"/" => write_http_response(
|
|
stream,
|
|
"200 OK",
|
|
"text/html; charset=utf-8",
|
|
POLARIS_DASHBOARD_HTML.as_bytes(),
|
|
)?,
|
|
"/api/status" => {
|
|
let body =
|
|
serde_json::to_vec_pretty(&protocol.status(None)?).map_err(io::Error::other)?;
|
|
write_http_response(stream, "200 OK", "application/json; charset=utf-8", &body)?;
|
|
}
|
|
"/api/locks" => {
|
|
let body = serde_json::to_vec_pretty(&protocol.locks()?).map_err(io::Error::other)?;
|
|
write_http_response(stream, "200 OK", "application/json; charset=utf-8", &body)?;
|
|
}
|
|
"/api/dag" => {
|
|
let body = serde_json::to_vec_pretty(&protocol.dag()?).map_err(io::Error::other)?;
|
|
write_http_response(stream, "200 OK", "application/json; charset=utf-8", &body)?;
|
|
}
|
|
_ => write_http_response(
|
|
stream,
|
|
"404 Not Found",
|
|
"text/plain; charset=utf-8",
|
|
b"not found",
|
|
)?,
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn write_http_response(
|
|
stream: &mut TcpStream,
|
|
status: &str,
|
|
content_type: &str,
|
|
body: &[u8],
|
|
) -> io::Result<()> {
|
|
write!(
|
|
stream,
|
|
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
|
body.len()
|
|
)?;
|
|
stream.write_all(body)?;
|
|
stream.flush()
|
|
}
|
|
|
|
fn derive_task_id(issue: u64, title: &str) -> String {
|
|
if issue > 0 {
|
|
return format!("issue-{issue}");
|
|
}
|
|
|
|
let slug: String = title
|
|
.chars()
|
|
.map(|ch| {
|
|
if ch.is_ascii_alphanumeric() {
|
|
ch.to_ascii_lowercase()
|
|
} else {
|
|
'-'
|
|
}
|
|
})
|
|
.collect();
|
|
let slug = slug
|
|
.split('-')
|
|
.filter(|part| !part.is_empty())
|
|
.collect::<Vec<_>>()
|
|
.join("-");
|
|
|
|
if slug.is_empty() {
|
|
"task".to_string()
|
|
} else {
|
|
slug
|
|
}
|
|
}
|
|
|
|
fn truncate_str(s: &str, max_len: usize) -> String {
|
|
if s.len() > max_len {
|
|
format!("{}...", &s[..max_len.saturating_sub(3)])
|
|
} else {
|
|
s.to_string()
|
|
}
|
|
}
|