Skip to main content

git_internal/internal/object/
intent.rs

1//! AI Intent Definition
2//!
3//! An [`Intent`] is the **entry point** of every AI-assisted workflow — it
4//! captures the raw user request (`prompt`) and the AI's structured
5//! interpretation of that request (`content`). The Intent is the first
6//! object created (step ① → ②) and the last one completed (step ⑩) in
7//! the end-to-end flow described in [`mod.rs`](super).
8//!
9//! # Position in Lifecycle
10//!
11//! ```text
12//!  ①  User input (natural-language request)
13//!       │
14//!       ▼
15//!  ②  Intent (Draft)        ← prompt recorded, content = None
16//!       │  AI analysis
17//!       ▼
18//!      Intent (Active)       ← content filled, plan linked
19//!       │
20//!       ├──▶ ContextPipeline ← seeded with IntentAnalysis frame
21//!       │
22//!       ▼
23//!  ③  Plan (derived from content)
24//!       │
25//!       ▼
26//!  ④–⑨ Task → Run → PatchSet → Evidence → Decision
27//!       │
28//!       ▼
29//!  ⑩  Intent (Completed)    ← commit recorded
30//! ```
31//!
32//! ## Conversational Refinement
33//!
34//! ```text
35//!  Intent₀ ("Add pagination")
36//!     ▲
37//!     │ parent
38//!  Intent₁ ("Also add cursor-based pagination")
39//!     ▲
40//!     │ parent
41//!  Intent₂ ("Use opaque cursors, not offsets")
42//! ```
43//!
44//! Each follow-up Intent links to its predecessor via `parent`,
45//! forming a singly-linked list from newest to oldest. This
46//! preserves the full conversational history without mutating
47//! earlier Intents.
48//!
49//! ## Status Transitions
50//!
51//! ```text
52//!  Draft ──▶ Active ──▶ Completed
53//!    │          │
54//!    └──────────┴──▶ Cancelled
55//! ```
56//!
57//! Status changes are **append-only**: each transition pushes a
58//! [`StatusEntry`] onto the `statuses` vector. The current status
59//! is always the last entry. This design preserves the full
60//! transition history with timestamps and optional reasons.
61//!
62//! # Purpose
63//!
64//! - **Traceability**: Links the original human request to all
65//!   downstream artifacts (Plan, Tasks, Runs, PatchSets). Reviewers
66//!   can trace any code change back to the Intent that motivated it.
67//! - **Reproducibility**: Stores both the verbatim prompt and the
68//!   AI's interpretation, allowing re-analysis with different models
69//!   or parameters.
70//! - **Conversational Context**: The `parent` chain captures iterative
71//!   refinement, so the agent can understand how the user's request
72//!   evolved over multiple exchanges.
73//! - **Completion Tracking**: The `commit` field closes the loop by
74//!   recording which git commit satisfied the Intent.
75
76use std::fmt;
77
78use chrono::{DateTime, Utc};
79use serde::{Deserialize, Serialize};
80use uuid::Uuid;
81
82use crate::{
83    errors::GitError,
84    hash::ObjectHash,
85    internal::object::{
86        ObjectTrait,
87        integrity::IntegrityHash,
88        types::{ActorRef, Header, ObjectType},
89    },
90};
91
92/// Status of an Intent through its lifecycle.
93///
94/// Valid transitions (see module docs for diagram):
95///
96/// - `Draft` → `Active`: AI has analyzed the prompt and filled `content`.
97/// - `Active` → `Completed`: All downstream Tasks finished successfully
98///   and the result commit has been recorded in `Intent.commit`.
99/// - `Draft` → `Cancelled`: User abandoned the request before AI analysis.
100/// - `Active` → `Cancelled`: User or orchestrator cancelled during
101///   planning/execution (e.g. timeout, user interrupt, budget exceeded).
102///
103/// Reverse transitions (e.g. `Active` → `Draft`) are not expected.
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(rename_all = "snake_case")]
106pub enum IntentStatus {
107    /// Initial state. The `prompt` has been captured but the AI has not
108    /// yet analyzed it — `Intent.content` is `None`.
109    Draft,
110    /// AI interpretation is available in `Intent.content`. Downstream
111    /// objects (Plan, Tasks, Runs) may be in progress.
112    Active,
113    /// The Intent has been fully satisfied. `Intent.commit` should
114    /// contain the SHA of the git commit that fulfils the request.
115    Completed,
116    /// The Intent was abandoned before completion. A reason should be
117    /// recorded in the [`StatusEntry`] that carries this status.
118    Cancelled,
119}
120
121impl IntentStatus {
122    /// Returns the snake_case string representation.
123    pub fn as_str(&self) -> &'static str {
124        match self {
125            IntentStatus::Draft => "draft",
126            IntentStatus::Active => "active",
127            IntentStatus::Completed => "completed",
128            IntentStatus::Cancelled => "cancelled",
129        }
130    }
131}
132
133impl fmt::Display for IntentStatus {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        write!(f, "{}", self.as_str())
136    }
137}
138
139/// A single entry in the Intent's status history.
140///
141/// Each status transition appends a new `StatusEntry` to
142/// `Intent.statuses`. The entries are never removed or mutated,
143/// forming an append-only audit log. The current status is always
144/// `statuses.last().status`.
145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
146pub struct StatusEntry {
147    /// The [`IntentStatus`] that was entered by this transition.
148    status: IntentStatus,
149    /// UTC timestamp of when this transition occurred.
150    ///
151    /// Automatically set to `Utc::now()` by [`StatusEntry::new`].
152    /// Timestamps across entries in the same Intent are monotonically
153    /// non-decreasing.
154    changed_at: DateTime<Utc>,
155    /// Optional human-readable reason for the transition.
156    ///
157    /// Recommended for `Cancelled` (why the request was abandoned) and
158    /// `Completed` (summary of what was achieved). May be `None` for
159    /// routine transitions like `Draft` → `Active`.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    reason: Option<String>,
162}
163
164impl StatusEntry {
165    /// Creates a new status entry timestamped to now.
166    pub fn new(status: IntentStatus, reason: Option<String>) -> Self {
167        Self {
168            status,
169            changed_at: Utc::now(),
170            reason,
171        }
172    }
173
174    /// The status that was entered.
175    pub fn status(&self) -> &IntentStatus {
176        &self.status
177    }
178
179    /// When the transition occurred.
180    pub fn changed_at(&self) -> DateTime<Utc> {
181        self.changed_at
182    }
183
184    /// Optional reason for the transition.
185    pub fn reason(&self) -> Option<&str> {
186        self.reason.as_deref()
187    }
188}
189
190/// The entry point of every AI-assisted workflow.
191///
192/// An `Intent` captures both the verbatim user input (`prompt`) and the
193/// AI's structured understanding of that input (`content`). It is
194/// created at step ② and completed at step ⑩ of the end-to-end flow.
195/// See module documentation for lifecycle position, status transitions,
196/// and conversational refinement.
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct Intent {
199    /// Common header (object ID, type, timestamps, creator, etc.).
200    #[serde(flatten)]
201    header: Header,
202    /// Verbatim natural-language request from the user.
203    ///
204    /// This is the unmodified input exactly as the user typed it (e.g.
205    /// "Add pagination to the user list API"). It is set once at
206    /// creation and never changed, preserving the original request for
207    /// auditing and potential re-analysis with a different model.
208    prompt: String,
209    /// AI-analyzed structured interpretation of `prompt`.
210    ///
211    /// `None` while the Intent is in `Draft` status — the AI has not
212    /// yet processed the prompt. Set to `Some(...)` when the AI
213    /// completes its analysis, at which point the status should
214    /// transition to `Active`. The content typically includes:
215    /// - Disambiguated requirements
216    /// - Identified scope (which files, modules, APIs are affected)
217    /// - Inferred constraints or acceptance criteria
218    ///
219    /// Unlike `prompt`, `content` is the AI's output and may be
220    /// regenerated if the analysis is re-run.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    content: Option<String>,
223    /// Link to a predecessor Intent for conversational refinement.
224    ///
225    /// Forms a singly-linked list from newest to oldest: each
226    /// follow-up Intent points to the Intent it refines. `None` for
227    /// the first Intent in a conversation. The orchestrator can walk
228    /// the `parent` chain to reconstruct the full conversational
229    /// history and provide prior context to the AI.
230    ///
231    /// Example chain: Intent₂ → Intent₁ → Intent₀ (root, parent=None).
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    parent: Option<Uuid>,
234    /// Git commit hash recorded when this Intent is fulfilled.
235    ///
236    /// Set by the orchestrator at step ⑩ after the
237    /// [`Decision`](super::decision::Decision) applies the final
238    /// PatchSet. `None` while the Intent is in progress (`Draft` or
239    /// `Active`) or if it was `Cancelled`. When set, the Intent's
240    /// status should be `Completed`.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    commit: Option<IntegrityHash>,
243    /// Link to the [`Plan`](super::plan::Plan) derived from this
244    /// Intent.
245    ///
246    /// Set after the AI analyzes `content` and produces a Plan at
247    /// step ③. Always points to the **latest** Plan revision — if
248    /// the Plan is revised (via `Plan.previous` chain), this field
249    /// is updated to the newest version. `None` while no Plan has
250    /// been created yet.
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    plan: Option<Uuid>,
253    /// Append-only chronological history of status transitions.
254    ///
255    /// Initialized with a single `Draft` entry at creation. Each call
256    /// to [`set_status`](Intent::set_status) or
257    /// [`set_status_with_reason`](Intent::set_status_with_reason)
258    /// pushes a new [`StatusEntry`]. The current status is always
259    /// `statuses.last().status`. Entries are never removed or mutated.
260    ///
261    /// This design preserves the full transition timeline with
262    /// timestamps and optional reasons, enabling audit and duration
263    /// analysis (e.g. time spent in `Active` before `Completed`).
264    statuses: Vec<StatusEntry>,
265}
266
267impl Intent {
268    /// Create a new intent in `Draft` status from a raw user prompt.
269    ///
270    /// The `content` field is initially `None` — call [`set_content`](Intent::set_content)
271    /// after the AI has analyzed the prompt.
272    pub fn new(created_by: ActorRef, prompt: impl Into<String>) -> Result<Self, String> {
273        Ok(Self {
274            header: Header::new(ObjectType::Intent, created_by)?,
275            prompt: prompt.into(),
276            content: None,
277            parent: None,
278            commit: None,
279            plan: None,
280            statuses: vec![StatusEntry::new(IntentStatus::Draft, None)],
281        })
282    }
283
284    /// Returns a reference to the common header.
285    pub fn header(&self) -> &Header {
286        &self.header
287    }
288
289    /// Returns the raw user prompt.
290    pub fn prompt(&self) -> &str {
291        &self.prompt
292    }
293
294    /// Returns the AI-analyzed content, if available.
295    pub fn content(&self) -> Option<&str> {
296        self.content.as_deref()
297    }
298
299    /// Sets the AI-analyzed content.
300    pub fn set_content(&mut self, content: Option<String>) {
301        self.content = content;
302    }
303
304    /// Returns the parent intent ID, if this is part of a refinement chain.
305    pub fn parent(&self) -> Option<Uuid> {
306        self.parent
307    }
308
309    /// Returns the result commit SHA, if the intent has been fulfilled.
310    pub fn commit(&self) -> Option<&IntegrityHash> {
311        self.commit.as_ref()
312    }
313
314    /// Returns the current lifecycle status (the last entry in the history).
315    ///
316    /// Returns `None` only if `statuses` is empty, which should not
317    /// happen for objects created via [`Intent::new`] (seeds with
318    /// `Draft`), but may occur for malformed deserialized data.
319    pub fn status(&self) -> Option<&IntentStatus> {
320        self.statuses.last().map(|e| &e.status)
321    }
322
323    /// Returns the full chronological status history.
324    pub fn statuses(&self) -> &[StatusEntry] {
325        &self.statuses
326    }
327
328    /// Links this intent to a parent intent for conversational refinement.
329    pub fn set_parent(&mut self, parent: Option<Uuid>) {
330        self.parent = parent;
331    }
332
333    /// Records the git commit SHA that fulfilled this intent.
334    pub fn set_commit(&mut self, sha: Option<IntegrityHash>) {
335        self.commit = sha;
336    }
337
338    /// Returns the associated Plan ID, if a Plan has been derived from this intent.
339    pub fn plan(&self) -> Option<Uuid> {
340        self.plan
341    }
342
343    /// Associates this intent with a [`Plan`](super::plan::Plan).
344    pub fn set_plan(&mut self, plan: Option<Uuid>) {
345        self.plan = plan;
346    }
347
348    /// Transitions the intent to a new lifecycle status, appending to the history.
349    pub fn set_status(&mut self, status: IntentStatus) {
350        self.statuses.push(StatusEntry::new(status, None));
351    }
352
353    /// Transitions the intent to a new lifecycle status with a reason.
354    pub fn set_status_with_reason(&mut self, status: IntentStatus, reason: impl Into<String>) {
355        self.statuses
356            .push(StatusEntry::new(status, Some(reason.into())));
357    }
358}
359
360impl fmt::Display for Intent {
361    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
362        write!(f, "Intent: {}", self.header.object_id())
363    }
364}
365
366impl ObjectTrait for Intent {
367    fn from_bytes(data: &[u8], _hash: ObjectHash) -> Result<Self, GitError>
368    where
369        Self: Sized,
370    {
371        serde_json::from_slice(data).map_err(|e| GitError::InvalidIntentObject(e.to_string()))
372    }
373
374    fn get_type(&self) -> ObjectType {
375        ObjectType::Intent
376    }
377
378    fn get_size(&self) -> usize {
379        match serde_json::to_vec(self) {
380            Ok(v) => v.len(),
381            Err(e) => {
382                tracing::warn!("failed to compute Intent size: {}", e);
383                0
384            }
385        }
386    }
387
388    fn to_data(&self) -> Result<Vec<u8>, GitError> {
389        serde_json::to_vec(self).map_err(|e| GitError::InvalidIntentObject(e.to_string()))
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn test_intent_creation() {
399        let actor = ActorRef::human("jackie").expect("actor");
400        let mut intent = Intent::new(actor, "Refactor login flow").expect("intent");
401
402        assert_eq!(intent.header().object_type(), &ObjectType::Intent);
403        assert_eq!(intent.prompt(), "Refactor login flow");
404        assert!(intent.content().is_none());
405        assert_eq!(intent.status(), Some(&IntentStatus::Draft));
406        assert!(intent.parent().is_none());
407        assert!(intent.plan().is_none());
408
409        intent.set_content(Some("Restructure the authentication module".to_string()));
410        assert_eq!(
411            intent.content(),
412            Some("Restructure the authentication module")
413        );
414
415        // After content is analyzed, a Plan can be linked
416        let plan_id = Uuid::from_u128(0x42);
417        intent.set_plan(Some(plan_id));
418        assert_eq!(intent.plan(), Some(plan_id));
419    }
420
421    #[test]
422    fn test_statuses() {
423        let actor = ActorRef::human("jackie").expect("actor");
424        let mut intent = Intent::new(actor, "Fix bug").expect("intent");
425
426        // Initial state: one Draft entry
427        assert_eq!(intent.statuses().len(), 1);
428        assert_eq!(intent.status(), Some(&IntentStatus::Draft));
429
430        // Transition to Active
431        intent.set_status(IntentStatus::Active);
432        assert_eq!(intent.status(), Some(&IntentStatus::Active));
433        assert_eq!(intent.statuses().len(), 2);
434
435        // Transition to Completed with reason
436        intent.set_status_with_reason(IntentStatus::Completed, "All tasks done");
437        assert_eq!(intent.status(), Some(&IntentStatus::Completed));
438        assert_eq!(intent.statuses().len(), 3);
439
440        // Verify full history
441        let history = intent.statuses();
442        assert_eq!(history[0].status(), &IntentStatus::Draft);
443        assert!(history[0].reason().is_none());
444        assert_eq!(history[1].status(), &IntentStatus::Active);
445        assert!(history[1].reason().is_none());
446        assert_eq!(history[2].status(), &IntentStatus::Completed);
447        assert_eq!(history[2].reason(), Some("All tasks done"));
448
449        // Timestamps are ordered
450        assert!(history[1].changed_at() >= history[0].changed_at());
451        assert!(history[2].changed_at() >= history[1].changed_at());
452    }
453}