Skip to content
This repository was archived by the owner on Apr 11, 2026. It is now read-only.

Commit 033ff66

Browse files
z23ccclaude
andcommitted
feat: three-layer state directory separation
Layer 1: .flow/ (runtime, gitignored) — epics, tasks, specs, pipeline state Layer 2: .flow-config/ (team config, git-tracked) — project-context.md, invariants.md, config.json Layer 3: ~/.flow/projects/{slug}/ (global, cross-session) — frecency.json, memory/ New modules: - paths.rs: FlowPaths struct with resolve(), slug from git remote, convenience getters with primary→fallback resolution - flowctl paths: CLI command showing all resolved paths Init changes: - Creates .flow-config/ and ~/.flow/projects/{slug}/memory/ - Migrates existing files to new locations (COPY, not move) - project-context.md now generated in .flow-config/ Read changes: - project_context: load_resolved() tries .flow-config/ first - frecency: load_resolved()/save_resolved() uses global dir - guard: uses load_resolved() for project-context Slug: computed from git remote URL (owner-repo format). Backward compatible: all reads fall back to .flow/ if new paths missing. 381 tests pass. Zero new dependencies. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ac21127 commit 033ff66

11 files changed

Lines changed: 573 additions & 7 deletions

File tree

bin/flowctl

16.2 KB
Binary file not shown.

docs/state-directory-analysis.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# .flow 状态目录设计分析
2+
3+
> 基于行业最佳实践 + gstack 参考 | 2026-04-09
4+
5+
## 结论
6+
7+
**项目内 `.flow/` 是正确的选择**,符合行业共识(git/.git, cargo/target, nx/.nx)。
8+
不应移到 `~/.flow/projects/`
9+
10+
## 当前已有的 CWD 修复(足够)
11+
12+
1. `get_flow_dir()` 向上遍历 — 子目录执行时自动找到根目录的 .flow
13+
2. `--project-dir` / `-C` — 显式指定项目目录
14+
3. `FLOW_STATE_DIR` 环境变量 — CI/脚本使用
15+
16+
## 未来可选改进(不紧急)
17+
18+
### 1. 分层存储(gstack 模式)
19+
20+
```
21+
.flow/ ← 运行时状态(gitignore)
22+
.flow-config/ ← 团队配置(git 追踪)
23+
~/.flow/projects/ ← 跨会话记忆(全局)
24+
```
25+
26+
### 2. Slug 缓存(gstack 的 slug-cache)
27+
28+
`git remote get-url origin` 生成项目 slug,缓存到 `~/.flow/slug-cache/`
29+
下次找 .flow 时先查缓存,不用遍历。
30+
31+
### 3. Frecency 移到全局
32+
33+
文件频率数据是跨会话积累的,放到 `~/.flow/projects/{slug}/frecency.json`
34+
不影响当前功能,只是位置更合理。
35+
36+
## 不做的理由
37+
38+
把 .flow 拆成 3 个位置需要修改 flowctl 所有文件路径解析(~50 个文件引用 .flow)。
39+
投入产出比不够。当前 3 个 CWD 修复已经解决了 5 轮审计的 #1 问题。
40+
41+
---
42+
43+
*Generated 2026-04-09*

flowctl/crates/flowctl-cli/src/commands/admin/guard.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -352,8 +352,9 @@ pub fn cmd_guard(json_mode: bool, layer: String) {
352352
};
353353

354354
// ── Priority chain: project-context → stack config → auto-detection ──
355-
// Try project-context.md first (highest priority).
356-
let pc = ProjectContext::load(&flow_dir);
355+
// Try three-layer resolution first (.flow-config/ then .flow/), fall back to direct load.
356+
let pc = ProjectContext::load_resolved()
357+
.or_else(|| ProjectContext::load(&flow_dir));
357358
let pc_commands = pc.as_ref().map(|ctx| {
358359
let gc = &ctx.guard_commands;
359360
let mut cmds: Vec<(String, String, String)> = Vec::new();

flowctl/crates/flowctl-cli/src/commands/admin/init.rs

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,17 +89,93 @@ pub fn cmd_init(json: bool) {
8989
eprintln!("warning: failed to ensure store dirs: {e}");
9090
}
9191

92+
// ── Layer 2: .flow-config/ (team config, git-tracked) ──────────────
93+
let config_dir = cwd.join(".flow-config");
94+
if !config_dir.exists() {
95+
if let Err(e) = fs::create_dir_all(&config_dir) {
96+
eprintln!("warning: failed to create .flow-config/: {e}");
97+
} else {
98+
actions.push("created .flow-config/".to_string());
99+
}
100+
}
101+
102+
// ── Layer 3: global project directory ──────────────────────────────
103+
if let Some(paths) = flowctl_core::paths::FlowPaths::resolve() {
104+
let global_dir = &paths.global_project_dir;
105+
if !global_dir.exists() {
106+
if let Err(e) = fs::create_dir_all(global_dir.join("memory")) {
107+
eprintln!("warning: failed to create global project dir: {e}");
108+
} else {
109+
actions.push(format!("created {}", global_dir.display()));
110+
}
111+
}
112+
}
113+
114+
// ── Migrate existing files to new locations (copy, not move) ───────
115+
116+
// config.json → .flow-config/config.json
117+
let old_config = flow_dir.join("config.json");
118+
let new_config = config_dir.join("config.json");
119+
if old_config.exists() && !new_config.exists() {
120+
if fs::copy(&old_config, &new_config).is_ok() {
121+
actions.push("migrated config.json → .flow-config/".to_string());
122+
}
123+
}
124+
125+
// project-context.md → .flow-config/project-context.md
126+
let old_pc = flow_dir.join("project-context.md");
127+
let new_pc = config_dir.join("project-context.md");
128+
if old_pc.exists() && !new_pc.exists() {
129+
if fs::copy(&old_pc, &new_pc).is_ok() {
130+
actions.push("migrated project-context.md → .flow-config/".to_string());
131+
}
132+
}
133+
134+
// invariants.md → .flow-config/invariants.md
135+
let old_inv = flow_dir.join("invariants.md");
136+
let new_inv = config_dir.join("invariants.md");
137+
if old_inv.exists() && !new_inv.exists() {
138+
if fs::copy(&old_inv, &new_inv).is_ok() {
139+
actions.push("migrated invariants.md → .flow-config/".to_string());
140+
}
141+
}
142+
143+
// frecency.json + memory/ → global project dir
144+
if let Some(paths) = flowctl_core::paths::FlowPaths::resolve() {
145+
let old_frec = flow_dir.join("frecency.json");
146+
let new_frec = paths.global_project_dir.join("frecency.json");
147+
if old_frec.exists() && !new_frec.exists() {
148+
if fs::copy(&old_frec, &new_frec).is_ok() {
149+
actions.push("migrated frecency.json → global".to_string());
150+
}
151+
}
152+
153+
let old_mem = flow_dir.join("memory");
154+
let new_mem = paths.global_project_dir.join("memory");
155+
if old_mem.is_dir() && !new_mem.exists() {
156+
if fs::create_dir_all(&new_mem).is_ok() {
157+
for entry in fs::read_dir(&old_mem).into_iter().flatten().flatten() {
158+
let dest = new_mem.join(entry.file_name());
159+
fs::copy(entry.path(), &dest).ok();
160+
}
161+
actions.push("migrated memory/ → global".to_string());
162+
}
163+
}
164+
}
165+
92166
// Create project-context.md with auto-detected stack info if missing
93-
let project_context_path = flow_dir.join("project-context.md");
94-
if !project_context_path.exists() {
167+
// Write to .flow-config/ (primary location) instead of .flow/
168+
let project_context_path = config_dir.join("project-context.md");
169+
let fallback_context_path = flow_dir.join("project-context.md");
170+
if !project_context_path.exists() && !fallback_context_path.exists() {
95171
let content = generate_project_context(&cwd);
96172
if let Err(e) = fs::write(&project_context_path, content) {
97173
eprintln!("warning: failed to create project-context.md: {e}");
98174
} else {
99175
actions.push("created project-context.md (auto-detected stack)".to_string());
100176
}
101177
}
102-
let has_project_context = project_context_path.exists();
178+
let has_project_context = project_context_path.exists() || fallback_context_path.exists();
103179

104180
// Build output
105181
let message = if actions.is_empty() {
@@ -114,13 +190,13 @@ pub fn cmd_init(json: bool) {
114190
"path": flow_dir.to_string_lossy(),
115191
"actions": actions,
116192
"hint": if has_project_context { serde_json::Value::Null } else {
117-
serde_json::Value::String("Tip: copy templates/project-context.md to .flow/project-context.md to share technical standards with all worker agents".to_string())
193+
serde_json::Value::String("Tip: copy templates/project-context.md to .flow-config/project-context.md to share technical standards with all worker agents".to_string())
118194
},
119195
}));
120196
} else {
121197
println!("{}", message);
122198
if !has_project_context {
123-
println!("Tip: copy templates/project-context.md to .flow/project-context.md to share technical standards with all worker agents");
199+
println!("Tip: copy templates/project-context.md to .flow-config/project-context.md to share technical standards with all worker agents");
124200
}
125201
}
126202
}

flowctl/crates/flowctl-cli/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ pub mod log;
1919
pub mod memory;
2020
pub mod outputs;
2121
pub mod patch;
22+
pub mod paths;
2223
pub mod project_context;
2324
pub mod query;
2425
pub mod ralph;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
//! `flowctl paths` — show all resolved state directory paths.
2+
3+
use flowctl_core::FlowPaths;
4+
use serde_json::json;
5+
6+
use crate::output::{error_exit, json_output};
7+
8+
/// Show all resolved state directory paths.
9+
pub fn cmd_paths(json: bool) {
10+
match FlowPaths::resolve() {
11+
Some(paths) => {
12+
if json {
13+
json_output(json!({
14+
"project_root": paths.project_root,
15+
"slug": paths.slug,
16+
"runtime_dir": paths.runtime_dir,
17+
"config_dir": paths.config_dir,
18+
"global_project_dir": paths.global_project_dir,
19+
"config_json": paths.config_json(),
20+
"project_context": paths.project_context(),
21+
"invariants": paths.invariants(),
22+
"frecency": paths.frecency(),
23+
"memory_dir": paths.memory_dir(),
24+
}));
25+
} else {
26+
println!(
27+
"Project: {} ({})",
28+
paths.slug,
29+
paths.project_root.display()
30+
);
31+
println!("Runtime: {}", paths.runtime_dir.display());
32+
println!("Config: {}", paths.config_dir.display());
33+
println!("Global: {}", paths.global_project_dir.display());
34+
}
35+
}
36+
None => error_exit("Cannot resolve project paths. Run flowctl init first."),
37+
}
38+
}

flowctl/crates/flowctl-cli/src/main.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ enum Commands {
9898
#[arg(long)]
9999
all: bool,
100100
},
101+
/// Show all resolved state directory paths (three-layer resolution).
102+
Paths,
101103
/// Show resolved state directory path.
102104
StatePath {
103105
/// Task ID to show state file path for.
@@ -645,6 +647,7 @@ fn main() {
645647
match cli.command {
646648
// Admin / top-level
647649
Commands::Init => admin::cmd_init(json),
650+
Commands::Paths => commands::paths::cmd_paths(json),
648651
Commands::Detect => admin::cmd_detect(json),
649652
Commands::Status { interrupted, dag, epic, progress } => {
650653
if dag {

flowctl/crates/flowctl-core/src/frecency.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,34 @@ impl FrecencyStore {
4848
}
4949
}
5050

51+
/// Load from three-layer resolution (tries ~/.flow/projects/{slug}/ first, then .flow/).
52+
pub fn load_resolved() -> Self {
53+
if let Some(paths) = crate::paths::FlowPaths::resolve() {
54+
let path = paths.frecency();
55+
if path.exists() {
56+
if let Ok(content) = std::fs::read_to_string(&path) {
57+
if let Ok(entries) = serde_json::from_str(&content) {
58+
return Self { entries };
59+
}
60+
}
61+
}
62+
}
63+
Self::default()
64+
}
65+
66+
/// Persist to global project dir via three-layer resolution.
67+
pub fn save_resolved(&self) {
68+
if let Some(paths) = crate::paths::FlowPaths::resolve() {
69+
let path = paths.global_project_dir.join("frecency.json");
70+
if let Some(parent) = path.parent() {
71+
std::fs::create_dir_all(parent).ok();
72+
}
73+
if let Ok(content) = serde_json::to_string_pretty(&self.entries) {
74+
std::fs::write(&path, content).ok();
75+
}
76+
}
77+
}
78+
5179
/// Record an access with the given weight. Applies decay before adding.
5280
pub fn record_access(&mut self, path: &str, weight: f64) {
5381
let now = Utc::now();

flowctl/crates/flowctl-core/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ pub mod lifecycle;
2525
pub mod ngram_index;
2626
pub mod outputs;
2727
pub mod patch;
28+
pub mod paths;
2829
pub mod pipeline;
2930
pub mod project_context;
3031
pub mod repo_map;
@@ -42,3 +43,4 @@ pub use pipeline::PipelinePhase;
4243
pub use state_machine::{Status, Transition, TransitionError};
4344
pub use types::{Epic, Evidence, Phase, Task, TaskSize};
4445
pub use approvals::FileApprovalStore;
46+
pub use paths::FlowPaths;

0 commit comments

Comments
 (0)