Skip to main content

speccoder_runtime/
context.rs

1//! The context bundle for one task.
2//!
3//! Built from files *before* a run and passed as the run's input, so the
4//! journal holds exactly what the model saw and replay needs no files.
5//! Deterministic: same files, same bundle, same bytes.
6
7use serde::Serialize;
8use speccoder_core::snapshot::ProjectSnapshot;
9
10/// Largest share of the prompt one document may take.
11pub const PART_LIMIT: usize = 32 * 1024;
12
13/// What the model is given about a task.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
15pub struct Bundle {
16    /// Feature directory name.
17    pub feature: String,
18    /// The project constitution (may be empty).
19    pub constitution: String,
20    /// `plan.md` (may be empty).
21    pub plan: String,
22    /// The task's user-story section of `spec.md`; the whole spec for a
23    /// Convergence task; or empty.
24    pub story: String,
25    /// The task.
26    pub task: TaskContext,
27    /// Its direct dependencies, in document order.
28    pub dependencies: Vec<DependencyContext>,
29    /// The other tasks of its story (OpenSpec: of its change), in document
30    /// order: what they do is theirs, not this task's.
31    #[serde(skip_serializing_if = "Vec::is_empty")]
32    pub story_tasks: Vec<DependencyContext>,
33}
34
35/// The task as written.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
37pub struct TaskContext {
38    /// Task ID.
39    pub id: String,
40    /// Title after markers.
41    pub title: String,
42    /// Enclosing phase heading.
43    pub phase: String,
44    /// Enclosing subsection, if any.
45    pub subsection: String,
46    /// Story label, if any.
47    pub story: String,
48    /// Carries `[P]`.
49    pub parallel: bool,
50}
51
52/// A dependency of the task.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
54pub struct DependencyContext {
55    /// Task ID.
56    pub id: String,
57    /// Title.
58    pub title: String,
59    /// Ticked in `tasks.md`.
60    pub done: bool,
61}
62
63/// Why no bundle could be built.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum BundleError {
66    /// No feature selected.
67    NoFeature,
68    /// The feature has no task list.
69    NoTasks,
70    /// The task ID is not in the list.
71    NotFound(String),
72}
73
74/// Builds the bundle for `task_id` of the selected feature of a project in
75/// the reference format.
76pub fn bundle(snapshot: &ProjectSnapshot, task_id: &str) -> Result<Bundle, BundleError> {
77    crate::format::reference().bundle(snapshot, task_id)
78}
79
80/// Cuts text at `PART_LIMIT` bytes on a char boundary, with a marker.
81pub(crate) fn cap(text: &str) -> String {
82    if text.len() <= PART_LIMIT {
83        return text.to_owned();
84    }
85    let mut end = PART_LIMIT;
86    while !text.is_char_boundary(end) {
87        end -= 1;
88    }
89    format!(
90        "{}\n[… cut at {PART_LIMIT} bytes of {} …]",
91        &text[..end],
92        text.len()
93    )
94}
95
96/// The one builder of a call's context manifest (constitution VIII): every
97/// part handed to a model call is pushed here, hashed and labelled once.
98#[derive(Debug, Default, Clone)]
99pub struct Manifest {
100    entries: Vec<speccoder_core::context::ContextEntry>,
101}
102
103impl Manifest {
104    /// An empty manifest.
105    #[must_use]
106    pub fn new() -> Self {
107        Self::default()
108    }
109
110    /// Adds a part: what it is, where it came from, a reference to it and
111    /// its exact bytes (an empty part is not added).
112    pub fn push(
113        &mut self,
114        kind: speccoder_core::context::ContextKind,
115        source: &str,
116        reference: &str,
117        bytes: &[u8],
118    ) -> &mut Self {
119        if bytes.is_empty() {
120            return self;
121        }
122        self.entries.push(speccoder_core::context::ContextEntry {
123            kind,
124            source: source.to_owned(),
125            reference: reference.to_owned(),
126            sha256: crate::integrity::sha256_hex(bytes),
127            bytes: u64::try_from(bytes.len()).unwrap_or(u64::MAX),
128            cut: bytes.len() > PART_LIMIT,
129        });
130        self
131    }
132
133    /// The finished manifest, sorted, with its digest.
134    #[must_use]
135    pub fn finish(&self) -> speccoder_core::context::ContextManifest {
136        speccoder_core::context::ContextManifest::of(
137            self.entries.clone(),
138            crate::integrity::sha256_hex,
139        )
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn a_manifest_recomputes_from_what_it_lists() {
149        use speccoder_core::context::ContextKind;
150        let mut m = Manifest::new();
151        m.push(ContextKind::Issue, "github:o/r#42", "#42", b"title\nbody")
152            .push(ContextKind::Story, "person", "/triad", b"C\tx\n")
153            .push(ContextKind::Plan, "worktree", "/bundle/plan", b"");
154        let done = m.finish();
155        assert_eq!(done.entries.len(), 2, "an empty part is not listed");
156        let again = speccoder_core::context::ContextManifest::of(
157            done.entries.clone(),
158            crate::integrity::sha256_hex,
159        );
160        assert_eq!(again.digest, done.digest);
161        assert_eq!(
162            done.entries[0].sha256,
163            crate::integrity::sha256_hex(b"C\tx\n")
164        );
165    }
166
167    #[test]
168    fn cap_marks_the_cut() {
169        let long = "é".repeat(PART_LIMIT);
170        let capped = cap(&long);
171        assert!(capped.len() < long.len());
172        assert!(
173            capped.contains("[… cut at 32768 bytes of 65536 …]"),
174            "{}",
175            &capped[capped.len() - 40..]
176        );
177    }
178}