mod edits;
use super::ChildResult;
use crate::facts::FILE as FACTS_FILE;
use crate::prompt::compactor::tools::SUMMARY_DIR;
use crate::prompt::dispatch::transcript::MESSAGES_DIR;
use crate::prompt::notice::notice;
use crate::prompt::{Deps, Error, role};
use crate::template::authoring::{self, Origin, Pass};
use crate::template::descriptions;
use crate::workspace::{self, SKILLS_DIR, current_config, proposal};
use edits::{admitted, changed_paths, write_patch};
use std::path::Path;
enum Staged {
Landed(String),
Empty,
Outside(String),
Stale(String),
Unstageable,
Declined(String),
}
pub(super) fn stage(
workspace: &Path,
worktree: &Path,
cr: &ChildResult,
deps: &Deps<'_>,
) -> Result<(), Error> {
report(&cr.child_id, &derive(workspace, worktree, cr, deps)?);
std::fs::remove_file(&cr.path).map_err(Error::Io)
}
fn derive(
workspace: &Path,
worktree: &Path,
cr: &ChildResult,
deps: &Deps<'_>,
) -> Result<Staged, Error> {
let git = deps.git;
let founding = role::founding_sha(worktree, &cr.terminal_ref, &cr.child_id, git)?;
let read = proposal::read_mark(worktree, &cr.child_id, git);
let (Some(founding), Some(read)) = (founding, read) else {
return Ok(Staged::Unstageable);
};
let tip = lineage_tip(workspace, &cr.child_id, git)?;
if tip != read {
return Ok(Staged::Stale(tip));
}
let changed = changed_paths(worktree, &founding, &cr.terminal_ref, git)?;
let pool = descriptions::pool_names(deps.data_root);
if let Some(outside) = changed.iter().find(|path| !admitted(path, &pool)) {
return Ok(Staged::Outside(outside.clone()));
}
if changed.is_empty() {
return Ok(Staged::Empty);
}
mint(workspace, worktree, cr, &founding, &read, deps)
}
fn lineage_tip(
workspace: &Path,
child_id: &str,
git: &dyn crate::template::GitRunner,
) -> Result<String, Error> {
let rev = workspace::agent_ref(child_id);
current_config::current_config(workspace, &rev, git)
.map(|resolution| resolution.commit().to_string())
.map_err(|source| Error::Git {
op: "proposal lineage tip",
source,
})
}
fn mint(
workspace: &Path,
worktree: &Path,
cr: &ChildResult,
founding: &str,
parent: &str,
deps: &Deps<'_>,
) -> Result<Staged, Error> {
let git = deps.git;
let patch = super::super::transfer::patch_path(&cr.child_id);
let patch_str = patch.to_string_lossy().into_owned();
write_patch(worktree, founding, &cr.terminal_ref, &patch_str, git)?;
let empty = std::fs::metadata(&patch)
.map(|m| m.len() == 0)
.unwrap_or(true);
let outcome = authoring::author(
workspace,
deps.data_root,
&cr.child_id,
Origin::Proposal {
parent,
message: cr.response.as_deref().unwrap_or(SILENT_REVIEWER),
},
|dir| {
if empty {
return Ok(());
}
git.run(dir, &["apply", &patch_str])
},
git,
);
let _ = std::fs::remove_file(&patch);
Ok(match outcome {
Ok(Pass::Landed) => Staged::Landed(workspace::proposal::proposal_ref(&cr.child_id)),
Ok(Pass::Declined { .. }) => Staged::Empty,
Err(refusal) => Staged::Declined(refusal.to_string()),
})
}
const SILENT_REVIEWER: &str = "proposal: the reviewer staged edits without a closing response";
fn report(reviewer: &str, staged: &Staged) {
match staged {
Staged::Empty => {}
Staged::Landed(target) => {
notice!("proposal [{reviewer}] staged at {target} — read it with `litany proposal`")
}
Staged::Outside(path) => notice!(
"proposal [{reviewer}] refused — {path} is outside a reviewer's two \
proposable classes (workspace skills and {FACTS_FILE}, \
docs/DESIGN_LEARNING_LOOP.md §3); nothing was staged"
),
Staged::Stale(tip) => notice!(
"proposal [{reviewer}] refused as stale — the lineage now stands at {tip}, \
not the commit the review read; the next checkpoint re-derives"
),
Staged::Unstageable => notice!(
"proposal [{reviewer}] refused — the return names no dispatch commit or no \
config-read mark, so there is no base to parent a proposal on"
),
Staged::Declined(refusal) => {
notice!("proposal [{reviewer}] refused by the config-authoring pass — {refusal}");
}
}
}
#[cfg(test)]
mod tests;