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

Commit 1efbac2

Browse files
z23ccclaude
andcommitted
refactor(epic): split epic.rs into focused sub-modules
Split the 1399-line epic.rs monolith into an epic/ directory with 7 files: - types.rs: EpicCmd enum definition - helpers.rs: shared utilities (load/save, validation, file I/O) - crud.rs: create, plan, review, branch, title, backend, auto_exec - lifecycle.rs: close, reopen, archive, clean - deps.rs: add-dep, rm-dep - audit.rs: audit command with receipt caching - history.rs: replay, diff Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5912d34 commit 1efbac2

9 files changed

Lines changed: 1454 additions & 1399 deletions

File tree

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

Lines changed: 0 additions & 1399 deletions
This file was deleted.
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
//! Epic audit command.
2+
3+
use std::fs;
4+
use std::path::{Path, PathBuf};
5+
6+
use chrono::Utc;
7+
use serde_json::json;
8+
9+
use crate::output::{error_exit, json_output};
10+
11+
use flowctl_core::types::REVIEWS_DIR;
12+
13+
use super::helpers::{ensure_flow_exists, load_epic, validate_epic_id};
14+
15+
/// Find the most recent `epic-audit-<id>-*.json` receipt in `.flow/reviews/`.
16+
/// Returns `(path, age_hours)` or `None` if none exists.
17+
fn find_recent_audit(flow_dir: &Path, id: &str) -> Option<(PathBuf, f64)> {
18+
let reviews_dir = flow_dir.join(REVIEWS_DIR);
19+
if !reviews_dir.is_dir() {
20+
return None;
21+
}
22+
let prefix = format!("epic-audit-{id}-");
23+
let entries = fs::read_dir(&reviews_dir).ok()?;
24+
let mut best: Option<(PathBuf, std::time::SystemTime)> = None;
25+
for entry in entries.flatten() {
26+
let name = entry.file_name();
27+
let name_str = name.to_string_lossy();
28+
if !name_str.starts_with(&prefix) || !name_str.ends_with(".json") {
29+
continue;
30+
}
31+
let path = entry.path();
32+
let modified = entry.metadata().and_then(|m| m.modified()).ok();
33+
if let Some(mtime) = modified {
34+
match &best {
35+
None => best = Some((path, mtime)),
36+
Some((_, cur)) if mtime > *cur => best = Some((path, mtime)),
37+
_ => {}
38+
}
39+
}
40+
}
41+
let (path, mtime) = best?;
42+
let age = std::time::SystemTime::now()
43+
.duration_since(mtime)
44+
.ok()?
45+
.as_secs_f64()
46+
/ 3600.0;
47+
Some((path, age))
48+
}
49+
50+
pub fn cmd_audit(id: &str, force: bool, json_mode: bool) {
51+
let flow_dir = ensure_flow_exists();
52+
validate_epic_id(id);
53+
54+
// Re-use recent audit unless --force.
55+
if !force {
56+
if let Some((existing, age_hours)) = find_recent_audit(&flow_dir, id) {
57+
if age_hours < 24.0 {
58+
if json_mode {
59+
json_output(json!({
60+
"id": id,
61+
"reused": true,
62+
"receipt_path": existing.to_string_lossy(),
63+
"age_hours": age_hours,
64+
"message": format!(
65+
"Reusing audit receipt from {:.1}h ago. Pass --force to regenerate.",
66+
age_hours
67+
),
68+
}));
69+
} else {
70+
println!(
71+
"Reusing audit receipt ({:.1}h old): {}",
72+
age_hours,
73+
existing.display()
74+
);
75+
println!("Pass --force to regenerate.");
76+
}
77+
return;
78+
}
79+
}
80+
}
81+
82+
// Load epic spec (frontmatter + body) from DB.
83+
let epic_doc = load_epic(id);
84+
let epic_body = epic_doc.body.clone();
85+
86+
// Load tasks from JSON.
87+
let tasks: Vec<flowctl_core::types::Task> = flowctl_core::json_store::task_list_by_epic(&flow_dir, id).unwrap_or_default();
88+
89+
// Shape task summaries for the payload.
90+
let task_entries: Vec<serde_json::Value> = tasks
91+
.iter()
92+
.map(|t| {
93+
json!({
94+
"id": t.id,
95+
"title": t.title,
96+
"status": format!("{:?}", t.status).to_lowercase(),
97+
"domain": format!("{:?}", t.domain).to_lowercase(),
98+
"depends_on": t.depends_on,
99+
"files": t.files,
100+
})
101+
})
102+
.collect();
103+
104+
// Assemble payload receipt.
105+
let timestamp = Utc::now();
106+
let receipt = json!({
107+
"schema_version": 1,
108+
"kind": "epic-audit-payload",
109+
"epic_id": id,
110+
"generated_at": timestamp.to_rfc3339(),
111+
"epic": {
112+
"id": epic_doc.frontmatter.id,
113+
"title": epic_doc.frontmatter.title,
114+
"status": format!("{:?}", epic_doc.frontmatter.status).to_lowercase(),
115+
"spec_body": epic_body,
116+
},
117+
"tasks": task_entries,
118+
"task_count": tasks.len(),
119+
// Audit findings placeholder — populated by agents/epic-auditor.md.
120+
"audit": {
121+
"coverage_score": null,
122+
"gaps": [],
123+
"redundancies": [],
124+
"recommendations": [],
125+
"notes": "Pending auditor agent — run agents/epic-auditor.md against this payload."
126+
}
127+
});
128+
129+
// Write receipt.
130+
let reviews_dir = flow_dir.join(REVIEWS_DIR);
131+
if let Err(e) = fs::create_dir_all(&reviews_dir) {
132+
error_exit(&format!("Failed to create reviews dir: {e}"));
133+
}
134+
let ts_slug = timestamp.format("%Y%m%dT%H%M%SZ").to_string();
135+
let receipt_path = reviews_dir.join(format!("epic-audit-{id}-{ts_slug}.json"));
136+
let serialized = serde_json::to_string_pretty(&receipt)
137+
.unwrap_or_else(|e| error_exit(&format!("Failed to serialize audit: {e}")));
138+
fs::write(&receipt_path, &serialized)
139+
.unwrap_or_else(|e| error_exit(&format!("Failed to write {}: {e}", receipt_path.display())));
140+
141+
if json_mode {
142+
json_output(json!({
143+
"id": id,
144+
"reused": false,
145+
"receipt_path": receipt_path.to_string_lossy(),
146+
"task_count": tasks.len(),
147+
"message": format!(
148+
"Wrote audit payload to {}. Run agents/epic-auditor.md to populate findings.",
149+
receipt_path.display()
150+
),
151+
}));
152+
} else {
153+
println!("Wrote audit payload: {}", receipt_path.display());
154+
println!(" Epic: {id} ({} tasks)", tasks.len());
155+
println!(" Next: run agents/epic-auditor.md with receipt path as input");
156+
}
157+
}

0 commit comments

Comments
 (0)