pub mod gate;
pub mod smtp;
pub mod trust;
use crate::action::gate::Gate;
use crate::action::trust::TrustRule;
use crate::gateway::proxy::Body;
use crate::imp::journal;
use crate::paths;
use crate::util::now_rfc3339;
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::{Response, StatusCode};
use serde::Deserialize;
use serde_json::{json, Value};
use std::fs::OpenOptions;
use std::io::Write;
use std::sync::atomic::{AtomicU64, Ordering};
pub const ACTION_HOST: &str = "actions.impyard.internal";
#[derive(Debug, Deserialize)]
pub struct Envelope {
pub intent: String,
#[serde(default)]
pub payload: Value,
#[serde(default)]
pub rationale: String,
#[serde(default)]
pub run_id: String,
#[serde(default)]
pub task_id: String,
}
fn org_scope() -> String {
"org".to_string()
}
fn gate_default() -> String {
"gate".to_string()
}
#[derive(Debug, Clone, Deserialize)]
pub struct ActionGrant {
#[serde(default = "org_scope")]
pub scope: String,
pub name: String,
pub executor: String,
#[serde(default = "gate_default")]
pub trust: String,
#[serde(default)]
pub wake_on_resolve: bool,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ActionPolicy {
#[serde(default)]
pub actions: Vec<ActionGrant>,
#[serde(default)]
pub trust: Vec<TrustRule>,
}
pub fn load_action_policy() -> ActionPolicy {
crate::config::snapshot()
.map(|c| c.actions.clone())
.unwrap_or_default()
}
fn grant_for<'a>(policy: &'a ActionPolicy, imp: &str, intent: &str) -> Option<&'a ActionGrant> {
policy
.actions
.iter()
.find(|a| crate::gateway::scope::applies(&a.scope, imp) && a.name == intent)
}
fn channel_payload_trusted(payload: &Value) -> bool {
payload
.get("channel_id")
.and_then(|v| v.as_str())
.map(crate::channel::discord::channel_trusted)
.unwrap_or(false)
}
fn reply(status: StatusCode, v: Value) -> Response<Body> {
let mut resp = Response::new(
Full::new(Bytes::from(v.to_string()))
.map_err(|n| match n {})
.boxed(),
);
*resp.status_mut() = status;
resp.headers_mut().insert(
hyper::header::CONTENT_TYPE,
"application/json".parse().unwrap(),
);
resp
}
pub async fn handle_action(
imp: &str,
trusted_run_id: &str,
method: &str,
path: &str,
body: &[u8],
) -> Response<Body> {
match (method, path) {
("POST", "/submit") => submit(imp, trusted_run_id, body).await,
("GET", "/gates") => {
let gates: Vec<Value> = crate::action::gate::for_imp(imp)
.iter()
.map(|g| g.summary())
.collect();
reply(StatusCode::OK, json!({ "gates": gates }))
}
("GET", "/journal") => reply(StatusCode::OK, json!({ "events": journal::tail(imp, 30) })),
("GET", "/memory") => {
let memories = crate::imp::memory::visible_to_current_actor(imp, trusted_run_id);
let user_settings = crate::imp::memory::current_user_settings(imp, trusted_run_id);
journal::append(
imp,
trusted_run_id,
"memory-read",
json!({ "note_ids": memories.iter().map(|n| n.id.as_str()).collect::<Vec<_>>() }),
);
reply(
StatusCode::OK,
json!({ "memories": memories, "user_settings": user_settings }),
)
}
_ => reply(
StatusCode::NOT_FOUND,
json!({ "status": "error", "error": "unknown action endpoint" }),
),
}
}
async fn submit(imp: &str, trusted_run_id: &str, body: &[u8]) -> Response<Body> {
let env: Envelope = match serde_json::from_slice(body) {
Ok(e) => e,
Err(e) => {
return reply(
StatusCode::BAD_REQUEST,
json!({ "status": "error", "error": format!("bad envelope: {e}") }),
)
}
};
let run_id = if trusted_run_id.is_empty() {
env.run_id.clone()
} else {
trusted_run_id.to_string()
};
let policy = load_action_policy();
let Some(grant) = grant_for(&policy, imp, &env.intent) else {
journal::append(
imp,
&run_id,
"action-refused",
json!({ "intent": env.intent, "reason": "no action grant in scope" }),
);
audit(imp, &env.intent, "refused", None, None);
return reply(
StatusCode::FORBIDDEN,
json!({ "status": "denied", "reason": format!("no action grant for \"{}\"", env.intent) }),
);
};
let grant = grant.clone();
if grant.executor == "note" && trusted_run_id.is_empty() {
journal::append(
imp,
&run_id,
"action-refused",
json!({ "intent": env.intent, "reason": "run-scoped action has no trusted run context" }),
);
audit(imp, &env.intent, "refused", None, None);
return reply(
StatusCode::FORBIDDEN,
json!({ "status": "denied", "reason": "run-scoped action has no trusted run context" }),
);
}
journal::append(
imp,
&run_id,
"action-proposed",
json!({ "intent": env.intent, "rationale": env.rationale, "run_id": run_id }),
);
let (executed, denied) = gate::history(imp, &env.intent);
let level = if grant.executor == "note" {
let context = crate::imp::memory::load_run_context(&run_id);
match crate::imp::memory::action_trust(imp, &env.intent, &env.payload, &context) {
Ok(level) => level.to_string(),
Err(reason) => {
journal::append(
imp,
&run_id,
"action-refused",
json!({ "intent": env.intent, "reason": reason }),
);
audit(imp, &env.intent, "refused", None, None);
return reply(
StatusCode::FORBIDDEN,
json!({ "status": "denied", "reason": reason }),
);
}
}
} else if grant.executor == "identity" {
"gate".to_string()
} else if (grant.executor == "discord"
|| grant.executor == "slack"
|| grant.executor == "purpose")
&& channel_payload_trusted(&env.payload)
{
"auto".to_string()
} else {
trust::evaluate(
imp,
&env.intent,
&env.payload,
&grant.trust,
&policy.trust,
executed,
denied,
)
};
if level == "auto" {
match run_executor(&grant.executor, imp, &env.intent, &env.payload, &run_id).await {
Ok(result) => {
journal::append(
imp,
&run_id,
"executed",
json!({ "intent": env.intent, "auto": true, "result": result }),
);
audit(imp, &env.intent, "auto-executed", None, Some(&result));
reply(
StatusCode::OK,
json!({ "status": "done", "result": result }),
)
}
Err(e) => {
journal::append(
imp,
&run_id,
"failed",
json!({ "intent": env.intent, "auto": true, "error": e }),
);
audit(imp, &env.intent, "failed", None, None);
reply(StatusCode::OK, json!({ "status": "error", "error": e }))
}
}
} else {
let g = Gate {
id: gate::new_id(),
imp: imp.to_string(),
intent: env.intent.clone(),
executor: grant.executor.clone(),
payload: env.payload.clone(),
rationale: env.rationale.clone(),
run_id: run_id.clone(),
task_id: env.task_id.clone(),
state: "pending".into(),
filed_at: gate::now(),
decided_by: None,
decided_at: None,
decision_note: None,
executed_at: None,
result: None,
error: None,
};
if let Err(e) = gate::save(&g) {
return reply(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "status": "error", "error": format!("could not file gate: {e}") }),
);
}
journal::append(
imp,
&run_id,
"gate-filed",
json!({ "gate_id": g.id, "intent": env.intent, "rationale": env.rationale }),
);
audit(imp, &env.intent, "gated", Some(&g.id), None);
reply(
StatusCode::ACCEPTED,
json!({ "status": "pending", "gate_id": g.id, "message": "held for human approval" }),
)
}
}
pub async fn execute_gate(id: &str, decided_by: &str, note: Option<&str>) -> Result<Gate, String> {
let mut g = gate::load(id).ok_or_else(|| format!("no such gate {id}"))?;
if g.state == "executed" {
return Err(format!("gate {id} already executed"));
}
if g.state == "denied" {
return Err(format!("gate {id} was denied"));
}
if g.state == "pending" {
g.state = "approved".into();
g.decided_by = Some(decided_by.to_string());
g.decided_at = Some(now_rfc3339());
g.decision_note = note.map(String::from);
gate::save(&g).map_err(|e| e.to_string())?;
journal::append(
&g.imp,
&g.run_id,
"approved",
json!({ "gate_id": g.id, "by": decided_by, "note": note }),
);
}
g.state = "executing".into();
gate::save(&g).map_err(|e| e.to_string())?;
match run_executor(&g.executor, &g.imp, &g.intent, &g.payload, &g.run_id).await {
Ok(result) => {
g.state = "executed".into();
g.executed_at = Some(now_rfc3339());
g.result = Some(result.clone());
gate::save(&g).map_err(|e| e.to_string())?;
journal::append(
&g.imp,
&g.run_id,
"executed",
json!({ "gate_id": g.id, "intent": g.intent, "result": result }),
);
audit(&g.imp, &g.intent, "executed", Some(&g.id), Some(&result));
resolve_followup(&g);
Ok(g)
}
Err(e) => {
g.state = "failed".into();
g.error = Some(e.clone());
gate::save(&g).map_err(|e| e.to_string())?;
journal::append(
&g.imp,
&g.run_id,
"failed",
json!({ "gate_id": g.id, "intent": g.intent, "error": e }),
);
audit(&g.imp, &g.intent, "failed", Some(&g.id), None);
Err(e)
}
}
}
pub fn deny_gate(id: &str, decided_by: &str, note: Option<&str>) -> Result<Gate, String> {
let mut g = gate::load(id).ok_or_else(|| format!("no such gate {id}"))?;
if g.is_terminal() {
return Err(format!("gate {id} is already {}", g.state));
}
g.state = "denied".into();
g.decided_by = Some(decided_by.to_string());
g.decided_at = Some(now_rfc3339());
g.decision_note = note.map(String::from);
gate::save(&g).map_err(|e| e.to_string())?;
journal::append(
&g.imp,
&g.run_id,
"denied",
json!({ "gate_id": g.id, "by": decided_by, "note": note }),
);
audit(&g.imp, &g.intent, "denied", Some(&g.id), None);
resolve_followup(&g);
Ok(g)
}
fn resolve_followup(g: &Gate) {
if g.task_id.is_empty() {
return;
}
if let Some(mut task) = crate::work::queue::find(&g.task_id) {
if task.state == "needs-review" && gate::pending_for_task(&task.id).is_empty() {
let _ = crate::work::queue::set_state(&mut task, "done");
}
}
let policy = load_action_policy();
let wake = grant_for(&policy, &g.imp, &g.intent)
.map(|a| a.wake_on_resolve)
.unwrap_or(false);
if !wake {
return;
}
let short = g.imp.strip_prefix("org/").unwrap_or(&g.imp).to_string();
let outcome = if g.state == "executed" {
format!(
"was approved and executed. Result: {}",
g.result.clone().unwrap_or(json!(null))
)
} else {
format!(
"was denied{}",
g.decision_note
.as_deref()
.map(|n| format!(" ({n})"))
.unwrap_or_default()
)
};
let prompt = format!(
"A previous action you proposed — {} (gate {}) — {}. Decide whether any follow-up is needed; if not, you are done.",
g.intent, g.id, outcome
);
let context = json!({ "resolved_gate": { "id": g.id, "intent": g.intent, "state": g.state, "result": g.result, "decided_by": g.decided_by, "note": g.decision_note } });
let _ = crate::work::queue::create(
&short,
&prompt,
"continuation",
false,
15.0,
"append",
context,
None,
None,
);
journal::append(
&g.imp,
&g.run_id,
"continuation-filed",
json!({ "gate_id": g.id, "intent": g.intent }),
);
}
pub async fn run_executor(
executor: &str,
imp: &str,
intent: &str,
payload: &Value,
run_id: &str,
) -> Result<Value, String> {
match executor {
"message-user" => exec_message_user(imp, payload).await,
"email" => exec_email(imp, payload).await,
"git-pr" => exec_git_pr(imp, run_id, payload),
"identity" => exec_identity(imp, payload),
"purpose" => exec_purpose(payload),
"discord" => exec_discord(imp, payload).await,
"slack" => exec_slack(imp, payload).await,
"task" => exec_file_task(imp, payload, run_id),
"note" => crate::imp::memory::execute(imp, intent, payload, run_id),
other => Err(format!("no executor \"{other}\" for intent \"{intent}\"")),
}
}
fn exec_file_task(imp: &str, payload: &Value, run_id: &str) -> Result<Value, String> {
if run_id.is_empty() {
return Err("file-task needs a trusted run context".into());
}
let prompt = payload
.get("prompt")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or("file-task needs a non-empty \"prompt\"")?;
let ceiling = payload
.get("ceiling_min")
.and_then(|v| v.as_f64())
.unwrap_or(30.0)
.clamp(1.0, 240.0);
let context = crate::imp::memory::load_run_context(run_id);
if let Err(reason) = crate::imp::boundary::check_task_prompt(&context, prompt) {
journal::append(
imp,
run_id,
"boundary-denied",
json!({ "intent": "file-task", "reason": reason }),
);
return Err(reason);
}
let task = crate::work::queue::create(
crate::paths::short_imp(imp),
prompt,
"imp",
true, ceiling,
"append",
Value::Null,
None,
None,
)
.map_err(|e| e.to_string())?;
journal::append(
imp,
run_id,
"task-filed",
json!({ "task_id": task.id, "prompt": prompt }),
);
eprintln!("file-task [{imp}] → {} ({} min)", task.id, ceiling);
Ok(json!({ "task_id": task.id, "state": "waiting" }))
}
async fn exec_message_user(imp: &str, payload: &Value) -> Result<Value, String> {
let text = payload
.get("text")
.and_then(|v| v.as_str())
.ok_or("message-user needs a \"text\" field")?;
if let Some(cred) = crate::credential::vault::get_credential("discord") {
let token = cred.get("token").and_then(|v| v.as_str());
let owner = cred
.get("owner_id")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty());
if let (Some(token), Some(owner)) = (token, owner) {
match crate::channel::discord::open_dm(token, owner).await {
Ok(dm) => match crate::channel::discord::post_message(token, &dm, text).await {
Ok(_) => {
eprintln!("message-user [{imp}] → lead DM");
return Ok(json!({ "delivered": "discord-dm" }));
}
Err(e) => {
eprintln!("message-user: DM post failed ({e}); trying other channels")
}
},
Err(e) => eprintln!("message-user: open DM failed ({e}); trying other channels"),
}
}
}
if let Some(cred) = crate::credential::vault::get_credential(&slack_credential_name(imp)) {
let token = cred.get("bot_token").and_then(|v| v.as_str());
let owner = cred
.get("owner_id")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty());
if let (Some(token), Some(owner)) = (token, owner) {
match crate::channel::slack::open_dm(token, owner).await {
Ok(dm) => match crate::channel::slack::post_message(token, &dm, text, None).await {
Ok(_) => {
eprintln!("message-user [{imp}] → lead Slack DM");
return Ok(json!({ "delivered": "slack-dm" }));
}
Err(e) => eprintln!("message-user: Slack DM post failed ({e}); using inbox"),
},
Err(e) => eprintln!("message-user: Slack open DM failed ({e}); using inbox"),
}
}
}
let path = paths::messages_log();
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&path) {
let _ = writeln!(
f,
"{}",
json!({ "ts": now_rfc3339(), "imp": imp, "text": text })
);
}
eprintln!("message-user [{imp}]: {text}");
Ok(json!({ "delivered": "inbox" }))
}
async fn exec_discord(imp: &str, payload: &Value) -> Result<Value, String> {
let channel = payload
.get("channel_id")
.and_then(|v| v.as_str())
.ok_or("discord-send needs a \"channel_id\"")?;
let text = payload
.get("text")
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.ok_or("discord-send needs non-empty \"text\"")?;
let cred = crate::credential::vault::get_credential("discord")
.ok_or("no discord credential — run: impyard server vault connect discord")?;
let token = cred
.get("token")
.and_then(|v| v.as_str())
.ok_or("discord credential has no token")?;
let id = crate::channel::discord::post_message(token, channel, text).await?;
eprintln!("discord [{imp}] → channel {channel}");
Ok(json!({ "sent": true, "channel_id": channel, "message_id": id }))
}
fn slack_credential_name(imp: &str) -> String {
let short = crate::paths::short_imp(imp).to_string();
crate::config::snapshot()
.ok()
.and_then(|c| {
c.listeners
.iter()
.find(|(w, platform, _)| *w == short && platform == "slack")
.map(|(_, _, credential)| credential.clone())
})
.unwrap_or_else(|| "slack".to_string())
}
async fn exec_slack(imp: &str, payload: &Value) -> Result<Value, String> {
let channel = payload
.get("channel_id")
.and_then(|v| v.as_str())
.ok_or("slack-send needs a \"channel_id\"")?;
let text = payload
.get("text")
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.ok_or("slack-send needs non-empty \"text\"")?;
let thread_ts = payload
.get("thread_ts")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty());
let name = slack_credential_name(imp);
let cred = crate::credential::vault::get_credential(&name).ok_or_else(|| {
format!("no \"{name}\" credential — run: impyard server vault connect slack")
})?;
let token = cred
.get("bot_token")
.and_then(|v| v.as_str())
.ok_or("slack credential has no bot_token")?;
let ts = crate::channel::slack::post_message(token, channel, text, thread_ts).await?;
eprintln!("slack [{imp}] → channel {channel}");
Ok(json!({ "sent": true, "channel_id": channel, "message_ts": ts }))
}
async fn exec_email(imp: &str, payload: &Value) -> Result<Value, String> {
let to: Vec<String> = payload
.get("to")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect()
})
.filter(|v: &Vec<String>| !v.is_empty())
.ok_or("email needs a non-empty \"to\" array")?;
let subject = payload
.get("subject")
.and_then(|v| v.as_str())
.unwrap_or("");
let body = payload.get("body").and_then(|v| v.as_str()).unwrap_or("");
if let Some(cfg) = smtp_config() {
let status = crate::action::smtp::send(&cfg, &to, subject, body)
.await
.map_err(|e| format!("smtp send failed: {e}"))?;
eprintln!("email [{imp}] → {to:?} via {}: {subject}", cfg.host);
return Ok(
json!({ "delivered": "smtp", "provider": cfg.host, "to": to, "status": status }),
);
}
if std::env::var("IMPYARD_EMAIL_SINK").is_err() {
return Err("email not sent: no SMTP configured — run `impyard server vault connect smtp` (e.g. your Mailgun SMTP creds)".into());
}
let dir = paths::outbox_dir();
let _ = std::fs::create_dir_all(&dir);
let file = dir.join(format!(
"{}-{}.json",
now_rfc3339().replace(':', "-"),
imp.replace('/', "_")
));
let rendered = json!({ "from": imp, "to": to, "subject": subject, "body": body });
std::fs::write(
&file,
format!(
"{}\n",
serde_json::to_string_pretty(&rendered).unwrap_or_default()
),
)
.map_err(|e| e.to_string())?;
eprintln!(
"email [{imp}] → {to:?}: {subject} (IMPYARD_EMAIL_SINK — wrote local sink, NOT sent)"
);
Ok(json!({ "delivered": "local-sink", "to": to, "file": file.display().to_string() }))
}
fn smtp_config() -> Option<crate::action::smtp::SmtpConfig> {
let c = crate::credential::vault::get_credential("smtp")?;
Some(crate::action::smtp::SmtpConfig {
host: c.get("host")?.as_str()?.to_string(),
port: c.get("port").and_then(|v| v.as_u64()).unwrap_or(465) as u16,
user: c.get("user")?.as_str()?.to_string(),
pass: c.get("pass")?.as_str()?.to_string(),
from: c.get("from")?.as_str()?.to_string(),
})
}
fn exec_git_pr(imp: &str, run_id: &str, payload: &Value) -> Result<Value, String> {
if run_id.is_empty() {
return Err("code-change has no run_id — cannot find the worktree".into());
}
let wt = paths::run_dir(run_id).join("worktree");
if !wt.exists() {
return Err(format!("no worktree at {}", wt.display()));
}
let wt = wt.display().to_string();
let message = payload
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("changes proposed by imp");
let title = payload
.get("title")
.and_then(|v| v.as_str())
.unwrap_or(message);
let body = payload.get("body").and_then(|v| v.as_str()).unwrap_or("");
git(&["-C", &wt, "add", "-A"])?;
let clean = std::process::Command::new("git")
.args(["-C", &wt, "diff", "--cached", "--quiet"])
.status()
.map(|s| s.success())
.unwrap_or(false);
if clean {
return Err("no changes in the worktree to commit".into());
}
let author = format!("user.name=impyard imp {imp}");
git(&[
"-C",
&wt,
"-c",
"user.email=imp@impyard.local",
"-c",
&author,
"commit",
"-m",
message,
])?;
let branch = git(&["-C", &wt, "rev-parse", "--abbrev-ref", "HEAD"])?;
let commit = git(&["-C", &wt, "rev-parse", "--short", "HEAD"])?;
git(&["-C", &wt, "push", "-u", "origin", &branch])?;
let pr = match std::process::Command::new("gh")
.args([
"pr", "create", "--head", &branch, "--title", title, "--body", body,
])
.current_dir(&wt)
.output()
{
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
_ => "branch pushed; open the PR from it".to_string(),
};
eprintln!("git-pr [{imp}] pushed {branch} ({commit})");
Ok(json!({ "branch": branch, "commit": commit, "pushed": true, "pr": pr }))
}
fn exec_identity(imp: &str, payload: &Value) -> Result<Value, String> {
let content = payload
.get("identity")
.or_else(|| payload.get("charter"))
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.ok_or("identity-edit needs a non-empty \"identity\" field")?;
let short = imp.strip_prefix("org/").unwrap_or(imp);
let path = crate::run::boxed::identity_path(short);
let dir = path.parent().ok_or("bad identity path")?;
if !dir.exists() {
return Err(format!(
"no imp directory {} — is \"{short}\" a real imp?",
dir.display()
));
}
write_atomic(&path, content)?;
eprintln!("identity [{imp}] updated ({} bytes)", content.trim().len());
Ok(json!({ "written": path.display().to_string(), "bytes": content.trim().len() }))
}
fn exec_purpose(payload: &Value) -> Result<Value, String> {
let channel = payload
.get("channel_id")
.and_then(|v| v.as_str())
.ok_or("purpose-edit needs a \"channel_id\"")?;
let content = payload
.get("purpose")
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.ok_or("purpose-edit needs a non-empty \"purpose\" field")?;
let path = crate::channel::discord::purpose_path(channel);
let _ = std::fs::create_dir_all(path.parent().unwrap());
write_atomic(&path, content)?;
eprintln!(
"purpose [{channel}] updated ({} bytes)",
content.trim().len()
);
Ok(json!({ "written": path.display().to_string(), "channel_id": channel }))
}
fn write_atomic(path: &std::path::Path, content: &str) -> Result<(), String> {
let tmp = path.with_extension("md.tmp");
std::fs::write(&tmp, format!("{}\n", content.trim())).map_err(|e| e.to_string())?;
std::fs::rename(&tmp, path).map_err(|e| e.to_string())?;
Ok(())
}
fn git(args: &[&str]) -> Result<String, String> {
let out = std::process::Command::new("git")
.args(args)
.output()
.map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(format!(
"git {}: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
pub fn identity_diff(imp: &str, proposed: &str) -> Option<String> {
let short = imp.strip_prefix("org/").unwrap_or(imp);
file_diff(&crate::run::boxed::identity_path(short), proposed)
}
pub fn purpose_diff(channel_id: &str, proposed: &str) -> Option<String> {
file_diff(&crate::channel::discord::purpose_path(channel_id), proposed)
}
fn file_diff(current: &std::path::Path, proposed: &str) -> Option<String> {
let current_arg = if current.exists() {
current.to_path_buf()
} else {
std::path::PathBuf::from("/dev/null")
};
let tmp = std::env::temp_dir().join(format!("impyard-charter-{}.md", std::process::id()));
std::fs::write(&tmp, format!("{}\n", proposed.trim())).ok()?;
let out = std::process::Command::new("git")
.args(["diff", "--no-index", "--"])
.arg(¤t_arg)
.arg(&tmp)
.output()
.ok()?;
let _ = std::fs::remove_file(&tmp);
let diff = String::from_utf8_lossy(&out.stdout).to_string();
if diff.trim().is_empty() {
None
} else {
Some(diff)
}
}
pub fn worktree_diff(run_id: &str) -> Option<String> {
let wt = paths::run_dir(run_id).join("worktree");
if !wt.exists() {
return None;
}
let wt = wt.display().to_string();
let _ = std::process::Command::new("git")
.args(["-C", &wt, "add", "-A", "-N"])
.status();
git(&["-C", &wt, "diff", "HEAD"]).ok()
}
fn audit(
imp: &str,
intent: &str,
disposition: &str,
gate_id: Option<&str>,
result: Option<&Value>,
) {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let rec = json!({
"decision_id": format!("act-{n:x}"),
"ts": now_rfc3339(),
"kind": "action",
"imp": imp,
"intent": intent,
"disposition": disposition,
"gate_id": gate_id,
"result": result,
});
let path = paths::decisions_log();
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&path) {
let _ = writeln!(f, "{rec}");
}
}