Skip to content
63 changes: 63 additions & 0 deletions crates/frameshift-cli/src/cmd/use_persona.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use std::path::{Path, PathBuf};

use clap::Args;
use frameshift_client::{Client, ClientError, InstallRequest, InstallSource, PersonaSpec};
use frameshift_orchestrator::Preferences;

use crate::util::CliError;

Expand Down Expand Up @@ -76,6 +77,18 @@ pub fn run_use(client: &Client, args: UseArgs) -> Result<(), CliError> {
other => CliError::Orchestrator(other.to_string()),
})?;

// Learn from the explicit choice: nudge future automatic selection toward
// the persona the user activated. This writes the same `automate-prefs.json`
// that `select` and the daemon read, so the bias actually closes the loop.
// Best-effort -- activation has already succeeded, so a preferences failure
// must not fail the command.
if let Ok(state_dir) = client.orchestrator_state_dir(&project_root) {
let prefs_path = state_dir.join("automate-prefs.json");
if let Err(e) = record_persona_use(&prefs_path, &args.name) {
eprintln!("warning: could not record persona preference: {e}");
}
}

// Read and print the rendered persona for the claude target.
let rendered = client.rendered_persona(&project_root, &args.name, "claude")?;
println!("{}", rendered);
Expand Down Expand Up @@ -103,3 +116,53 @@ fn read_pack_version(persona_dir: &Path) -> Option<String> {
}
None
}

/// Record that the user explicitly activated `persona`, nudging future
/// automatic selection toward it.
///
/// Loads the shared `automate-prefs.json` (the same store `select` and the
/// daemon read), bumps the persona's bias via [`Preferences::record_override`]
/// (with no auto-pick to penalize -- an explicit `use` is a positive signal,
/// not a correction of a specific automatic pick), and persists it atomically.
fn record_persona_use(prefs_path: &Path, persona: &str) -> Result<(), String> {
let mut prefs = Preferences::load(prefs_path).map_err(|e| e.to_string())?;
prefs.record_override(None, persona);
prefs.save(prefs_path).map_err(|e| e.to_string())
}

#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;

/// Recording a use bumps the persona's bias and persists it to the shared
/// preferences file so later selection can read it back.
#[test]
fn record_persona_use_biases_persona() {
let tmp = TempDir::new().unwrap();
let prefs_path = tmp.path().join("automate-prefs.json");

record_persona_use(&prefs_path, "rust").unwrap();

let prefs = Preferences::load(&prefs_path).unwrap();
assert!(
prefs.bias_for("rust") > 0.0,
"an explicit `use` should bias the persona upward"
);
}

/// Repeated uses accumulate bias (capped by the feedback layer) and never
/// error on an existing preferences file.
#[test]
fn repeated_use_accumulates_and_persists() {
let tmp = TempDir::new().unwrap();
let prefs_path = tmp.path().join("automate-prefs.json");

record_persona_use(&prefs_path, "rust").unwrap();
let first = Preferences::load(&prefs_path).unwrap().bias_for("rust");
record_persona_use(&prefs_path, "rust").unwrap();
let second = Preferences::load(&prefs_path).unwrap().bias_for("rust");

assert!(second >= first, "bias should not decrease on repeated use");
}
}
104 changes: 95 additions & 9 deletions crates/frameshift-daemon/src/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@ use frameshift_orchestrator::{
run::{select, SelectionInputs},
};

/// Read the persona name recorded in the project's active marker, if any.
///
/// Returns `None` when the marker is absent, unreadable, or empty after
/// trimming. Shared by the manual-override check and the audit `from` capture.
fn read_active_persona(client: &Client, project_root: &Path) -> Option<String> {
match client.project_paths(project_root) {
Ok(paths) if paths.active_path.exists() => std::fs::read_to_string(&paths.active_path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
_ => None,
}
}

/// Evaluate the current project context and apply a persona switch if warranted.
///
/// Steps:
Expand Down Expand Up @@ -66,6 +80,31 @@ pub fn evaluate_and_apply(client: &Client, controller: &mut SwitchController, pr
return;
}

// Load learned preferences (shared with the CLI and the selection pass).
let mut prefs = Preferences::load(&prefs_path).unwrap_or_default();

// Manual-override learning (A1): if the persona on disk differs from the
// auto-pick the controller last applied, the user switched by hand. Reward
// their choice, decay the rejected auto-pick, persist the preference, and
// re-baseline the controller so the same override is not re-learned on every
// subsequent tick. The updated bias also feeds this tick's selection.
let auto_pick = controller.active_persona().map(str::to_string);
let active_now = read_active_persona(client, project_root);
if let (Some(auto), Some(chosen)) = (auto_pick.as_deref(), active_now.as_deref()) {
if auto != chosen {
prefs.record_override(Some(auto), chosen);
if let Err(e) = prefs.save(&prefs_path) {
tracing::warn!(error = %e, "orchestrator: failed to persist override preference");
}
controller.adopt_active(chosen);
tracing::info!(
from = %auto,
to = %chosen,
"orchestrator: learned manual persona override"
);
}
}

// Step 3: collect persona source dirs and run selection.
let source_dirs = match client.installed_persona_source_dirs(project_root) {
Ok(dirs) => dirs,
Expand All @@ -75,8 +114,6 @@ pub fn evaluate_and_apply(client: &Client, controller: &mut SwitchController, pr
}
};

let prefs = Preferences::load(&prefs_path).unwrap_or_default();

let inputs = SelectionInputs {
project_root,
task_hint: None,
Expand Down Expand Up @@ -105,13 +142,7 @@ pub fn evaluate_and_apply(client: &Client, controller: &mut SwitchController, pr
} = decision
{
// Read the currently active persona before overwriting the marker.
let from = match client.project_paths(project_root) {
Ok(paths) if paths.active_path.exists() => std::fs::read_to_string(&paths.active_path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
_ => None,
};
let from = read_active_persona(client, project_root);

tracing::info!(
persona = %to,
Expand Down Expand Up @@ -382,4 +413,59 @@ mod tests {
}
}
}

/// A manual switch away from the daemon's auto-pick is learned: the chosen
/// persona is rewarded and the rejected auto-pick is decayed in the shared
/// preferences file, and the controller re-baselines to the manual choice.
/// Prior to A1 the daemon applied its own switches but never learned when
/// the user overrode them by hand.
#[test]
fn evaluate_learns_from_manual_override() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path().join("project");
fs::create_dir_all(&project_root).unwrap();

let data_root = tmp.path().join("data");
let client = test_client(&data_root);

// Enable automate mode (no lock) so evaluation proceeds.
let state_dir = client.orchestrator_state_dir(&project_root).unwrap();
fs::create_dir_all(&state_dir).unwrap();
let mode = ModeState {
mode: Mode::On,
sensitivity: 0.5,
};
mode.save(&state_dir.join("automate.json")).unwrap();

// Seed the controller's auto-pick deterministically, as if the daemon
// had activated "auto-pick" on a prior tick.
let mut controller = SwitchController::new(SwitchPolicy::default());
controller.adopt_active("auto-pick");

// Simulate the user manually switching to a different persona by writing
// the on-disk active marker directly.
let paths = client.project_paths(&project_root).unwrap();
if let Some(parent) = paths.active_path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&paths.active_path, "manual-choice\n").unwrap();

// Tick: the daemon must detect the divergence and learn from it.
evaluate_and_apply(&client, &mut controller, &project_root);

let prefs_path = state_dir.join("automate-prefs.json");
let prefs = Preferences::load(&prefs_path).expect("preferences should load");
assert!(
prefs.bias_for("manual-choice") > 0.0,
"the user's manual choice must be rewarded"
);
assert!(
prefs.bias_for("auto-pick") < 0.0,
"the rejected auto-pick must be decayed"
);

// The controller re-baselines to the manual choice so the same override
// is not re-learned on the next tick.
assert_eq!(controller.active_persona(), Some("manual-choice"));
}
}
53 changes: 46 additions & 7 deletions crates/frameshift-orchestrator/src/audit.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
//! Explainable audit log of persona transitions.

use std::collections::VecDeque;
use std::io::{BufRead, BufReader};
use std::path::Path;

use chrono::Utc;
Expand Down Expand Up @@ -34,25 +36,44 @@ pub struct AuditLog {
}

impl AuditLog {
/// Maximum number of audit entries retained in memory when loading. Entries
/// older than the most recent `MAX_AUDIT_ENTRIES` are dropped so a log grown
/// over a long-lived deployment cannot exhaust memory on load.
const MAX_AUDIT_ENTRIES: usize = 50_000;

/// Load an audit log from a JSON-lines file.
///
/// Returns an empty `AuditLog` if the file does not exist. Each line must
/// be a valid JSON object matching `Transition`.
/// Returns an empty `AuditLog` if the file does not exist. Each non-empty
/// line must be a valid JSON object matching `Transition`. Only the most
/// recent `MAX_AUDIT_ENTRIES` are retained.
pub fn load(path: &Path) -> Result<Self, OrchestratorError> {
Self::load_capped(path, Self::MAX_AUDIT_ENTRIES)
}

/// Load at most `max` most-recent entries, streaming the file line by line
/// so the full file contents are never held in memory at once.
fn load_capped(path: &Path, max: usize) -> Result<Self, OrchestratorError> {
if !path.exists() {
return Ok(AuditLog::default());
}
let data = std::fs::read_to_string(path)?;
let mut entries = Vec::new();
for line in data.lines() {
let file = std::fs::File::open(path)?;
let reader = BufReader::new(file);
let mut entries: VecDeque<Transition> = VecDeque::new();
for line in reader.lines() {
let line = line?;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let t: Transition = serde_json::from_str(trimmed)?;
entries.push(t);
entries.push_back(t);
if max > 0 && entries.len() > max {
entries.pop_front();
}
}
Ok(AuditLog { entries })
Ok(AuditLog {
entries: entries.into(),
})
}

/// Append a transition to both the in-memory log and the backing file.
Expand Down Expand Up @@ -181,4 +202,22 @@ mod tests {

assert_eq!(log.recent(100).len(), 1);
}

/// load_capped retains only the most recent `max` entries from the file.
#[test]
fn load_capped_keeps_most_recent() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("audit.jsonl");

let mut log = AuditLog::default();
for i in 0..5 {
log.append(&path, make_transition(None, &format!("p{i}")))
.unwrap();
}

let loaded = AuditLog::load_capped(&path, 3).unwrap();
assert_eq!(loaded.entries.len(), 3, "only the 3 most recent retained");
assert_eq!(loaded.entries.first().unwrap().to, "p2");
assert_eq!(loaded.entries.last().unwrap().to, "p4");
}
}
Loading
Loading