Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/flok-core/src/compaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ impl CompactionStore {
}

fn compactions_dir(&self) -> PathBuf {
self.project_root.join(".flok").join("compactions")
crate::config::project_state_dir(&self.project_root).join("compactions")
}

fn project_memory_path(&self) -> PathBuf {
Expand Down
50 changes: 50 additions & 0 deletions crates/flok-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,7 @@ pub fn ensure_directories() -> anyhow::Result<()> {
dirs.config_dir().join("flok"),
dirs.data_dir().join("flok"),
dirs.cache_dir().join("flok"),
flok_state_root(),
];
for path in &paths {
std::fs::create_dir_all(path)?;
Expand All @@ -763,6 +764,47 @@ pub fn ensure_directories() -> anyhow::Result<()> {
Ok(())
}

/// Root directory for generated flok runtime state.
///
/// This intentionally lives under `~/.flok` instead of the project tree so
/// compactions, plans, memory, snapshots, and other agent artifacts do not
/// pollute repositories.
#[must_use]
pub fn flok_state_root() -> PathBuf {
if cfg!(test) {
return std::env::temp_dir().join("flok-test-state");
}

if let Some(home) = directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf()) {
return home.join(".flok");
}

std::env::temp_dir().join("flok")
}

/// Stable per-project directory for generated runtime state.
#[must_use]
pub fn project_state_dir(project_root: &Path) -> PathBuf {
let canonical =
std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
let slug = canonical
.file_name()
.and_then(std::ffi::OsStr::to_str)
.map(sanitize_project_slug)
.filter(|slug| !slug.is_empty())
.unwrap_or_else(|| "project".to_string());
let hash = blake3::hash(canonical.to_string_lossy().as_bytes()).to_hex().to_string();
flok_state_root().join("projects").join(format!("{slug}-{}", &hash[..16]))
}

fn sanitize_project_slug(value: &str) -> String {
let slug: String = value
.chars()
.map(|ch| if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') { ch } else { '-' })
.collect();
slug.trim_matches('-').to_string()
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -786,6 +828,14 @@ mod tests {
assert_eq!(config.output_compression, OutputCompressionConfig::default());
}

#[test]
fn project_state_dir_is_outside_project_root() {
let dir = tempfile::tempdir().unwrap();
let state_dir = project_state_dir(dir.path());
assert!(!state_dir.starts_with(dir.path()));
assert!(state_dir.to_string_lossy().contains("projects"));
}

#[test]
fn parse_output_compression_config() {
let toml_str = r#"
Expand Down
4 changes: 2 additions & 2 deletions crates/flok-core/src/plan.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Typed execution plans persisted in the workspace.
//!
//! Plans are stored as JSON under `.flok/plans/<plan_id>.json` so they can be
//! Plans are stored under flok's generated per-project state directory so they can be
//! reviewed, diffed, resumed, and executed later.

use std::collections::{HashMap, HashSet, VecDeque};
Expand Down Expand Up @@ -285,7 +285,7 @@ impl PlanStore {
}

fn plans_dir(&self) -> PathBuf {
self.project_root.join(".flok").join("plans")
crate::config::project_state_dir(&self.project_root).join("plans")
}
}

Expand Down
33 changes: 4 additions & 29 deletions crates/flok-core/src/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
//! ## Shadow repo location
//!
//! ```text
//! <data_dir>/flok/snapshot/<project_id>/<sha1(worktree)>/
//! ~/.flok/snapshot/<project_id>/<sha1(worktree)>/
//! ```
//!
//! The shadow repo uses `--git-dir` and `--work-tree` flags so it never
Expand Down Expand Up @@ -89,20 +89,11 @@ impl SnapshotManager {
/// `worktree` is the absolute path to the workspace root.
///
/// The shadow git repo will be created at:
/// `<data_dir>/flok/snapshot/<project_id>/<hash(worktree)>/`
/// `~/.flok/snapshot/<project_id>/<hash(worktree)>/`
pub fn new(project_id: &str, worktree: PathBuf) -> Self {
let worktree_hash = hash_path(&worktree);
let fallback_base = worktree.join(".flok").join("snapshot").join(project_id);
let snapshot_base = if cfg!(test) {
let _ = std::fs::create_dir_all(&fallback_base);
fallback_base
} else {
select_writable_storage_dir(
directories::BaseDirs::new()
.map(|dirs| dirs.data_dir().join("flok").join("snapshot").join(project_id)),
fallback_base,
)
};
let snapshot_base = crate::config::flok_state_root().join("snapshot").join(project_id);
let _ = std::fs::create_dir_all(&snapshot_base);
let gitdir = snapshot_base.join(worktree_hash);

// Only enable if the worktree has a .git directory (is a git project)
Expand Down Expand Up @@ -639,22 +630,6 @@ impl SnapshotManager {
}
}

fn select_writable_storage_dir(primary: Option<PathBuf>, fallback: PathBuf) -> PathBuf {
if let Some(primary) = primary {
if std::fs::create_dir_all(&primary).is_ok() {
return primary;
}
tracing::warn!(
path = %primary.display(),
fallback = %fallback.display(),
"snapshot storage directory unavailable, using project-local fallback"
);
}

let _ = std::fs::create_dir_all(&fallback);
fallback
}

impl std::fmt::Debug for SnapshotManager {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SnapshotManager")
Expand Down
8 changes: 5 additions & 3 deletions crates/flok-core/src/team.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ impl TeamStore {
}

fn teams_dir(&self) -> PathBuf {
self.project_root.join(".flok").join("teams")
crate::config::project_state_dir(&self.project_root).join("teams")
}
}

Expand Down Expand Up @@ -364,7 +364,7 @@ impl TeamRegistry {
Self { teams: Arc::new(DashMap::new()), store: None }
}

/// Create a registry backed by `.flok/teams` under the project root.
/// Create a registry backed by flok's generated per-project state directory.
pub fn new_with_project_root(project_root: PathBuf) -> anyhow::Result<Self> {
let store = Arc::new(TeamStore::new(project_root));
let registry = Self { teams: Arc::new(DashMap::new()), store: Some(Arc::clone(&store)) };
Expand Down Expand Up @@ -583,7 +583,9 @@ mod tests {
let registry = TeamRegistry::new_with_project_root(temp.path().to_path_buf()).unwrap();
let team = registry.create_team("ephemeral").unwrap();
let team_id = team.id.clone();
let team_path = temp.path().join(".flok").join("teams").join(format!("{team_id}.json"));
let team_path = crate::config::project_state_dir(temp.path())
.join("teams")
.join(format!("{team_id}.json"));

assert!(team_path.exists());
assert!(registry.delete(&team_id).unwrap());
Expand Down
6 changes: 3 additions & 3 deletions crates/flok-core/src/tool/memory.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! The `agent_memory` tool — read/write persistent per-agent memory.
//!
//! Memory is stored in `.flok/memory/<agent>.md` files. Each agent has
//! independent memory scoped to the project.
//! Memory is stored under flok's generated per-project state directory. Each
//! agent has independent memory scoped to the project.

use std::path::PathBuf;

Expand Down Expand Up @@ -49,7 +49,7 @@ impl Tool for AgentMemoryTool {
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing required parameter: operation"))?;

let memory_dir = ctx.project_root.join(".flok").join("memory");
let memory_dir = crate::config::project_state_dir(&ctx.project_root).join("memory");
let memory_file = memory_dir.join(format!("{}.md", ctx.agent));

match operation {
Expand Down
24 changes: 14 additions & 10 deletions crates/flok-core/src/tool/plan.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
//! The `plan` tool — writes structured plan output.
//!
//! Available in plan mode only. Writes a structured markdown plan
//! to `.flok/plan.md` in the project root.
//! under flok's generated per-project state directory.

use super::{Tool, ToolContext, ToolOutput};

/// Write a structured plan to `.flok/plan.md`.
/// Write a structured plan to flok's generated state directory.
pub struct PlanTool;

#[async_trait::async_trait]
Expand All @@ -15,7 +15,7 @@ impl Tool for PlanTool {
}

fn description(&self) -> &'static str {
"Write a structured plan to .flok/plan.md. Use this in plan mode to document \
"Write a structured plan to flok's generated state directory. Use this in plan mode to document \
your analysis and proposed changes before switching to build mode."
}

Expand All @@ -37,12 +37,12 @@ impl Tool for PlanTool {
}

fn permission_level(&self) -> super::PermissionLevel {
// Plan tool is safe — it only writes to .flok/plan.md
// Plan tool is safe — it only writes to flok-managed internal state.
super::PermissionLevel::Safe
}

fn describe_invocation(&self, _args: &serde_json::Value) -> String {
"plan: write to .flok/plan.md".to_string()
"plan: write to flok internal plan state".to_string()
}

async fn execute(
Expand All @@ -55,7 +55,7 @@ impl Tool for PlanTool {
.ok_or_else(|| anyhow::anyhow!("missing required parameter: content"))?;
let append = args["append"].as_bool().unwrap_or(false);

let plan_dir = ctx.project_root.join(".flok");
let plan_dir = crate::config::project_state_dir(&ctx.project_root);
let plan_file = plan_dir.join("plan.md");

tokio::fs::create_dir_all(&plan_dir).await?;
Expand All @@ -72,13 +72,13 @@ impl Tool for PlanTool {
existing.push_str(content);
tokio::fs::write(&plan_file, &existing).await?;
Ok(ToolOutput::success(format!(
"Plan appended to .flok/plan.md ({} chars total)",
"Plan appended to internal plan state ({} chars total)",
existing.len()
)))
} else {
tokio::fs::write(&plan_file, content).await?;
Ok(ToolOutput::success(format!(
"Plan written to .flok/plan.md ({} chars)",
"Plan written to internal plan state ({} chars)",
content.len()
)))
}
Expand Down Expand Up @@ -106,7 +106,9 @@ mod tests {
.unwrap();
assert!(!result.is_error);

let content = std::fs::read_to_string(dir.path().join(".flok/plan.md")).unwrap();
let content =
std::fs::read_to_string(crate::config::project_state_dir(dir.path()).join("plan.md"))
.unwrap();
assert!(content.contains("# Plan"));
}

Expand All @@ -119,7 +121,9 @@ mod tests {
tool.execute(serde_json::json!({"content": "Step 1"}), &ctx).await.unwrap();
tool.execute(serde_json::json!({"content": "Step 2", "append": true}), &ctx).await.unwrap();

let content = std::fs::read_to_string(dir.path().join(".flok/plan.md")).unwrap();
let content =
std::fs::read_to_string(crate::config::project_state_dir(dir.path()).join("plan.md"))
.unwrap();
assert!(content.contains("Step 1"));
assert!(content.contains("Step 2"));
}
Expand Down
6 changes: 3 additions & 3 deletions crates/flok-core/src/tool/plan_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use crate::plan::{

use super::{Tool, ToolContext, ToolOutput};

/// Create a structured execution plan persisted to `.flok/plans/<plan_id>.json`.
/// Create a structured execution plan persisted to flok's generated state directory.
pub struct PlanCreateTool;

/// Update plan-level or step-level status for an existing plan.
Expand Down Expand Up @@ -63,7 +63,7 @@ impl Tool for PlanCreateTool {
}

fn description(&self) -> &'static str {
"Create a typed execution plan and persist it under .flok/plans/<plan_id>.json. \
"Create a typed execution plan and persist it under flok's generated per-project state directory. \
Use this in plan mode when a task is complex enough to require explicit steps, \
dependencies, and later approval/execution."
}
Expand Down Expand Up @@ -295,7 +295,7 @@ mod tests {

assert!(!result.is_error);
assert!(result.content.contains("Plan file:"));
assert!(ctx.project_root.join(".flok/plans").exists());
assert!(crate::config::project_state_dir(&ctx.project_root).join("plans").exists());
}

#[tokio::test]
Expand Down
33 changes: 4 additions & 29 deletions crates/flok-core/src/worktree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ pub enum MergeResult {
pub struct WorktreeManager {
/// The main project root (where `.git` lives).
project_root: PathBuf,
/// Base directory for worktrees: `$XDG_STATE_HOME/flok/worktrees/{project_id}/`.
/// Base directory for worktrees: `~/.flok/worktrees/{project_id}/`.
worktree_base: PathBuf,
/// Serializes merge operations to prevent concurrent merges.
merge_lock: Mutex<()>,
Expand All @@ -65,21 +65,12 @@ pub struct WorktreeManager {
impl WorktreeManager {
/// Create a new worktree manager.
///
/// Worktrees are stored under `$XDG_STATE_HOME/flok/worktrees/{project_id}/`.
/// Worktrees are stored under `~/.flok/worktrees/{project_id}/`.
/// Disabled if the project root does not contain a `.git` directory.
pub fn new(project_id: &str, project_root: PathBuf) -> Self {
let enabled = project_root.join(".git").exists();
let fallback_base = project_root.join(".flok").join("worktrees").join(project_id);
let state_base = if cfg!(test) {
let _ = std::fs::create_dir_all(&fallback_base);
fallback_base
} else {
select_writable_storage_dir(
directories::BaseDirs::new()
.map(|dirs| dirs.data_dir().join("flok").join("worktrees").join(project_id)),
fallback_base,
)
};
let state_base = crate::config::flok_state_root().join("worktrees").join(project_id);
let _ = std::fs::create_dir_all(&state_base);

Self { project_root, worktree_base: state_base, merge_lock: Mutex::new(()), enabled }
}
Expand Down Expand Up @@ -328,22 +319,6 @@ impl WorktreeManager {
}
}

fn select_writable_storage_dir(primary: Option<PathBuf>, fallback: PathBuf) -> PathBuf {
if let Some(primary) = primary {
if std::fs::create_dir_all(&primary).is_ok() {
return primary;
}
tracing::warn!(
path = %primary.display(),
fallback = %fallback.display(),
"worktree storage directory unavailable, using project-local fallback"
);
}

let _ = std::fs::create_dir_all(&fallback);
fallback
}

impl std::fmt::Debug for WorktreeManager {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WorktreeManager")
Expand Down
Loading