Skip to main content

harness_loop/
goal.rs

1//! A goal: the run itself, written down and survivable.
2//!
3//! A spec and a goal look alike on the page and are not the same object. A spec
4//! is design authority — you think hard, you write it, you hand it to the model
5//! as context. It is a bigger prompt. Nothing about it touches the runtime, so
6//! when the process dies there is nothing to come back to: the file is still
7//! there and the run is gone.
8//!
9//! A goal is *bound to the run*. It has an id, it holds which phase is in
10//! flight, and it is on disk before the first turn, so a crash, a laptop lid,
11//! or a deliberate stop leaves something to resume rather than something to
12//! re-derive. That is the whole distinction, and it is why this lives beside
13//! [`crate::seal`] and [`crate::receipt`] rather than in a docs folder: goal
14//! says what, seal says what may not move while it happens, receipt says what
15//! came of it.
16//!
17//! **Context is referenced, not inlined.** [`Goal::context`] holds paths. A
18//! goal that embeds the material it points at is a prompt again — it goes stale
19//! against the files it copied, and it grows until it is the thing you were
20//! trying to avoid re-reading.
21//!
22//! **Phases exist to bound review, not the model.** The useful question is not
23//! "how long may this run" but "how many commits am I willing to read". Ten to
24//! twenty phases is the range that has worked; nothing here enforces it,
25//! because the right number is a property of the work.
26
27use serde::{Deserialize, Serialize};
28use std::path::PathBuf;
29
30/// Current [`Goal::schema`].
31pub const SCHEMA: &str = "harness.goal.v1";
32
33/// Where one phase stands.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum PhaseStatus {
37    Pending,
38    Running,
39    Done,
40    /// Attempted and did not hold. Kept rather than reset to `Pending` so a
41    /// resumed goal can tell "not started" from "tried once and failed",
42    /// which are different things to hand back to a model.
43    Failed,
44}
45
46/// One reviewable chunk of the objective.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct Phase {
49    pub title: String,
50    #[serde(default = "pending")]
51    pub status: PhaseStatus,
52    /// What happened. On a failure this is what gets handed back on resume.
53    #[serde(default)]
54    pub note: String,
55}
56
57fn pending() -> PhaseStatus {
58    PhaseStatus::Pending
59}
60
61impl Phase {
62    pub fn new(title: impl Into<String>) -> Self {
63        Self {
64            title: title.into(),
65            status: PhaseStatus::Pending,
66            note: String::new(),
67        }
68    }
69}
70
71/// A durable, resumable objective.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct Goal {
74    pub schema: String,
75    /// Stable across resumes. The thing a log line can point at.
76    pub id: String,
77    /// One outcome. Not a list — a goal that wants three things is three goals,
78    /// and the single objective is what makes "what was this run for?"
79    /// answerable months later.
80    pub objective: String,
81    /// Paths to read, not their contents. See the module docs.
82    #[serde(default)]
83    pub context: Vec<PathBuf>,
84    /// How to work: cautious, exploratory, ship-it. Free text, because the
85    /// useful values differ per team and an enum would just be ignored.
86    #[serde(default)]
87    pub posture: String,
88    /// What must not change. Stated separately from the objective because the
89    /// model reads the objective as an instruction and these as a boundary.
90    #[serde(default)]
91    pub invariants: Vec<String>,
92    #[serde(default)]
93    pub phases: Vec<Phase>,
94    /// How completion is checked, in words. The executable form is an
95    /// [`crate::Acceptance`]; this is what a human reads to know what that
96    /// check is supposed to be enforcing.
97    #[serde(default)]
98    pub verify: String,
99    #[serde(default)]
100    pub created_ms: i64,
101    #[serde(default)]
102    pub updated_ms: i64,
103}
104
105impl Goal {
106    pub fn new(id: impl Into<String>, objective: impl Into<String>, now_ms: i64) -> Self {
107        Self {
108            schema: SCHEMA.into(),
109            id: id.into(),
110            objective: objective.into(),
111            context: Vec::new(),
112            posture: String::new(),
113            invariants: Vec::new(),
114            phases: Vec::new(),
115            verify: String::new(),
116            created_ms: now_ms,
117            updated_ms: now_ms,
118        }
119    }
120
121    pub fn with_context<I, P>(mut self, paths: I) -> Self
122    where
123        I: IntoIterator<Item = P>,
124        P: Into<PathBuf>,
125    {
126        self.context = paths.into_iter().map(Into::into).collect();
127        self
128    }
129
130    pub fn with_posture(mut self, p: impl Into<String>) -> Self {
131        self.posture = p.into();
132        self
133    }
134
135    pub fn with_invariants<I, S>(mut self, items: I) -> Self
136    where
137        I: IntoIterator<Item = S>,
138        S: Into<String>,
139    {
140        self.invariants = items.into_iter().map(Into::into).collect();
141        self
142    }
143
144    pub fn with_phases<I, S>(mut self, titles: I) -> Self
145    where
146        I: IntoIterator<Item = S>,
147        S: Into<String>,
148    {
149        self.phases = titles.into_iter().map(|t| Phase::new(t)).collect();
150        self
151    }
152
153    pub fn with_verify(mut self, v: impl Into<String>) -> Self {
154        self.verify = v.into();
155        self
156    }
157
158    /// The phase to work on: the one running, else the first not yet done.
159    ///
160    /// A `Failed` phase is returned again rather than skipped — the run stopped
161    /// there for a reason and resuming past it would quietly abandon work the
162    /// goal still claims to want.
163    pub fn current(&self) -> Option<(usize, &Phase)> {
164        self.phases
165            .iter()
166            .enumerate()
167            .find(|(_, p)| p.status == PhaseStatus::Running)
168            .or_else(|| {
169                self.phases
170                    .iter()
171                    .enumerate()
172                    .find(|(_, p)| matches!(p.status, PhaseStatus::Pending | PhaseStatus::Failed))
173            })
174    }
175
176    /// Every phase is `Done`. A goal with no phases is never complete: it has
177    /// not been broken down, so there is nothing to have finished.
178    pub fn complete(&self) -> bool {
179        !self.phases.is_empty() && self.phases.iter().all(|p| p.status == PhaseStatus::Done)
180    }
181
182    /// Mark the current phase as in flight.
183    pub fn start_current(&mut self, now_ms: i64) -> Option<usize> {
184        let i = self.current()?.0;
185        self.phases[i].status = PhaseStatus::Running;
186        self.updated_ms = now_ms;
187        Some(i)
188    }
189
190    pub fn finish(&mut self, i: usize, note: impl Into<String>, now_ms: i64) {
191        if let Some(p) = self.phases.get_mut(i) {
192            p.status = PhaseStatus::Done;
193            p.note = note.into();
194            self.updated_ms = now_ms;
195        }
196    }
197
198    pub fn fail(&mut self, i: usize, why: impl Into<String>, now_ms: i64) {
199        if let Some(p) = self.phases.get_mut(i) {
200            p.status = PhaseStatus::Failed;
201            p.note = why.into();
202            self.updated_ms = now_ms;
203        }
204    }
205
206    /// Render the brief for the current phase.
207    ///
208    /// Includes the objective every time. A model resuming at phase 9 has none
209    /// of the earlier conversation, and a phase title on its own ("wire the
210    /// handler") is not a task — it is a reminder to someone who already knew.
211    pub fn brief(&self) -> String {
212        let mut s = format!("# Objective\n{}\n", self.objective.trim());
213
214        if !self.posture.is_empty() {
215            s.push_str(&format!("\n# How to work\n{}\n", self.posture.trim()));
216        }
217        if !self.context.is_empty() {
218            s.push_str("\n# Read first\n");
219            for p in &self.context {
220                s.push_str(&format!("- {}\n", p.display()));
221            }
222        }
223        if !self.invariants.is_empty() {
224            s.push_str("\n# Do not change\n");
225            for i in &self.invariants {
226                s.push_str(&format!("- {i}\n"));
227            }
228        }
229        if !self.phases.is_empty() {
230            let done = self
231                .phases
232                .iter()
233                .filter(|p| p.status == PhaseStatus::Done)
234                .count();
235            s.push_str(&format!(
236                "\n# Phase {} of {}\n",
237                done + 1,
238                self.phases.len()
239            ));
240            match self.current() {
241                Some((_, p)) => {
242                    s.push_str(&format!("{}\n", p.title));
243                    // The previous attempt, if there was one. This is the whole
244                    // value of keeping `Failed` distinct from `Pending`.
245                    if p.status == PhaseStatus::Failed && !p.note.is_empty() {
246                        s.push_str(&format!(
247                            "\nThis phase was attempted and did not hold: {}\n\
248                             Address that before anything else.\n",
249                            p.note
250                        ));
251                    }
252                }
253                None => s.push_str("All phases are done.\n"),
254            }
255        }
256        if !self.verify.is_empty() {
257            s.push_str(&format!("\n# Done when\n{}\n", self.verify.trim()));
258        }
259        s
260    }
261}
262
263/// Goals on disk, one JSON file per id.
264///
265/// A directory of plain files rather than a database: the same reasoning as
266/// `FileMemory` — greppable, diffable, and a goal you can open in an editor
267/// mid-run is worth more than one you have to query for.
268pub struct GoalStore {
269    dir: PathBuf,
270}
271
272impl GoalStore {
273    pub fn open(dir: impl Into<PathBuf>) -> std::io::Result<Self> {
274        let dir = dir.into();
275        std::fs::create_dir_all(&dir)?;
276        Ok(Self { dir })
277    }
278
279    fn path(&self, id: &str) -> PathBuf {
280        // Ids reach the filesystem, so they get the same treatment every other
281        // externally-supplied path segment gets here: anything that is not a
282        // clean identifier byte becomes one that is.
283        let safe: String = id
284            .chars()
285            .map(|c| {
286                if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
287                    c
288                } else {
289                    '_'
290                }
291            })
292            .collect();
293        self.dir.join(format!("{safe}.json"))
294    }
295
296    pub fn save(&self, g: &Goal) -> std::io::Result<()> {
297        // Write-rename: a goal half-written by a process that died is worse
298        // than no goal, because resume would read it and believe it.
299        let p = self.path(&g.id);
300        let tmp = p.with_extension("json.tmp");
301        std::fs::write(&tmp, serde_json::to_string_pretty(g).unwrap_or_default())?;
302        std::fs::rename(&tmp, &p)
303    }
304
305    pub fn load(&self, id: &str) -> std::io::Result<Goal> {
306        let s = std::fs::read_to_string(self.path(id))?;
307        serde_json::from_str(&s).map_err(std::io::Error::other)
308    }
309
310    /// Ids of every goal that is not finished, oldest first — what "resume"
311    /// offers you.
312    pub fn unfinished(&self) -> Vec<Goal> {
313        let mut out: Vec<Goal> = std::fs::read_dir(&self.dir)
314            .into_iter()
315            .flatten()
316            .flatten()
317            .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
318            .filter_map(|e| std::fs::read_to_string(e.path()).ok())
319            .filter_map(|s| serde_json::from_str::<Goal>(&s).ok())
320            .filter(|g| !g.complete())
321            .collect();
322        out.sort_by_key(|g| g.created_ms);
323        out
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    fn goal() -> Goal {
332        Goal::new("g1", "Port the deploy from Netlify to Azure", 1000)
333            .with_context(["docs/deploy.md", "infra/"])
334            .with_posture("Cautious. Prefer reversible steps.")
335            .with_invariants(["the public API shape", "the database schema"])
336            .with_phases(["stand up the Azure app", "move the DNS", "retire Netlify"])
337            .with_verify("the five gold queries return the same answers as production")
338    }
339
340    fn store() -> (GoalStore, PathBuf) {
341        let d = std::env::temp_dir().join(format!(
342            "harness-goal-{}-{:?}",
343            std::process::id(),
344            std::thread::current().id()
345        ));
346        let _ = std::fs::remove_dir_all(&d);
347        (GoalStore::open(&d).unwrap(), d)
348    }
349
350    #[test]
351    fn a_goal_survives_the_process_that_started_it() {
352        // The property that makes it a goal and not a spec.
353        let (s, d) = store();
354        let mut g = goal();
355        let i = g.start_current(1001).unwrap();
356        g.finish(i, "app service created", 1002);
357        s.save(&g).unwrap();
358
359        let back = s.load("g1").unwrap();
360        assert_eq!(back, g);
361        assert_eq!(back.current().unwrap().1.title, "move the DNS");
362        let _ = std::fs::remove_dir_all(&d);
363    }
364
365    #[test]
366    fn a_failed_phase_is_retried_not_skipped() {
367        let mut g = goal();
368        let i = g.start_current(1).unwrap();
369        g.fail(i, "the app service quota was exhausted", 2);
370        let (j, p) = g.current().unwrap();
371        assert_eq!(j, i, "resume must land back on the phase that failed");
372        assert_eq!(p.status, PhaseStatus::Failed);
373        // And the brief has to say so, or the model repeats the failure.
374        let b = g.brief();
375        assert!(b.contains("did not hold"), "{b}");
376        assert!(b.contains("quota was exhausted"), "{b}");
377    }
378
379    #[test]
380    fn the_brief_restates_the_objective_at_every_phase() {
381        // A model resuming at phase 3 has none of the earlier conversation.
382        let mut g = goal();
383        for k in 0..2 {
384            let i = g.start_current(k).unwrap();
385            g.finish(i, "ok", k);
386        }
387        let b = g.brief();
388        assert!(b.contains("Port the deploy from Netlify to Azure"));
389        assert!(b.contains("Phase 3 of 3"));
390        assert!(b.contains("retire Netlify"));
391        assert!(b.contains("the database schema"), "invariants must carry");
392    }
393
394    #[test]
395    fn context_is_listed_as_paths_not_pasted_in() {
396        let g = goal();
397        let b = g.brief();
398        assert!(b.contains("docs/deploy.md"));
399        // If someone later "helpfully" inlines the file, this catches it: the
400        // brief must stay a set of pointers.
401        assert!(b.len() < 800, "the brief grew into a prompt:\n{b}");
402    }
403
404    #[test]
405    fn a_goal_with_no_phases_is_never_complete() {
406        // Otherwise "I broke down nothing, therefore I finished everything".
407        let g = Goal::new("empty", "do the thing", 0);
408        assert!(!g.complete());
409        assert!(g.current().is_none());
410    }
411
412    #[test]
413    fn completion_requires_every_phase() {
414        let mut g = goal();
415        while let Some(i) = g.start_current(9) {
416            assert!(!g.complete());
417            g.finish(i, "ok", 9);
418        }
419        assert!(g.complete());
420        assert!(g.current().is_none());
421    }
422
423    #[test]
424    fn unfinished_lists_only_what_is_still_owed() {
425        let (s, d) = store();
426        let mut a = Goal::new("a", "first", 10).with_phases(["one"]);
427        let b = Goal::new("b", "second", 20).with_phases(["one"]);
428        let i = a.start_current(11).unwrap();
429        a.finish(i, "done", 12);
430        s.save(&a).unwrap();
431        s.save(&b).unwrap();
432
433        let left = s.unfinished();
434        assert_eq!(left.len(), 1);
435        assert_eq!(left[0].id, "b");
436        let _ = std::fs::remove_dir_all(&d);
437    }
438
439    #[test]
440    fn a_crafted_id_cannot_escape_the_store_directory() {
441        let (s, d) = store();
442        let g = Goal::new("../../etc/passwd", "nope", 0);
443        s.save(&g).unwrap();
444        assert!(!std::path::Path::new("/etc/passwd.json").exists());
445        let files: Vec<_> = std::fs::read_dir(&d).unwrap().flatten().collect();
446        assert_eq!(files.len(), 1);
447        assert!(!files[0].file_name().to_string_lossy().contains(".."));
448        let _ = std::fs::remove_dir_all(&d);
449    }
450
451    #[test]
452    fn an_older_goal_file_without_the_newer_fields_still_loads() {
453        // Goals outlive the code that wrote them; that is the point of them.
454        let json = r#"{"schema":"harness.goal.v1","id":"old","objective":"ship"}"#;
455        let g: Goal = serde_json::from_str(json).unwrap();
456        assert_eq!(g.objective, "ship");
457        assert!(g.phases.is_empty() && g.invariants.is_empty());
458    }
459}