use std::io::Read;
use aristo_core::config::{Aggressiveness, ConfigFile};
use aristo_core::metrics::Metrics;
use aristo_core::walk::{count_fns_per_module_with, WalkOptions};
use crate::commands::index::workspace_or_error;
use crate::commands::show::read_index;
use crate::nudge::state::{Baseline, NudgeState, STATE_FILENAME};
use crate::nudge::{score, throttle, Audience, Decision, EngineInputs};
use crate::{CliError, CliResult, Workspace};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HookEvent {
PostToolUse,
UserPromptSubmit,
SessionStart,
}
fn parse_event(raw: &str) -> Option<HookEvent> {
match raw {
"post-tool-use" | "PostToolUse" => Some(HookEvent::PostToolUse),
"user-prompt-submit" | "UserPromptSubmit" => Some(HookEvent::UserPromptSubmit),
"session-start" | "SessionStart" => Some(HookEvent::SessionStart),
_ => None,
}
}
pub(crate) fn run(event: Option<String>) -> CliResult<()> {
let ws = workspace_or_error()?;
let config = ws.load_config();
let aggressiveness = config.nudges.aggressiveness;
let Some(raw) = event else {
let state = NudgeState::load(&ws.aristo_dir().join(STATE_FILENAME));
let inputs = build_inputs(&ws, &config, &state)?;
let decision = score(&inputs, aggressiveness);
print_readout(&inputs, aggressiveness, &decision);
return Ok(());
};
let _ = emit_for_event(&ws, &config, aggressiveness, &raw);
Ok(())
}
fn emit_for_event(
ws: &Workspace,
config: &ConfigFile,
aggressiveness: Aggressiveness,
raw_event: &str,
) -> CliResult<()> {
let Some(event) = parse_event(raw_event) else {
return Ok(()); };
let state_path = ws.aristo_dir().join(STATE_FILENAME);
let mut state = NudgeState::load(&state_path);
let now = now_epoch();
match event {
HookEvent::PostToolUse => {
if stdin_tool_is_edit() {
state.edits_since_annotation = state.edits_since_annotation.saturating_add(1);
let _ = state.save(&state_path);
}
if aggressiveness.is_off() {
return Ok(());
}
let edits = state.edits_since_annotation;
if let Some(f) = crate::nudge::score_authoring_debt(edits, aggressiveness) {
if throttle::may_surface(
state.throttle.get(f.id),
now,
aggressiveness,
f.metric,
f.base,
) {
print_additional_context("PostToolUse", &authoring_debt_context(edits));
state.throttle.insert(
f.id.to_string(),
throttle::record_after_surface(now, f.metric),
);
let _ = state.save(&state_path);
}
}
}
HookEvent::UserPromptSubmit => {
if aggressiveness.is_off() {
return Ok(());
}
let inputs = build_inputs(ws, config, &state)?;
let decision = score(&inputs, aggressiveness);
let cleared: Vec<crate::nudge::Fired> = decision
.human
.iter()
.filter(|f| {
throttle::may_surface(
state.throttle.get(f.id),
now,
aggressiveness,
f.metric,
f.base,
)
})
.cloned()
.collect();
if !cleared.is_empty() {
print_additional_context(
"UserPromptSubmit",
&review_nudge_context(&decision, &inputs),
);
for f in &cleared {
state.throttle.insert(
f.id.to_string(),
throttle::record_after_surface(now, f.metric),
);
}
let _ = state.save(&state_path);
}
}
HookEvent::SessionStart => {
let inputs = build_inputs(ws, config, &state)?;
state.baseline = Some(Baseline {
score: inputs.metrics.visible_score,
tier: inputs.metrics.tier.label().to_string(),
});
state.window_intent_ids = read_index(&ws.index_path()).ok().map(|idx| {
crate::nudge::intents::authored_intents(&idx)
.into_iter()
.map(|i| i.id)
.collect()
});
state.edits_since_annotation = 0;
let _ = state.save(&state_path);
}
}
Ok(())
}
fn now_epoch() -> u64 {
time::OffsetDateTime::now_utc().unix_timestamp().max(0) as u64
}
fn stdin_tool_is_edit() -> bool {
let mut buf = String::new();
if std::io::stdin().read_to_string(&mut buf).is_err() || buf.trim().is_empty() {
return false;
}
let Ok(json) = serde_json::from_str::<serde_json::Value>(&buf) else {
return false;
};
let tool = json.get("tool_name").and_then(|v| v.as_str()).unwrap_or("");
matches!(tool, "Edit" | "Write" | "MultiEdit" | "NotebookEdit")
}
#[aristo::intent(
"The union function is read-only and tolerant: it never mutates the \
workspace and never fails the caller on missing runtime state. Absent \
reviewed/proof-reviewed maps make everything read as unreviewed, an \
absent baseline disables the gain/slump signals, and an unreadable \
proofs dir contributes zero — degrade quietly. A nudge surface that \
errored or wrote files would turn an advisory into a workflow blocker, \
violating the engine's nudge-only posture (D3).",
verify = "neural",
id = "nudge_union_is_read_only_and_tolerant"
)]
pub(crate) fn build_inputs(
ws: &Workspace,
config: &ConfigFile,
state: &NudgeState,
) -> CliResult<EngineInputs> {
let index = read_index(&ws.index_path())?;
let fn_counts =
count_fns_per_module_with(&ws.root, &WalkOptions::none()).map_err(|e| CliError::Other {
message: format!("failed to walk source for metrics coverage: {e}"),
exit_code: 1,
})?;
let metrics = Metrics::from_index(&index, &fn_counts, config.verify.default_method);
let intents = crate::nudge::intents::authored_intents(&index);
let unreviewed_intents = state.unreviewed_count(
intents
.iter()
.map(|i| (i.id.as_str(), i.text_hash.as_str(), i.body_hash.as_str())),
);
let proofs_awaiting_review = count_proofs_awaiting(ws, state);
let (prior_score, tier_increased) = match &state.baseline {
Some(b) => (
Some(b.score),
metrics.tier.label() != b.tier && metrics.visible_score > b.score,
),
None => (None, false),
};
let signed_in = aristo_core::auth::resolve_full().is_ok();
let canon_pending = if signed_in {
crate::commands::canon::suggestions::pending_total(ws)
} else {
0
};
Ok(EngineInputs {
metrics,
edits_since_annotation: state.edits_since_annotation,
unreviewed_intents,
proofs_awaiting_review,
canon_pending,
prior_score,
tier_increased,
signed_in,
})
}
fn count_proofs_awaiting(ws: &Workspace, state: &NudgeState) -> usize {
let dir = ws.aristo_dir().join("proofs");
let Ok(read) = std::fs::read_dir(&dir) else {
return 0;
};
let mut n = 0usize;
for entry in read.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("proof") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let id = stem.replace("__", ":");
if !state.proof_reviewed.get(&id).copied().unwrap_or(false) {
n += 1;
}
}
n
}
fn print_readout(
inputs: &EngineInputs,
aggressiveness: aristo_core::config::Aggressiveness,
decision: &crate::nudge::Decision,
) {
println!("Aristo nudge engine — would-surface readout");
println!(" aggressiveness: {aggressiveness:?}");
println!(
" inputs: {} unreviewed · {} unverified/{} verifiable · {} proofs awaiting · score {:.2} ({})",
inputs.unreviewed_intents,
inputs.metrics.unverified,
inputs.metrics.verifiable,
inputs.proofs_awaiting_review,
inputs.metrics.visible_score,
inputs.metrics.tier.label(),
);
if decision.is_silent() {
println!(" → nothing would fire.");
return;
}
if let Some(rec) = decision.recommended() {
println!(" → recommended: {rec}");
}
for fired in &decision.human {
println!(" · [human] {} (pressure {:.2})", fired.id, fired.pressure);
}
for fired in &decision.agent {
let _ = Audience::Agent; println!(" · [agent] {} (pressure {:.2})", fired.id, fired.pressure);
}
}
#[aristo::intent(
"A hook reaches the agent's context ONLY through \
`hookSpecificOutput.additionalContext`; a plain string (even a literal \
`<system-reminder>`) printed to a hook's stdout lands in the transcript \
but never in the model's context (the S0a spike: the old Stop/PostToolUse \
stdout reminders never reached the agent, the SessionStart \
additionalContext did). Every agent-facing nudge MUST use this JSON \
envelope — emitting bare text would be a silently-dead nudge.",
verify = "test",
id = "nudge_reaches_agent_only_via_additional_context"
)]
fn additional_context_json(event_name: &str, context: &str) -> serde_json::Value {
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": event_name,
"additionalContext": context,
}
})
}
fn print_additional_context(event_name: &str, context: &str) {
println!("{}", additional_context_json(event_name, context));
}
fn authoring_debt_context(edits: usize) -> String {
format!(
"Aristo: {edits} source edits since your last annotation. If any of \
them embodied a non-obvious decision (a chosen invariant, a refactor \
trap, an intentional-not-incomplete choice), capture it now with an \
`aristo::intent` while the rationale is fresh — see the \
aristo-authoring skill. Skip if the edits were purely mechanical."
)
}
fn review_nudge_context(decision: &Decision, inputs: &EngineInputs) -> String {
let mut context = format!("Aristo progress nudge — {}.", backlog_summary(inputs));
if let Some(rec) = decision.recommended() {
context.push_str(&format!(
" At a natural pause, offer the user: {}. Don't interrupt mid-task.",
recommended_phrase(rec)
));
}
context
}
fn backlog_summary(inputs: &EngineInputs) -> String {
let mut parts = Vec::new();
if inputs.unreviewed_intents > 0 {
parts.push(format!(
"{} intent(s) await review",
inputs.unreviewed_intents
));
}
if inputs.canon_pending > 0 {
parts.push(format!(
"{} canon match(es)/suggestion(s) pending",
inputs.canon_pending
));
}
if inputs.metrics.unverified > 0 {
parts.push(format!(
"{} of {} intents unverified",
inputs.metrics.unverified, inputs.metrics.verifiable
));
}
if inputs.proofs_awaiting_review > 0 {
parts.push(format!(
"{} proof(s) await review",
inputs.proofs_awaiting_review
));
}
if parts.is_empty() {
format!(
"tier {} (score {:.2})",
inputs.metrics.tier.label(),
inputs.metrics.visible_score
)
} else {
parts.join(" · ")
}
}
fn recommended_phrase(signal_id: &str) -> &'static str {
match signal_id {
"congrats" => "a quick note on the progress just made (tier/score went up)",
"review_backlog" => {
"an intent review — Critique-first runs in the background while you continue"
}
"canon_pending" => "a look at the pending canon matches (aristo-intent-suggestions)",
"verify_backlog" => "running `aristo verify` (can run in the background)",
"proof_review_backlog" => "a review of the freshly-verified proofs",
"score_slump" => "shoring up coverage — annotate or verify to recover the score",
_ => "a review of the outstanding aristo items",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn additional_context_envelope_carries_event_and_context() {
let v = additional_context_json("UserPromptSubmit", "hello backlog");
assert_eq!(v["hookSpecificOutput"]["hookEventName"], "UserPromptSubmit");
assert_eq!(
v["hookSpecificOutput"]["additionalContext"],
"hello backlog"
);
let s = v.to_string();
assert!(s.contains("hookSpecificOutput"));
assert!(!s.contains("<system-reminder>"));
}
#[test]
fn parse_event_drops_stop_and_keeps_the_v2_events() {
assert_eq!(parse_event("post-tool-use"), Some(HookEvent::PostToolUse));
assert_eq!(
parse_event("user-prompt-submit"),
Some(HookEvent::UserPromptSubmit)
);
assert_eq!(parse_event("session-start"), Some(HookEvent::SessionStart));
assert_eq!(parse_event("stop"), None);
assert_eq!(parse_event("Stop"), None);
assert_eq!(parse_event("nonsense"), None);
}
#[test]
fn authoring_debt_context_names_the_count_and_the_skill() {
let c = authoring_debt_context(4);
assert!(c.contains("4 source edits"));
assert!(c.contains("aristo-authoring"));
}
}