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