Skip to main content

speccoder_runtime/
in_place.rs

1//! In-place runs: `speccoder run --in-place` works on
2//! `speccoder/<feature>` in the person's own checkout instead of a separate
3//! worktree — the way to run features whose spec git ignores (a worktree
4//! holds only commits).
5//!
6//! What the worktree gave, replaced:
7//! - a clean checkout before the run, and the person's branch never touched:
8//!   the run switches to `speccoder/<feature>` and back;
9//! - an intent the run cannot read differently later: every intent, judge
10//!   and context file — git-ignored ones included — is hashed when the run
11//!   starts (the manifest) and re-checked before every task and every tick;
12//!   only the engine's own one-byte tick and convergence append change it;
13//! - a checkout nobody else changes: HEAD, the branch and a clean tree are
14//!   re-checked before every task and tick (the checkout guard).
15//!
16//! Evidence is committed before the tick: a gitignored `tasks.md` is never in
17//! a commit, so its tick is local, and the commit says so (`Tick: local`).
18
19use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21
22use serde::{Deserialize, Serialize};
23use speccoder_core::{CommandData, CommandResult, CommandStatus, InPlaceReport, RefusedPath};
24
25use crate::git::Git;
26use crate::integrity::{JudgeSnapshot, sha256_hex};
27use crate::tools::policy::Policy;
28use crate::worktree;
29
30/// The session record in a project's state directory.
31pub const FILE: &str = "in-place.json";
32
33/// Runtime state under the intent that is not intent.
34fn runtime_state(path: &str) -> bool {
35    path.contains("/workflows/runs/") || path.split('/').any(|c| c == "__pycache__")
36}
37
38/// Why an in-place run refuses or stops.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct InPlaceError {
41    /// `DIRTY-CHECKOUT`, `INTENT-CHANGED`, ….
42    pub code: &'static str,
43    /// What happened.
44    pub message: String,
45    /// The paths involved, with why.
46    pub paths: Vec<(String, String)>,
47    /// What to run: the first is `fix:`, the rest `or:`.
48    pub fix: Vec<String>,
49    /// The person's own commands among them, as argvs.
50    pub commands: Vec<speccoder_core::step::Argvs>,
51}
52
53impl InPlaceError {
54    fn new(code: &'static str, message: impl Into<String>) -> Self {
55        Self {
56            code,
57            message: message.into(),
58            paths: Vec::new(),
59            fix: Vec::new(),
60            commands: Vec::new(),
61        }
62    }
63
64    /// The person's own command as argvs: its line is the fix.
65    fn command(mut self, commands: Vec<Vec<String>>) -> Self {
66        self.fix = vec![speccoder_core::step::render(&commands)];
67        self.commands = vec![commands];
68        self
69    }
70
71    fn fix(mut self, lines: &[&str]) -> Self {
72        self.fix = lines.iter().map(|l| (*l).to_owned()).collect();
73        self
74    }
75
76    /// A stop during the run (exit 23) rather than a refusal before it (14).
77    #[must_use]
78    pub fn is_stop(&self) -> bool {
79        matches!(
80            self.code,
81            "INTENT-CHANGED"
82                | "JUDGE-CHANGED"
83                | "CHECKOUT-CHANGED"
84                | "GIT-CHANGED"
85                | "RETURN-BLOCKED"
86        )
87    }
88
89    /// One line naming the code, for a run report's reason and a saved
90    /// attempt: `stopped[INTENT-CHANGED]: … (specs/x/spec.md: changed)`.
91    #[must_use]
92    pub fn line(&self) -> String {
93        let word = if self.is_stop() { "stopped" } else { "error" };
94        let mut line = format!("{word}[{}]: {}", self.code, self.message);
95        for (p, why) in &self.paths {
96            line.push_str(&format!("; {p} ({why})"));
97        }
98        line
99    }
100
101    /// The command's result, its code and fixes on the envelope.
102    #[must_use]
103    pub fn result(&self, command: &str) -> CommandResult {
104        let status = match self.code {
105            "IN-PLACE-PARALLEL" => CommandStatus::UsageError,
106            _ if self.is_stop() => CommandStatus::NeedsHuman,
107            _ => CommandStatus::GitPrecondition,
108        };
109        CommandResult::new(command, status, &self.message)
110            .with_code(self.code)
111            .with_fix(self.fix.clone())
112            .with_commands(self.commands.clone())
113            .with_data(CommandData::InPlace(Box::new(InPlaceReport {
114                paths: self
115                    .paths
116                    .iter()
117                    .map(|(path, reason)| RefusedPath {
118                        path: path.clone(),
119                        reason: reason.clone(),
120                    })
121                    .collect(),
122            })))
123    }
124}
125
126/// The branch the person was on.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "snake_case")]
129pub enum Original {
130    /// A branch name.
131    Branch(String),
132    /// A detached commit.
133    Detached(String),
134}
135
136impl Original {
137    fn target(&self) -> &str {
138        match self {
139            Self::Branch(b) | Self::Detached(b) => b,
140        }
141    }
142
143    /// As a person reads it.
144    #[must_use]
145    pub fn describe(&self) -> String {
146        match self {
147            Self::Branch(b) => b.clone(),
148            Self::Detached(c) => format!("detached {}", c.chars().take(10).collect::<String>()),
149        }
150    }
151}
152
153/// An in-place run in progress: written before the checkout is switched,
154/// removed after it is switched back.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156pub struct Session {
157    /// The feature.
158    pub feature: String,
159    /// `speccoder/<feature>`.
160    pub branch: String,
161    /// Where the person was.
162    pub original: Original,
163    /// The checkout (the repository's root).
164    pub checkout: PathBuf,
165    /// The project root inside it.
166    pub project: PathBuf,
167    /// The branch's tip as speccoder last left it.
168    pub tip: String,
169    /// Intent, judge and context files by hash, as the run expects them.
170    pub manifest: BTreeMap<String, String>,
171    /// The running process.
172    pub pid: u32,
173    /// Seconds since the epoch.
174    pub started_at: u64,
175    /// What starting this run did on its own (a resume, work set aside, a
176    /// tick restored): told with the outcome, never recorded.
177    #[serde(skip)]
178    pub notes: Vec<String>,
179    /// What git ignored when the session began (directories end in `/`):
180    /// hidden from the model's file tools, as a worktree would not hold it.
181    #[serde(default)]
182    pub hidden: Vec<String>,
183    /// The repository's configuration, hooks, `info/` and refs (other than
184    /// the session's branch) by hash: nothing a run does may change them.
185    #[serde(default)]
186    pub git: String,
187}
188
189/// The manifest of `project`: every intent, judge and context file, ignored
190/// ones included, minus runtime state.
191#[must_use]
192pub fn manifest(project: &Path, policy: &Policy) -> BTreeMap<String, String> {
193    JudgeSnapshot::take(project, policy)
194        .entries()
195        .iter()
196        .filter(|(p, _)| !runtime_state(p))
197        .map(|(p, h)| (p.clone(), h.clone()))
198        .collect()
199}
200
201/// The paths that differ between two manifests, with how.
202#[must_use]
203pub fn diff(
204    expected: &BTreeMap<String, String>,
205    now: &BTreeMap<String, String>,
206) -> Vec<(String, String)> {
207    let mut out = Vec::new();
208    for (p, h) in expected {
209        match now.get(p) {
210            None => out.push((p.clone(), "deleted".to_owned())),
211            Some(n) if n != h => out.push((p.clone(), "modified".to_owned())),
212            _ => {}
213        }
214    }
215    for p in now.keys().filter(|p| !expected.contains_key(*p)) {
216        out.push((p.clone(), "added".to_owned()));
217    }
218    out
219}
220
221/// Whether `after` is `before` with exactly one byte changed, from a space
222/// to `x`/`X` (the engine's tick).
223#[must_use]
224pub fn is_tick(before: &[u8], after: &[u8]) -> bool {
225    let changed: Vec<(u8, u8)> = before
226        .iter()
227        .zip(after)
228        .filter(|(a, b)| a != b)
229        .map(|(a, b)| (*a, *b))
230        .collect();
231    before.len() == after.len() && matches!(changed.as_slice(), [(b' ', b'x' | b'X')])
232}
233
234/// SHA-256 over the intent files except the task lists (files named
235/// `tasks_file`, whose task lines the commits pin one by one with
236/// `Task-Line:`): what the verdict commits name (`Intent-Digest:`), so a
237/// later reader can tell the spec, plan or constitution changed — not a
238/// tick or a convergence round.
239#[must_use]
240pub fn intent_digest(project: &Path, policy: &Policy, tasks_file: &str) -> String {
241    let mut parts = Vec::new();
242    for (path, hash) in manifest(project, policy) {
243        if policy.write_protected(&path) != Some(crate::tools::paths::Class::Intent) {
244            continue;
245        }
246        if path.rsplit('/').next() == Some(tasks_file) {
247            continue;
248        }
249        parts.push(format!("{path} {hash}"));
250    }
251    sha256_hex(parts.join("\n").as_bytes())
252}
253
254pub(crate) use crate::integrity::untick;
255
256fn now() -> u64 {
257    std::time::SystemTime::now()
258        .duration_since(std::time::UNIX_EPOCH)
259        .map_or(0, |d| d.as_secs())
260}
261
262impl Session {
263    /// The record's path in a project's state directory.
264    #[must_use]
265    pub fn path(plane_dir: &Path) -> PathBuf {
266        plane_dir.join(FILE)
267    }
268
269    /// The record, if a session exists.
270    #[must_use]
271    pub fn load(plane_dir: &Path) -> Option<Self> {
272        let text = std::fs::read_to_string(Self::path(plane_dir)).ok()?;
273        serde_json::from_str(&text).ok()
274    }
275
276    /// Writes the record (temporary file, then rename).
277    pub fn save(&self, plane_dir: &Path) -> Result<(), String> {
278        std::fs::create_dir_all(plane_dir).map_err(|e| e.to_string())?;
279        let tmp = plane_dir.join(format!("{FILE}.tmp"));
280        let text = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
281        std::fs::write(&tmp, text).map_err(|e| e.to_string())?;
282        std::fs::rename(&tmp, Self::path(plane_dir)).map_err(|e| e.to_string())
283    }
284
285    /// Whether the process that owns it is running.
286    #[must_use]
287    pub fn is_live(&self) -> bool {
288        self.pid != std::process::id() && crate::progress::alive(self.pid)
289    }
290
291    /// Checks the preconditions, records the session, and switches the
292    /// checkout to `speccoder/<feature>`.
293    pub fn begin(
294        project: &Path,
295        plane_dir: &Path,
296        feature: &str,
297        policy: &Policy,
298    ) -> Result<Self, InPlaceError> {
299        let root_of = Git::at(project).toplevel().ok().flatten().ok_or_else(|| {
300            InPlaceError::new("UNBORN-HEAD", "the project is not inside a git repository")
301                .fix(&["git init && git add -A && git commit -m init"])
302        })?;
303        let checkout = std::fs::canonicalize(&root_of).unwrap_or(root_of);
304        let project = std::fs::canonicalize(project).unwrap_or_else(|_| project.to_path_buf());
305        let git = Git::at(&checkout);
306        if let Some(s) = Self::load(plane_dir).filter(Self::is_live) {
307            return Err(InPlaceError::new(
308                "IN-PLACE-ACTIVE",
309                format!(
310                    "an in-place run of {} is running (pid {})",
311                    s.feature, s.pid
312                ),
313            ));
314        }
315        if git.head().ok().flatten().is_none() {
316            return Err(
317                InPlaceError::new("UNBORN-HEAD", "the repository has no commits yet")
318                    .fix(&["git add -A && git commit -m init"]),
319            );
320        }
321        // A run of this feature that died on its branch, at the commit it
322        // recorded: with the intent unchanged it is taken over — an attempt
323        // the journal can resume resumes; with the intent changed, what it
324        // left is saved and the task starts fresh.
325        let mut notes = Vec::new();
326        if let Some(mut prior) = Self::load(plane_dir).filter(|p| {
327            p.feature == feature
328                && git
329                    .line(&["symbolic-ref", "-q", "HEAD"])
330                    .unwrap_or_default()
331                    == format!("refs/heads/{}", p.branch)
332                && git.line(&["rev-parse", "HEAD"]).unwrap_or_default() == p.tip
333        }) {
334            let dirty = worktree::require_clean(&checkout).is_err();
335            if diff(&prior.manifest, &manifest(&project, policy)).is_empty() {
336                if !dirty || resumable(plane_dir, feature, &prior.tip) {
337                    prior.pid = std::process::id();
338                    prior.started_at = now();
339                    prior
340                        .save(plane_dir)
341                        .map_err(|e| InPlaceError::new("IN-PLACE-ACTIVE", e))?;
342                    prior.notes.push(format!(
343                        "took over the interrupted in-place run (returns to {})",
344                        prior.original.describe()
345                    ));
346                    return Ok(prior);
347                }
348            } else if dirty
349                && let Some(r) = prior.set_aside(
350                    plane_dir,
351                    "left by an interrupted in-place run; the spec changed since, so it starts fresh",
352                )? {
353                    notes.push(format!(
354                        "the spec changed since the run was interrupted: what it left is saved as {r} and the task starts fresh"
355                    ));
356                }
357        }
358        if let Err(r) = worktree::require_clean(&checkout) {
359            let mut e = InPlaceError::new(
360                "DIRTY-CHECKOUT",
361                "the checkout has uncommitted changes; an in-place run needs a clean one",
362            )
363            .fix(&["git stash -u", "git commit"]);
364            if let Some((p, _)) = r.paths.iter().find(|(p, _)| {
365                policy.write_protected(p) == Some(crate::tools::paths::Class::Intent)
366            }) {
367                e.fix.push(format!("echo {p} >> .git/info/exclude"));
368            }
369            e.paths = r.paths;
370            return Err(e);
371        }
372        let config =
373            crate::verify::find_config(&project).map(|d| d.join(crate::verify::config::FILE));
374        let tracked = config.as_ref().is_some_and(|c| {
375            git.succeeds(&["ls-files", "--error-unmatch", "--", &c.to_string_lossy()])
376                .unwrap_or(false)
377        });
378        if !tracked {
379            return Err(InPlaceError::new(
380                "CONFIG-NOT-TRACKED",
381                "speccoder.toml is not committed; the checks that judge a run are reviewed like code",
382            )
383            .fix(&["git add speccoder.toml && git commit -m \"speccoder config\""]));
384        }
385        let branch = format!("speccoder/{feature}");
386        let listing = git
387            .run(&["worktree", "list", "--porcelain"])
388            .map(|b| String::from_utf8_lossy(&b).into_owned())
389            .unwrap_or_default();
390        let mut current_wt = String::new();
391        for line in listing.lines() {
392            if let Some(p) = line.strip_prefix("worktree ") {
393                current_wt = p.to_owned();
394            } else if line == format!("branch refs/heads/{branch}")
395                && Path::new(&current_wt) != checkout
396            {
397                return Err(InPlaceError::new(
398                    "BRANCH-IN-WORKTREE",
399                    format!("{branch} is checked out in the worktree {current_wt}"),
400                )
401                .command(vec![
402                    // An absolute path (git's listing): never a flag.
403                    ["git", "worktree", "remove"]
404                        .map(str::to_owned)
405                        .into_iter()
406                        .chain([current_wt.clone()])
407                        .collect(),
408                ]));
409            }
410        }
411        let head_ref = git
412            .line(&["symbolic-ref", "-q", "HEAD"])
413            .unwrap_or_default();
414        // A run that died leaves its record: on its branch, the branch to
415        // go back to is the recorded one, not the one checked out now.
416        let prior = Self::load(plane_dir);
417        if let Some(p) = prior.as_ref().filter(|p| p.feature != feature) {
418            return Err(InPlaceError::new(
419                "IN-PLACE-SESSION",
420                format!(
421                    "an interrupted in-place run of {} holds this checkout",
422                    p.feature
423                ),
424            )
425            .fix(&["speccoder run --in-place --return"]));
426        }
427        let original = match (prior, head_ref.strip_prefix("refs/heads/")) {
428            (Some(p), Some(b)) if b == branch => p.original,
429            (_, Some(b)) => Original::Branch(b.to_owned()),
430            (_, None) => Original::Detached(git.line(&["rev-parse", "HEAD"]).unwrap_or_default()),
431        };
432        let mut session = Self {
433            feature: feature.to_owned(),
434            branch: branch.clone(),
435            original: original.clone(),
436            checkout: checkout.clone(),
437            project: project.clone(),
438            tip: String::new(),
439            manifest: BTreeMap::new(),
440            pid: std::process::id(),
441            started_at: now(),
442            notes,
443            hidden: Vec::new(),
444            git: String::new(),
445        };
446        // Written before switching: a crash after the switch still knows
447        // where to go back to.
448        session
449            .save(plane_dir)
450            .map_err(|e| InPlaceError::new("IN-PLACE-ACTIVE", e))?;
451        if original != Original::Branch(branch.clone()) {
452            let exists = git
453                .succeeds(&[
454                    "rev-parse",
455                    "--verify",
456                    "-q",
457                    &format!("refs/heads/{branch}"),
458                ])
459                .unwrap_or(false);
460            let switched = if exists {
461                git.run(&["switch", "-q", &branch])
462            } else {
463                git.run(&["switch", "-q", "-c", &branch, "HEAD"])
464            };
465            if let Err(e) = switched {
466                let _ = std::fs::remove_file(Self::path(plane_dir));
467                return Err(InPlaceError::new(
468                    "DIRTY-CHECKOUT",
469                    format!("cannot switch to {branch}: {}", e.0),
470                ));
471            }
472            // The person's committed spec changes reach the branch.
473            if exists
474                && let Original::Branch(b) = &original
475                && !git
476                    .succeeds(&["merge-base", "--is-ancestor", b, "HEAD"])
477                    .unwrap_or(true)
478            {
479                let mut merge = crate::attempts::IDENTITY.to_vec();
480                merge.extend(["merge", "-q", "--no-edit", b.as_str()]);
481                if git.run(&merge).is_err() {
482                    let _ = git.run(&["merge", "--abort"]);
483                    let _ = git.run(&["switch", "-q", original.target()]);
484                    let _ = std::fs::remove_file(Self::path(plane_dir));
485                    return Err(InPlaceError::new(
486                        "MERGE-CONFLICT",
487                        format!("{b} does not merge cleanly into {branch}"),
488                    )
489                    .fix(&[&format!(
490                        "git switch {branch} && git merge {b}   # resolve, commit, then run again"
491                    )]));
492                }
493            }
494        }
495        session.tip = git.line(&["rev-parse", "HEAD"]).unwrap_or_default();
496        session.manifest = manifest(&project, policy);
497        session.git = git_fingerprint(&checkout, &branch);
498        session.hidden = git
499            .run(&[
500                "ls-files",
501                "-z",
502                "--others",
503                "--ignored",
504                "--exclude-standard",
505                "--directory",
506            ])
507            .map(|b| {
508                String::from_utf8_lossy(&b)
509                    .split('\0')
510                    .filter(|p| !p.is_empty())
511                    .map(str::to_owned)
512                    .collect()
513            })
514            .unwrap_or_default();
515        session
516            .save(plane_dir)
517            .map_err(|e| InPlaceError::new("IN-PLACE-ACTIVE", e))?;
518        Ok(session)
519    }
520
521    /// Saves what a dead run left in the checkout under an attempt ref,
522    /// restores the branch tip, and forgets the attempts that cannot resume
523    /// any more. `None` when there was nothing to save.
524    fn set_aside(&self, plane_dir: &Path, reason: &str) -> Result<Option<String>, InPlaceError> {
525        // A call that waited for a person waits on nothing now. One that
526        // cannot be withdrawn stops this: its attempt would resume into a
527        // checkout that no longer holds its writes.
528        crate::approvals::withdraw_waiting(&self.project, plane_dir, &self.feature, reason)
529            .map_err(|e| {
530                InPlaceError::new(
531                    "RETURN-BLOCKED",
532                    format!("the calls waiting for a person could not be withdrawn: {e}"),
533                )
534            })?;
535        let _ = std::fs::remove_dir_all(plane_dir.join("resume").join(&self.feature));
536        if worktree::require_clean(&self.checkout).is_ok() {
537            return Ok(None);
538        }
539        let tmp = plane_dir.join("tmp");
540        let record = crate::attempts::Record {
541            run: None,
542            outcome: "interrupted",
543            reason,
544            pending: None,
545            usage: None,
546            story_open: &[],
547            own_test: None,
548            extra: "",
549        };
550        let saved = crate::attempts::save(&self.checkout, &tmp, &self.feature, "in-place", &record)
551            .map_err(|e| InPlaceError::new("RETURN-BLOCKED", e.0))?;
552        let git = Git::at(&self.checkout);
553        let head_tree =
554            worktree::Checkpoint(git.line(&["rev-parse", "HEAD^{tree}"]).unwrap_or_default());
555        worktree::restore(&self.checkout, &tmp, &head_tree)
556            .map_err(|e| InPlaceError::new("RETURN-BLOCKED", e.0))?;
557        Ok(Some(saved.reference))
558    }
559
560    /// The checkout guard and the manifest, before every task: HEAD where
561    /// speccoder left it, nothing changed, the protected files as recorded.
562    pub fn check(&self, policy: &Policy) -> Result<(), InPlaceError> {
563        self.check_with(policy, true)
564    }
565
566    /// [`Session::check`] before a tick: the attempt's own writes are in the
567    /// checkout then, so only HEAD and the protected files are compared.
568    pub fn check_before_tick(&self, policy: &Policy) -> Result<(), InPlaceError> {
569        self.check_with(policy, false)
570    }
571
572    fn check_with(&self, policy: &Policy, clean: bool) -> Result<(), InPlaceError> {
573        // Where the sandbox cannot keep `.git` read-only, a change is caught
574        // here — before speccoder runs git there again for a tick.
575        if !self.git.is_empty() && git_fingerprint(&self.checkout, &self.branch) != self.git {
576            return Err(InPlaceError::new(
577                "GIT-CHANGED",
578                "the repository's configuration, hooks or branches changed during the run; speccoder does not run git against them",
579            )
580            .fix(&[
581                "git -C . config --list --local   # review, restore what changed",
582                "speccoder run --in-place --return",
583            ]));
584        }
585        let git = Git::at(&self.checkout);
586        let head_ref = git
587            .line(&["symbolic-ref", "-q", "HEAD"])
588            .unwrap_or_default();
589        let head = git.line(&["rev-parse", "HEAD"]).unwrap_or_default();
590        let fix = [
591            format!("git stash -u && git switch {}", self.original.target()),
592            "speccoder run --in-place --return".to_owned(),
593        ];
594        if head_ref != format!("refs/heads/{}", self.branch) || head != self.tip {
595            let mut e = InPlaceError::new(
596                "CHECKOUT-CHANGED",
597                format!(
598                    "the checkout moved during the run (HEAD is {}, speccoder left {} at {}); nothing was switched",
599                    if head_ref.is_empty() {
600                        head.clone()
601                    } else {
602                        head_ref.clone()
603                    },
604                    self.branch,
605                    self.tip.chars().take(10).collect::<String>()
606                ),
607            );
608            e.fix = fix.to_vec();
609            return Err(e);
610        }
611        if let Some(r) = clean
612            .then(|| worktree::require_clean(&self.checkout).err())
613            .flatten()
614        {
615            let mut e = InPlaceError::new(
616                "CHECKOUT-CHANGED",
617                "the checkout changed during the run; nothing was saved, restored or switched",
618            );
619            e.paths = r.paths;
620            e.fix = fix.to_vec();
621            return Err(e);
622        }
623        let now = manifest(&self.project, policy);
624        let changed = diff(&self.manifest, &now);
625        if !changed.is_empty() {
626            let intent = changed.iter().any(|(p, _)| {
627                policy.write_protected(p) == Some(crate::tools::paths::Class::Intent)
628            });
629            let mut e = InPlaceError::new(
630                if intent {
631                    "INTENT-CHANGED"
632                } else {
633                    "JUDGE-CHANGED"
634                },
635                if intent {
636                    "the spec changed under the running judge; the run stops before judging against it"
637                } else {
638                    "a judge input changed under the running judge"
639                },
640            );
641            e.paths = changed;
642            e.fix = vec![
643                "review the change, then run again: the next run judges against the spec as it is"
644                    .to_owned(),
645            ];
646            return Err(e);
647        }
648        Ok(())
649    }
650
651    /// After a speccoder commit: the new tip.
652    pub fn advance(&mut self, plane_dir: &Path) {
653        self.tip = Git::at(&self.checkout)
654            .line(&["rev-parse", "HEAD"])
655            .unwrap_or_default();
656        let _ = self.save(plane_dir);
657    }
658
659    /// After the engine's tick or append of `file` (absolute): the manifest
660    /// takes its new hash — only when the change is exactly that (`accept`
661    /// says what the bytes before and after may be).
662    pub fn accept(
663        &mut self,
664        plane_dir: &Path,
665        file: &Path,
666        before: &[u8],
667        accept: impl Fn(&[u8], &[u8]) -> bool,
668    ) -> Result<(), InPlaceError> {
669        let after = std::fs::read(file).unwrap_or_default();
670        let rel = file
671            .strip_prefix(&self.project)
672            .map(|r| r.to_string_lossy().replace('\\', "/"))
673            .unwrap_or_default();
674        if !accept(before, &after) {
675            return Err(InPlaceError::new(
676                "INTENT-CHANGED",
677                format!("{rel} changed beyond the engine's own write"),
678            ));
679        }
680        self.manifest.insert(rel, sha256_hex(&after));
681        let _ = self.save(plane_dir);
682        Ok(())
683    }
684
685    /// Switches the checkout back to where the person was, when it is as
686    /// speccoder left it; removes the record after.
687    pub fn finish(&self, plane_dir: &Path) -> Result<(), InPlaceError> {
688        let git = Git::at(&self.checkout);
689        let head = git.line(&["rev-parse", "HEAD"]).unwrap_or_default();
690        let on_branch = git
691            .line(&["symbolic-ref", "-q", "HEAD"])
692            .unwrap_or_default()
693            == format!("refs/heads/{}", self.branch);
694        if !on_branch || head != self.tip || worktree::require_clean(&self.checkout).is_err() {
695            return Err(InPlaceError::new(
696                "RETURN-BLOCKED",
697                format!(
698                    "the checkout is not as speccoder left it, so it stays on {}",
699                    self.branch
700                ),
701            )
702            .fix(&[
703                &format!("git stash -u && git switch {}", self.original.target()),
704                "speccoder run --in-place --return",
705            ]));
706        }
707        if self.original != Original::Branch(self.branch.clone()) {
708            let back = match &self.original {
709                Original::Branch(b) => git.run(&["switch", "-q", b]),
710                Original::Detached(c) => git.run(&["switch", "-q", "--detach", c]),
711            };
712            if let Err(e) = back {
713                return Err(InPlaceError::new("RETURN-BLOCKED", e.0)
714                    .fix(&[&format!("git switch {}", self.original.target())]));
715            }
716        }
717        let _ = std::fs::remove_file(Self::path(plane_dir));
718        Ok(())
719    }
720}
721
722impl Session {
723    /// A run killed between its verdict commit and the local tick: each
724    /// open task whose newest commit names its line exactly (`Task-Line`) is
725    /// ticked now, the manifest accepting it. Returns what was ticked.
726    pub fn reconcile(
727        &mut self,
728        plane_dir: &Path,
729        tasks_file: &Path,
730        tasks: &speccoder_core::snapshot::ParsedTasks,
731        tick: impl Fn(&Path, &str, &str) -> Result<(), String>,
732    ) -> Vec<String> {
733        let log = Git::at(&self.checkout)
734            .run(&[
735                "log",
736                "--format=%(trailers:key=Spec-Task,valueonly)\u{1f}%(trailers:key=Task-Line,valueonly)\u{1e}",
737                &self.branch,
738            ])
739            .map(|b| String::from_utf8_lossy(&b).into_owned())
740            .unwrap_or_default();
741        let mut newest: BTreeMap<String, String> = BTreeMap::new();
742        for record in log.split('\u{1e}') {
743            let Some((task, line)) = record.trim().split_once('\u{1f}') else {
744                continue;
745            };
746            if let Some(id) = task.trim().strip_prefix(&format!("{}/", self.feature)) {
747                newest
748                    .entry(id.to_owned())
749                    .or_insert_with(|| line.trim().to_owned());
750            }
751        }
752        let mut ticked = Vec::new();
753        for t in tasks.tasks.iter().filter(|t| !t.done) {
754            if newest
755                .get(&t.id)
756                .is_none_or(|l| l.is_empty() || *l != t.line_hash)
757            {
758                continue;
759            }
760            let before = std::fs::read(tasks_file).unwrap_or_default();
761            if tick(tasks_file, &t.id, &t.line_hash).is_ok()
762                && self.accept(plane_dir, tasks_file, &before, is_tick).is_ok()
763            {
764                ticked.push(t.id.clone());
765            }
766        }
767        if !ticked.is_empty() {
768            self.notes.push(format!(
769                "ticked {} from {} verdict commit(s): the run was interrupted before the tick",
770                ticked.join(", "),
771                ticked.len()
772            ));
773        }
774        ticked
775    }
776}
777
778/// What committing a task in place did.
779#[derive(Debug, Default)]
780pub struct Committed {
781    /// The verdict commit.
782    pub commit: Option<String>,
783    /// Whether the task is ticked.
784    pub ticked: bool,
785    /// What went wrong, for the report's integrity list.
786    pub problems: Vec<String>,
787}
788
789impl Session {
790    /// Commits a passed (or attested) task and ticks it. The evidence comes
791    /// first: a `tasks_file` git ignores is never in a commit, so the commit
792    /// names the tick as local (`Tick: local`), and the tick follows it. A
793    /// tracked one is ticked and committed together, as in a worktree.
794    /// Every commit says it was made in place, against which intent
795    /// (`intent_digest`) and which task line (`line_hash`, before the tick).
796    pub fn commit_and_tick(
797        &mut self,
798        plane_dir: &Path,
799        tasks_file: &Path,
800        line_hash: &str,
801        message: &str,
802        intent_digest: &str,
803        tick: impl Fn() -> Result<(), String>,
804    ) -> Committed {
805        let mut out = Committed::default();
806        let rel = tasks_file
807            .strip_prefix(&self.checkout)
808            .map(|r| r.to_string_lossy().replace('\\', "/"))
809            .unwrap_or_default();
810        let tracked = Git::at(&self.checkout)
811            .succeeds(&["ls-files", "--error-unmatch", "--", &rel])
812            .unwrap_or(false);
813        let mut trailers = format!(
814            "Mode: in-place\nIntent-Digest: sha256:{intent_digest}\nTask-Line: {line_hash}\n"
815        );
816        if !tracked {
817            trailers.push_str(&format!("Tick: local {rel}\n"));
818        }
819        let message = format!("{}\n{trailers}", message.trim_end());
820        let before = std::fs::read(tasks_file).unwrap_or_default();
821        let tick_now = |out: &mut Committed| match tick() {
822            Ok(()) => out.ticked = true,
823            Err(e) => out.problems.push(format!("tick refused: {e}")),
824        };
825        if tracked {
826            tick_now(&mut out);
827        }
828        if out.ticked || !tracked {
829            match crate::pipeline::commit::commit_attempt_as(&self.checkout, &message, !tracked) {
830                Ok(id) => out.commit = Some(id),
831                Err(e) => out.problems.push(format!("commit failed: {e}")),
832            }
833        }
834        if !tracked && out.commit.is_some() {
835            tick_now(&mut out);
836        }
837        if out.ticked
838            && let Err(e) = self.accept(plane_dir, tasks_file, &before, is_tick)
839        {
840            out.problems.push(e.message);
841        }
842        self.advance(plane_dir);
843        out
844    }
845}
846
847impl Session {
848    /// Commits a convergence round in place: the append to `tasks_file`
849    /// (old bytes an exact prefix of the new) is accepted into the manifest,
850    /// and the commit says it was made in place, against which intent; a
851    /// git-ignored task list is not in it, so the commit may be empty.
852    pub fn commit_round(
853        &mut self,
854        plane_dir: &Path,
855        tasks_file: &Path,
856        before: &[u8],
857        message: &str,
858        intent_digest: &str,
859    ) -> Result<String, String> {
860        self.accept(plane_dir, tasks_file, before, |b, a| a.starts_with(b))
861            .map_err(|e| e.message)?;
862        let rel = tasks_file
863            .strip_prefix(&self.checkout)
864            .map(|r| r.to_string_lossy().replace('\\', "/"))
865            .unwrap_or_default();
866        let tracked = Git::at(&self.checkout)
867            .succeeds(&["ls-files", "--error-unmatch", "--", &rel])
868            .unwrap_or(false);
869        let mut trailers = format!("Mode: in-place\nIntent-Digest: sha256:{intent_digest}\n");
870        if !tracked {
871            trailers.push_str(&format!("Tasks: local {rel}\n"));
872        }
873        let message = format!("{}\n{trailers}", message.trim_end());
874        let id = crate::pipeline::commit::commit_attempt_as(&self.checkout, &message, !tracked)?;
875        self.advance(plane_dir);
876        Ok(id)
877    }
878
879    /// The model's view of this checkout: the policy denies git that moves
880    /// it and hides what git ignored; the sandbox keeps `.git` read-only
881    /// and the ignored secrets unreadable.
882    #[must_use]
883    pub fn confine(
884        &self,
885        policy: Policy,
886        runner: crate::sandbox::Runner,
887    ) -> (Policy, crate::sandbox::Runner, Option<String>) {
888        let secrets: Vec<PathBuf> = self
889            .hidden
890            .iter()
891            .map(|h| h.trim_end_matches('/'))
892            .filter(|h| policy.protected().is_secret(h))
893            .map(|h| self.checkout.join(h))
894            .collect();
895        let (runner, notice) = runner.carve(&[self.checkout.join(".git")], &secrets);
896        (
897            policy.in_place().with_hidden(self.hidden.clone()),
898            runner,
899            notice,
900        )
901    }
902}
903
904/// A hash over what in `.git` could make git run code or move a branch the
905/// person owns: `config`, `hooks/`, `info/`, and every branch and tag except
906/// `branch` (speccoder's own).
907fn git_fingerprint(checkout: &Path, branch: &str) -> String {
908    let git = Git::at(checkout);
909    let Some(dir) = git
910        .line(&["rev-parse", "--path-format=absolute", "--git-common-dir"])
911        .ok()
912        .map(PathBuf::from)
913    else {
914        return String::new();
915    };
916    let mut parts = Vec::new();
917    let mut files = vec![dir.join("config")];
918    for sub in ["hooks", "info"] {
919        files.extend(
920            ignore::WalkBuilder::new(dir.join(sub))
921                .standard_filters(false)
922                .build()
923                .flatten()
924                .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
925                .map(ignore::DirEntry::into_path),
926        );
927    }
928    files.sort();
929    for f in files {
930        let hash = std::fs::read(&f).map_or_else(|_| "-".to_owned(), |b| sha256_hex(&b));
931        parts.push(format!("{} {hash}", f.display()));
932    }
933    let own = format!("refs/heads/{branch} ");
934    let refs = git
935        .run(&[
936            "for-each-ref",
937            "--format=%(refname) %(objectname)",
938            "refs/heads",
939            "refs/tags",
940        ])
941        .map(|b| String::from_utf8_lossy(&b).into_owned())
942        .unwrap_or_default();
943    parts.extend(
944        refs.lines()
945            .filter(|l| !l.starts_with(&own))
946            .map(str::to_owned),
947    );
948    sha256_hex(parts.join("\n").as_bytes())
949}
950
951/// Whether the journal holds an attempt of `feature` that started at `head`.
952fn resumable(plane_dir: &Path, feature: &str, head: &str) -> bool {
953    std::fs::read_dir(plane_dir.join("resume").join(feature))
954        .into_iter()
955        .flatten()
956        .flatten()
957        .filter_map(|e| crate::resume::load(&e.path()))
958        .any(|m| m.head == head)
959}
960
961/// `run --in-place --return`: saves what an interrupted run left, switches
962/// back to the person's branch, removes the record. No model call.
963pub fn return_checkout(plane_dir: &Path) -> Result<String, InPlaceError> {
964    let Some(session) = Session::load(plane_dir) else {
965        return Err(InPlaceError::new(
966            "IN-PLACE-SESSION",
967            "there is no in-place run to return from",
968        ));
969    };
970    if session.is_live() {
971        return Err(InPlaceError::new(
972            "IN-PLACE-ACTIVE",
973            format!("the in-place run (pid {}) is still running", session.pid),
974        ));
975    }
976    let git = Git::at(&session.checkout);
977    let saved = session.set_aside(
978        plane_dir,
979        "left by an interrupted in-place run; saved by --return",
980    )?;
981    let fresh = Session {
982        tip: git.line(&["rev-parse", "HEAD"]).unwrap_or_default(),
983        ..session.clone()
984    };
985    fresh.finish(plane_dir)?;
986    Ok(match saved {
987        Some(r) => format!(
988            "back on {}; what the interrupted run left is saved as {r}",
989            session.original.describe()
990        ),
991        None => format!("back on {}", session.original.describe()),
992    })
993}
994
995/// The project's in-place session, if one is open or was left by a run
996/// that died: what `status` and the TUI show.
997#[must_use]
998pub fn session_at(path: &Path, state: &Path) -> Option<Session> {
999    let root = crate::format::open(path, None).map_or_else(
1000        |_| std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()),
1001        |o| PathBuf::from(&o.snapshot.root),
1002    );
1003    Session::load(&worktree::project_state_dir(state, &root))
1004}
1005
1006impl Session {
1007    /// `in-place · speccoder/<f> in <checkout> (returns to <original>)`.
1008    #[must_use]
1009    pub fn mode_line(&self) -> String {
1010        format!(
1011            "in-place · {} in {} (returns to {})",
1012            self.branch,
1013            self.checkout.display(),
1014            self.original.describe()
1015        )
1016    }
1017}
1018
1019/// A worktree-mode run of a feature that an in-place session holds: the
1020/// branch is checked out in the person's checkout, so it is refused.
1021#[must_use]
1022pub fn session_elsewhere(path: &Path, feature: Option<&str>, state: &Path) -> Option<InPlaceError> {
1023    let opened = crate::format::open(path, feature).ok()?;
1024    let plane_dir = worktree::project_state_dir(state, Path::new(&opened.snapshot.root));
1025    let session = Session::load(&plane_dir)?;
1026    let wanted = opened
1027        .snapshot
1028        .feature
1029        .as_ref()
1030        .map(|f| f.feature.name.as_str());
1031    if wanted.is_some_and(|w| w != session.feature) {
1032        return None;
1033    }
1034    Some(
1035        InPlaceError::new(
1036            "IN-PLACE-SESSION",
1037            format!(
1038                "{} is being run in place in {} (on {})",
1039                session.feature,
1040                session.checkout.display(),
1041                session.branch
1042            ),
1043        )
1044        .fix(&[
1045            "speccoder run --in-place",
1046            "speccoder run --in-place --return",
1047        ]),
1048    )
1049}
1050
1051/// `run --in-place --return` for the project at `path`, with the state in `state`.
1052#[must_use]
1053pub fn return_in_place(path: &Path, state: &Path) -> CommandResult {
1054    let root = crate::format::open(path, None).map_or_else(
1055        |_| std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()),
1056        |o| PathBuf::from(&o.snapshot.root),
1057    );
1058    let plane_dir = worktree::project_state_dir(state, &root);
1059    match return_checkout(&plane_dir) {
1060        Ok(message) => CommandResult::new("run", speccoder_core::CommandStatus::Ok, &message),
1061        Err(e) => e.result("run"),
1062    }
1063}
1064
1065#[cfg(test)]
1066mod tests {
1067    use super::*;
1068
1069    #[test]
1070    fn only_the_engines_tick_is_a_tick() {
1071        let before = b"- [ ] T001 a\n- [ ] T002 b\n";
1072        let mut after = before.to_vec();
1073        after[3] = b'x';
1074        assert!(is_tick(before, &after));
1075        let mut two = after.clone();
1076        two[16] = b'X';
1077        assert!(!is_tick(before, &two), "two ticks are not one");
1078        let mut other = before.to_vec();
1079        other[8] = b'Z';
1080        assert!(!is_tick(before, &other), "any other byte is not a tick");
1081        assert!(!is_tick(before, b"- [ ] T001 a\n"));
1082    }
1083
1084    #[test]
1085    fn the_intent_digest_ignores_ticks_only() {
1086        assert_eq!(
1087            untick(b"- [x] T1\n- [X] T2\n- [ ] T3"),
1088            b"- [ ] T1\n- [ ] T2\n- [ ] T3"
1089        );
1090        assert_eq!(untick(b"a [xy] b"), b"a [xy] b");
1091    }
1092
1093    /// Re-checking a 2,000-file, 20 MB protected set takes at
1094    /// most a second (`cargo test --release -- --ignored`).
1095    #[test]
1096    #[ignore = "a timing check: run in release"]
1097    fn a_large_protected_set_is_rechecked_within_a_second() {
1098        let dir = tempfile::tempdir().unwrap();
1099        let chunk = "x".repeat(10 * 1024);
1100        for f in 0..40 {
1101            let feature = dir.path().join(format!("specs/{f:03}-f"));
1102            std::fs::create_dir_all(&feature).unwrap();
1103            for i in 0..50 {
1104                std::fs::write(feature.join(format!("checklist-{i}.md")), &chunk).unwrap();
1105            }
1106        }
1107        let policy = Policy::new(&[], &[]);
1108        let first = manifest(dir.path(), &policy);
1109        assert!(first.len() >= 2000, "{} files", first.len());
1110        let start = std::time::Instant::now();
1111        let again = manifest(dir.path(), &policy);
1112        let took = start.elapsed();
1113        assert!(diff(&first, &again).is_empty());
1114        assert!(took <= std::time::Duration::from_secs(1), "took {took:?}");
1115    }
1116
1117    #[test]
1118    fn manifests_diff_by_path() {
1119        let a: BTreeMap<String, String> = [("s.md", "1"), ("t.md", "2")]
1120            .iter()
1121            .map(|(p, h)| ((*p).to_owned(), (*h).to_owned()))
1122            .collect();
1123        let mut b = a.clone();
1124        b.insert("t.md".to_owned(), "3".to_owned());
1125        b.remove("s.md");
1126        b.insert("n.md".to_owned(), "4".to_owned());
1127        let d = diff(&a, &b);
1128        assert_eq!(
1129            d,
1130            [
1131                ("s.md".to_owned(), "deleted".to_owned()),
1132                ("t.md".to_owned(), "modified".to_owned()),
1133                ("n.md".to_owned(), "added".to_owned())
1134            ]
1135        );
1136        assert!(
1137            runtime_state(".specify/workflows/runs/x.json") && runtime_state("a/__pycache__/b.pyc")
1138        );
1139    }
1140}