Skip to main content

lit/commands/
intent.rs

1//! Intent-based workflow for agentic development
2//!
3//! Intents replace the branch+PR model with declared units of work that have
4//! explicit scope, agent attribution, priority, and hierarchical decomposition.
5//! Multiple intents can be active simultaneously on the same working tree
6//! because they declare non-overlapping scopes.
7
8use crate::core::find_repo_root;
9use crate::errors::LitError;
10use crate::response::IntentResponse;
11use serde::{Deserialize, Serialize};
12use std::fs;
13use std::path::{Path, PathBuf};
14
15// ── Data types ──────────────────────────────────────────────────────────────
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18pub enum IntentStatus {
19    Active,
20    Converged,
21    Abandoned,
22}
23
24impl std::fmt::Display for IntentStatus {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        match self {
27            IntentStatus::Active => write!(f, "active"),
28            IntentStatus::Converged => write!(f, "converged"),
29            IntentStatus::Abandoned => write!(f, "abandoned"),
30        }
31    }
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35pub enum IntentPriority {
36    Low,
37    Medium,
38    High,
39    Critical,
40}
41
42impl std::fmt::Display for IntentPriority {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            IntentPriority::Low => write!(f, "low"),
46            IntentPriority::Medium => write!(f, "medium"),
47            IntentPriority::High => write!(f, "high"),
48            IntentPriority::Critical => write!(f, "critical"),
49        }
50    }
51}
52
53/// A scope-checked conflict between two active intents
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct ScopeConflict {
56    pub intent_id: String,
57    pub agent: String,
58    pub overlapping_paths: Vec<String>,
59}
60
61/// A declared unit of work with scope, agent, and hierarchy
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct Intent {
64    pub id: String,
65    pub title: String,
66    pub agent: String,
67    pub scope: Vec<String>,
68    pub priority: IntentPriority,
69    pub status: IntentStatus,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub parent: Option<String>,
72    #[serde(default)]
73    pub commits: Vec<String>,
74    #[serde(default)]
75    pub children: Vec<String>,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub ucan_proof: Option<String>,
78    pub created: String,
79    pub updated: String,
80}
81
82// ── Helpers ─────────────────────────────────────────────────────────────────
83
84fn intents_dir(repo_root: &Path) -> PathBuf {
85    repo_root.join(".lit").join("intents")
86}
87
88fn save_intent(repo_root: &Path, intent: &Intent) -> Result<(), LitError> {
89    let dir = intents_dir(repo_root);
90    fs::create_dir_all(&dir).map_err(|e| LitError::io(format!("Create intents dir: {}", e)))?;
91    let path = dir.join(format!("{}.json", intent.id));
92    let json = serde_json::to_string_pretty(intent)
93        .map_err(|e| LitError::general(format!("Serialize intent: {}", e)))?;
94    fs::write(&path, json).map_err(|e| LitError::io(format!("Write intent: {}", e)))?;
95    Ok(())
96}
97
98pub fn load_intent(repo_root: &Path, id: &str) -> Result<Intent, LitError> {
99    let path = intents_dir(repo_root).join(format!("{}.json", id));
100    if !path.exists() {
101        return Err(LitError::general(format!("Intent not found: {}", id)));
102    }
103    let json = fs::read_to_string(&path).map_err(|e| LitError::io(format!("Read: {}", e)))?;
104    serde_json::from_str(&json).map_err(|e| LitError::general(format!("Parse intent: {}", e)))
105}
106
107fn load_all_intents(repo_root: &Path) -> Result<Vec<Intent>, LitError> {
108    let dir = intents_dir(repo_root);
109    if !dir.exists() {
110        return Ok(Vec::new());
111    }
112    let mut intents = Vec::new();
113    for entry in fs::read_dir(&dir).map_err(|e| LitError::io(e.to_string()))? {
114        let entry = entry.map_err(|e| LitError::io(e.to_string()))?;
115        if entry
116            .path()
117            .extension()
118            .map(|e| e == "json")
119            .unwrap_or(false)
120        {
121            let json = fs::read_to_string(entry.path()).map_err(|e| LitError::io(e.to_string()))?;
122            if let Ok(intent) = serde_json::from_str::<Intent>(&json) {
123                intents.push(intent);
124            }
125        }
126    }
127    Ok(intents)
128}
129
130/// Check whether two scope patterns overlap.
131/// Patterns use simple prefix/glob matching: `src/auth/**` overlaps with `src/auth/jwt.rs`.
132fn scopes_overlap(a: &[String], b: &[String]) -> Vec<String> {
133    let mut overlaps = Vec::new();
134    for pa in a {
135        let pa_base = pa.trim_end_matches("/**").trim_end_matches("/*");
136        for pb in b {
137            let pb_base = pb.trim_end_matches("/**").trim_end_matches("/*");
138            // Overlap if one is a prefix of the other, or they are the same
139            if pa_base.starts_with(pb_base) || pb_base.starts_with(pa_base) || pa == pb {
140                overlaps.push(format!("{} <-> {}", pa, pb));
141            }
142        }
143    }
144    overlaps
145}
146
147/// Auto-acquire swarm leases for intent scope paths
148fn auto_acquire_leases(repo_root: &Path, agent: &str, scope: &[String]) {
149    let leases_dir = repo_root.join(".lit").join("swarm").join("leases");
150    let _ = fs::create_dir_all(&leases_dir);
151    let now = chrono::Utc::now().timestamp();
152    let duration = 3600i64; // 1 hour default
153    for path in scope {
154        let sanitized: String = path
155            .chars()
156            .map(|c| {
157                if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
158                    c
159                } else {
160                    '_'
161                }
162            })
163            .collect();
164        let lease_file = leases_dir.join(format!("{}.json", sanitized));
165        // Only acquire if not already leased by another agent
166        if lease_file.exists() {
167            if let Ok(data) = fs::read_to_string(&lease_file) {
168                if let Ok(existing) = serde_json::from_str::<serde_json::Value>(&data) {
169                    if existing.get("agent_id").and_then(|v| v.as_str()) != Some(agent)
170                        && existing
171                            .get("expires_at")
172                            .and_then(|v| v.as_i64())
173                            .unwrap_or(0)
174                            > now
175                    {
176                        continue; // Already leased by someone else
177                    }
178                }
179            }
180        }
181        let lease = serde_json::json!({
182            "agent_id": agent,
183            "path": path,
184            "acquired_at": now,
185            "expires_at": now + duration,
186        });
187        let _ = fs::write(
188            &lease_file,
189            serde_json::to_string_pretty(&lease).unwrap_or_default(),
190        );
191    }
192}
193
194/// Release swarm leases held by an agent for given scope paths
195fn auto_release_leases(repo_root: &Path, agent: &str, scope: &[String]) {
196    let leases_dir = repo_root.join(".lit").join("swarm").join("leases");
197    if !leases_dir.exists() {
198        return;
199    }
200    for path in scope {
201        let sanitized: String = path
202            .chars()
203            .map(|c| {
204                if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
205                    c
206                } else {
207                    '_'
208                }
209            })
210            .collect();
211        let lease_file = leases_dir.join(format!("{}.json", sanitized));
212        if lease_file.exists() {
213            if let Ok(data) = fs::read_to_string(&lease_file) {
214                if let Ok(existing) = serde_json::from_str::<serde_json::Value>(&data) {
215                    if existing.get("agent_id").and_then(|v| v.as_str()) == Some(agent) {
216                        let _ = fs::remove_file(&lease_file);
217                    }
218                }
219            }
220        }
221    }
222}
223
224// ── Public API ──────────────────────────────────────────────────────────────
225
226/// Create a new intent — checks for scope conflicts with other active intents
227pub fn execute_create(
228    title: String,
229    agent: String,
230    scope: Vec<String>,
231    priority: IntentPriority,
232    parent: Option<String>,
233    ucan_proof: Option<String>,
234) -> Result<IntentResponse, LitError> {
235    let repo_root = find_repo_root()?;
236    let now = chrono::Utc::now().to_rfc3339();
237    let id = format!("intent-{}", chrono::Utc::now().timestamp_millis());
238
239    // Check scope conflicts with other active intents
240    let all = load_all_intents(&repo_root)?;
241    let mut conflicts = Vec::new();
242    for existing in &all {
243        if existing.status != IntentStatus::Active {
244            continue;
245        }
246        let overlaps = scopes_overlap(&scope, &existing.scope);
247        if !overlaps.is_empty() {
248            conflicts.push(ScopeConflict {
249                intent_id: existing.id.clone(),
250                agent: existing.agent.clone(),
251                overlapping_paths: overlaps,
252            });
253        }
254    }
255
256    // If there's a parent intent, register this as a child
257    if let Some(ref parent_id) = parent {
258        let mut parent_intent = load_intent(&repo_root, parent_id)?;
259        parent_intent.children.push(id.clone());
260        parent_intent.updated = now.clone();
261        save_intent(&repo_root, &parent_intent)?;
262    }
263
264    let intent = Intent {
265        id: id.clone(),
266        title: title.clone(),
267        agent: agent.clone(),
268        scope: scope.clone(),
269        priority,
270        status: IntentStatus::Active,
271        parent,
272        commits: Vec::new(),
273        children: Vec::new(),
274        ucan_proof,
275        created: now.clone(),
276        updated: now,
277    };
278
279    save_intent(&repo_root, &intent)?;
280
281    // Auto-acquire leases for the scoped paths
282    auto_acquire_leases(&repo_root, &agent, &scope);
283
284    let has_conflicts = !conflicts.is_empty();
285    let details = serde_json::json!({
286        "intent": intent,
287        "conflicts": conflicts,
288    });
289
290    Ok(IntentResponse {
291        action: "create".into(),
292        intent_id: Some(id),
293        message: if has_conflicts {
294            format!(
295                "Intent '{}' created with {} scope conflict(s) — lease negotiation may be required",
296                title,
297                conflicts.len()
298            )
299        } else {
300            format!("Intent '{}' created — scope clear, leases acquired", title)
301        },
302        details: Some(details),
303    })
304}
305
306/// List intents, optionally filtered by status or agent
307pub fn execute_list(
308    status_filter: Option<String>,
309    agent_filter: Option<String>,
310) -> Result<IntentResponse, LitError> {
311    let repo_root = find_repo_root()?;
312    let mut intents = load_all_intents(&repo_root)?;
313
314    if let Some(ref status) = status_filter {
315        intents.retain(|i| i.status.to_string() == *status);
316    }
317    if let Some(ref agent) = agent_filter {
318        intents.retain(|i| i.agent == *agent);
319    }
320
321    let count = intents.len();
322    Ok(IntentResponse {
323        action: "list".into(),
324        intent_id: None,
325        message: format!("{} intent(s)", count),
326        details: Some(serde_json::to_value(&intents).unwrap_or_default()),
327    })
328}
329
330/// Show details of a specific intent
331pub fn execute_show(intent_id: String) -> Result<IntentResponse, LitError> {
332    let repo_root = find_repo_root()?;
333    let intent = load_intent(&repo_root, &intent_id)?;
334
335    Ok(IntentResponse {
336        action: "show".into(),
337        intent_id: Some(intent.id.clone()),
338        message: format!(
339            "{} [{}] — {} commit(s), {} child(ren)",
340            intent.title,
341            intent.status,
342            intent.commits.len(),
343            intent.children.len()
344        ),
345        details: Some(serde_json::to_value(&intent).unwrap_or_default()),
346    })
347}
348
349/// Close (abandon) an intent — releases leases
350pub fn execute_close(intent_id: String) -> Result<IntentResponse, LitError> {
351    let repo_root = find_repo_root()?;
352    let mut intent = load_intent(&repo_root, &intent_id)?;
353
354    if intent.status != IntentStatus::Active {
355        return Err(LitError::general(format!(
356            "Intent {} is already {}",
357            intent_id, intent.status
358        )));
359    }
360
361    intent.status = IntentStatus::Abandoned;
362    intent.updated = chrono::Utc::now().to_rfc3339();
363    save_intent(&repo_root, &intent)?;
364
365    // Release leases
366    auto_release_leases(&repo_root, &intent.agent, &intent.scope);
367
368    Ok(IntentResponse {
369        action: "close".into(),
370        intent_id: Some(intent_id),
371        message: format!("Intent '{}' abandoned, leases released", intent.title),
372        details: None,
373    })
374}
375
376/// Attach a commit hash to an intent (called by `lit commit --intent`)
377pub fn attach_commit(repo_root: &Path, intent_id: &str, commit_hash: &str) -> Result<(), LitError> {
378    let mut intent = load_intent(repo_root, intent_id)?;
379    if intent.status != IntentStatus::Active {
380        return Err(LitError::general(format!(
381            "Cannot commit to intent {} — status is {}",
382            intent_id, intent.status
383        )));
384    }
385    intent.commits.push(commit_hash.to_string());
386    intent.updated = chrono::Utc::now().to_rfc3339();
387    save_intent(repo_root, &intent)?;
388    Ok(())
389}
390
391/// Mark an intent as converged (called by `lit converge`)
392pub fn mark_converged(repo_root: &Path, intent_id: &str) -> Result<Intent, LitError> {
393    let mut intent = load_intent(repo_root, intent_id)?;
394    intent.status = IntentStatus::Converged;
395    intent.updated = chrono::Utc::now().to_rfc3339();
396    save_intent(repo_root, &intent)?;
397
398    // Release leases
399    auto_release_leases(repo_root, &intent.agent, &intent.scope);
400
401    Ok(intent)
402}