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