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}