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

Commit 3805203

Browse files
z23ccclaude
andcommitted
feat: workflow optimization — safety fixes, review consistency, observability
- Plan-sync safety: skip in_progress task specs to prevent worker drift - File lock conflict detection in Phase 3c½ before worker spawn - Unify MAX_REVIEW_ITERATIONS=3 across all review skills (fix steps.md contradiction: was >=2 AND max 5, now consistently max 3) - Add explicit circuit breaker to plan-review and epic-review SKILL.md - Brainstorm/plan dedup: Step 0.5 skips when requirements doc exists - Formal Wave Model definition in phases.md - Scout decision tree simplified to 3 profiles (quick/standard/deep) - New `flowctl log decision` command for workflow traceability - New `flowctl status --progress` with Unicode progress bar - New `flowctl doctor --workflow` checks (backend config, tools, locks) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4f4bb38 commit 3805203

11 files changed

Lines changed: 433 additions & 21 deletions

File tree

agents/plan-sync.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,4 @@ Updated tasks (cross-epic): # Only if CROSS_EPIC enabled and found
181181
- **Preserve intent** - Update references, not requirements
182182
- **Minimal changes** - Only fix stale references, don't rewrite specs
183183
- **Skip if no drift** - Return quickly if implementation matches spec
184+
- **Never edit in_progress tasks** - Before editing any task spec, check status via `$FLOWCTL show <task-id> --json`. If status is `in_progress`, skip the edit and log: `"Skipping <task-id>: task is in_progress (worker may be executing)"`. This prevents spec drift while workers are actively implementing.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ mod exchange;
6464
// ── Re-exports (preserves public API) ──────────────────────────────
6565

6666
pub use init::{cmd_init, cmd_detect};
67-
pub use status::{cmd_status, cmd_doctor, cmd_validate};
67+
pub use status::{cmd_status, cmd_doctor, cmd_progress, cmd_validate};
6868
pub use review::{cmd_review_backend, cmd_parse_findings};
6969
pub use config::{cmd_config, cmd_state_path, ConfigCmd};
7070
pub use guard::{cmd_guard, cmd_worker_prompt};

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

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ use serde_json::json;
1010
use crate::output::{error_exit, json_output, pretty_output};
1111

1212
use flowctl_core::types::{
13-
CONFIG_FILE, EPICS_DIR, MEMORY_DIR, META_FILE, REVIEWS_DIR, SCHEMA_VERSION,
13+
CONFIG_FILE, EPICS_DIR, EpicStatus, MEMORY_DIR, META_FILE, REVIEWS_DIR, SCHEMA_VERSION,
1414
SPECS_DIR, TASKS_DIR,
1515
};
16+
use flowctl_core::state_machine::Status;
1617

1718
use super::get_flow_dir;
1819

@@ -491,7 +492,81 @@ pub fn cmd_validate(json_mode: bool, epic: Option<String>, all: bool) {
491492

492493
// ── Doctor command ─────────────────────────────────────────────────
493494

494-
pub fn cmd_doctor(json_mode: bool) {
495+
// ── Progress command ──────────────────────────────────────────────
496+
497+
pub fn cmd_progress(json_mode: bool, epic_id: Option<String>) {
498+
let flow_dir = get_flow_dir();
499+
if !flow_dir.exists() {
500+
error_exit(".flow/ does not exist. Run 'flowctl init' first.");
501+
}
502+
503+
// Find the epic — either from flag or auto-detect first open epic
504+
let epic = if let Some(id) = epic_id {
505+
id
506+
} else {
507+
match flowctl_core::json_store::epic_list(&flow_dir) {
508+
Ok(epics) => {
509+
epics
510+
.iter()
511+
.find(|e| e.status == EpicStatus::Open)
512+
.map(|e| e.id.clone())
513+
.unwrap_or_else(|| {
514+
error_exit("No open epic found. Pass --epic <id>.");
515+
})
516+
}
517+
Err(_) => error_exit("Cannot read epics."),
518+
}
519+
};
520+
521+
// Load tasks for this epic
522+
let tasks = match flowctl_core::json_store::task_list_by_epic(&flow_dir, &epic) {
523+
Ok(t) => t,
524+
Err(_) => {
525+
error_exit(&format!("Cannot load tasks for epic {epic}"));
526+
}
527+
};
528+
529+
let total = tasks.len();
530+
let done = tasks.iter().filter(|t| t.status == Status::Done || t.status == Status::Skipped).count();
531+
let in_progress: Vec<&str> = tasks.iter().filter(|t| t.status == Status::InProgress).map(|t| t.id.as_str()).collect();
532+
let blocked = tasks.iter().filter(|t| t.status == Status::Blocked).count();
533+
let todo = tasks.iter().filter(|t| t.status == Status::Todo).count();
534+
let failed = tasks.iter().filter(|t| t.status == Status::Failed || t.status == Status::UpstreamFailed).count();
535+
536+
// Estimate wave: count how many distinct "rounds" have completed
537+
// Simple heuristic: wave = number of tasks that are done/in_progress groups
538+
// Wave estimation reserved for future use
539+
let percent = if total > 0 { (done * 100) / total } else { 0 };
540+
541+
if json_mode {
542+
json_output(json!({
543+
"epic": epic,
544+
"tasks_total": total,
545+
"tasks_done": done,
546+
"tasks_in_progress": in_progress,
547+
"tasks_blocked": blocked,
548+
"tasks_todo": todo,
549+
"tasks_failed": failed,
550+
"percent": percent,
551+
}));
552+
} else {
553+
// Progress bar using Unicode blocks
554+
let bar_width = 30;
555+
let filled = (percent * bar_width) / 100;
556+
let empty = bar_width - filled;
557+
let bar: String = "\u{2588}".repeat(filled) + &"\u{2591}".repeat(empty);
558+
559+
println!("Epic: {epic}");
560+
println!("Tasks: {done}/{total} done, {} in progress, {} todo, {} blocked, {} failed",
561+
in_progress.len(), todo, blocked, failed);
562+
if !in_progress.is_empty() {
563+
println!("Active: [{}]", in_progress.join(", "));
564+
}
565+
println!("{bar} {percent}%");
566+
}
567+
}
568+
569+
pub fn cmd_doctor(json_mode: bool, workflow: bool) {
495570
let flow_dir = get_flow_dir();
496571
if !flow_dir.exists() {
497572
error_exit(".flow/ does not exist. Run 'flowctl init' first.");
@@ -604,6 +679,85 @@ pub fn cmd_doctor(json_mode: bool) {
604679
}
605680
}
606681

682+
// Check 5-8: Workflow checks (only when --workflow flag is passed)
683+
if workflow {
684+
// Check 5: review backend configured
685+
let _backend_out = Command::new("git")
686+
.args(["rev-parse", "--show-toplevel"])
687+
.output()
688+
.ok()
689+
.and_then(|o| {
690+
if o.status.success() {
691+
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
692+
} else {
693+
None
694+
}
695+
});
696+
let config_path = flow_dir.join(CONFIG_FILE);
697+
let review_backend = if config_path.exists() {
698+
fs::read_to_string(&config_path)
699+
.ok()
700+
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
701+
.and_then(|v| v["review"]["backend"].as_str().map(String::from))
702+
} else {
703+
None
704+
};
705+
match review_backend.as_deref() {
706+
Some("rp") | Some("codex") | Some("none") => {
707+
checks.push(json!({"name": "review_backend", "status": "pass", "message": format!("Review backend configured: {}", review_backend.as_ref().unwrap())}));
708+
}
709+
_ => {
710+
checks.push(json!({"name": "review_backend", "status": "warn", "message": "Review backend not configured. Run /flow-code:setup or set review.backend in .flow/config.json"}));
711+
}
712+
}
713+
714+
// Check 6: configured backend tool available
715+
if let Some(ref backend) = review_backend {
716+
let tool = match backend.as_str() {
717+
"rp" => Some("rp-cli"),
718+
"codex" => Some("codex"),
719+
_ => None,
720+
};
721+
if let Some(tool_name) = tool {
722+
match Command::new("which").arg(tool_name).output() {
723+
Ok(o) if o.status.success() => {
724+
checks.push(json!({"name": "tool_available", "status": "pass", "message": format!("{} found on PATH", tool_name)}));
725+
}
726+
_ => {
727+
checks.push(json!({"name": "tool_available", "status": "fail", "message": format!("{} not found on PATH (required by review.backend={})", tool_name, backend)}));
728+
}
729+
}
730+
}
731+
}
732+
733+
// Check 7: stale file locks (count via SQL)
734+
let cwd = env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
735+
if let Ok(conn) = crate::commands::db_shim::open(&cwd) {
736+
let lock_count = crate::commands::db_shim::block_on_pub(async {
737+
let mut rows = conn.inner_conn()
738+
.query("SELECT COUNT(*) FROM file_locks", ())
739+
.await
740+
.map_err(flowctl_db::DbError::LibSql)?;
741+
if let Some(row) = rows.next().await.map_err(flowctl_db::DbError::LibSql)? {
742+
Ok::<i64, flowctl_db::DbError>(row.get::<i64>(0).unwrap_or(0))
743+
} else {
744+
Ok(0)
745+
}
746+
});
747+
match lock_count {
748+
Ok(n) if n > 0 => {
749+
checks.push(json!({"name": "stale_locks", "status": "warn", "message": format!("{} file lock(s) active — verify with 'flowctl lock-check'", n)}));
750+
}
751+
Ok(_) => {
752+
checks.push(json!({"name": "stale_locks", "status": "pass", "message": "No active file locks"}));
753+
}
754+
Err(_) => {
755+
checks.push(json!({"name": "stale_locks", "status": "warn", "message": "Could not query file locks"}));
756+
}
757+
}
758+
}
759+
}
760+
607761
// Build summary
608762
let mut summary = json!({"pass": 0, "warn": 0, "fail": 0});
609763
for c in &checks {

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ fn block_on<F: std::future::Future>(fut: F) -> F::Output {
4646
.block_on(fut)
4747
}
4848

49+
/// Public block_on for command modules that call async repos directly.
50+
pub fn block_on_pub<F: std::future::Future>(fut: F) -> F::Output {
51+
block_on(fut)
52+
}
53+
4954
// ── Pool functions ──────────────────────────────────────────────────
5055

5156
pub fn resolve_state_dir(working_dir: &Path) -> Result<PathBuf, DbError> {
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
//! Decision logging commands: log decision, log decisions.
2+
//!
3+
//! Records workflow auto-decisions in the events table (event_type = "decision")
4+
//! for post-hoc traceability. Skills call `flowctl log decision` at each auto-
5+
//! decision point; `flowctl log decisions` queries the stored decisions.
6+
7+
use clap::Subcommand;
8+
use serde_json::json;
9+
10+
use crate::output::{error_exit, json_output, pretty_output};
11+
use super::db_shim;
12+
use super::helpers::get_flow_dir;
13+
14+
#[derive(Subcommand, Debug)]
15+
pub enum LogCmd {
16+
/// Record a workflow decision.
17+
Decision {
18+
/// Decision key (e.g., "review_backend", "branch_strategy").
19+
#[arg(long)]
20+
key: String,
21+
/// Decision value (e.g., "rp-mcp", "worktree").
22+
#[arg(long)]
23+
value: String,
24+
/// Why this decision was made.
25+
#[arg(long)]
26+
reason: String,
27+
/// Epic ID (optional, for scoping).
28+
#[arg(long)]
29+
epic: Option<String>,
30+
/// Task ID (optional, for scoping).
31+
#[arg(long)]
32+
task: Option<String>,
33+
},
34+
/// Query stored decisions.
35+
Decisions {
36+
/// Filter by epic ID.
37+
#[arg(long)]
38+
epic: Option<String>,
39+
/// Maximum number of results (default 20).
40+
#[arg(long, default_value = "20")]
41+
limit: usize,
42+
},
43+
}
44+
45+
pub fn dispatch(cmd: &LogCmd, json_mode: bool) {
46+
match cmd {
47+
LogCmd::Decision {
48+
key,
49+
value,
50+
reason,
51+
epic,
52+
task,
53+
} => cmd_log_decision(json_mode, key, value, reason, epic.as_deref(), task.as_deref()),
54+
LogCmd::Decisions { epic, limit } => cmd_log_decisions(json_mode, epic.as_deref(), *limit),
55+
}
56+
}
57+
58+
fn cmd_log_decision(
59+
json_mode: bool,
60+
key: &str,
61+
value: &str,
62+
reason: &str,
63+
epic_id: Option<&str>,
64+
task_id: Option<&str>,
65+
) {
66+
let flow_dir = get_flow_dir();
67+
let conn = db_shim::open(&flow_dir).unwrap_or_else(|e| {
68+
error_exit(&format!("Cannot open DB: {e}"));
69+
});
70+
71+
let payload = json!({
72+
"key": key,
73+
"value": value,
74+
"reason": reason,
75+
})
76+
.to_string();
77+
78+
let epic = epic_id.unwrap_or("_global");
79+
80+
let repo = flowctl_db::repo::EventRepo::new(conn.inner_conn());
81+
let id = db_shim::block_on_pub(async {
82+
repo.insert(epic, task_id, "decision", None, Some(&payload), None)
83+
.await
84+
})
85+
.unwrap_or_else(|e| {
86+
error_exit(&format!("Failed to log decision: {e}"));
87+
});
88+
89+
if json_mode {
90+
json_output(json!({
91+
"id": id,
92+
"event_type": "decision",
93+
"key": key,
94+
"value": value,
95+
"reason": reason,
96+
"epic_id": epic,
97+
"task_id": task_id,
98+
}));
99+
} else {
100+
pretty_output("log", &format!("Decision logged: {key}={value} (reason: {reason})"));
101+
}
102+
}
103+
104+
fn cmd_log_decisions(json_mode: bool, epic_id: Option<&str>, limit: usize) {
105+
let flow_dir = get_flow_dir();
106+
let conn = db_shim::open(&flow_dir).unwrap_or_else(|e| {
107+
error_exit(&format!("Cannot open DB: {e}"));
108+
});
109+
110+
let repo = flowctl_db::repo::EventRepo::new(conn.inner_conn());
111+
let events = if let Some(epic) = epic_id {
112+
db_shim::block_on_pub(async { repo.list_by_epic(epic, limit * 2).await })
113+
.unwrap_or_else(|e| {
114+
error_exit(&format!("Failed to query decisions: {e}"));
115+
})
116+
.into_iter()
117+
.filter(|e| e.event_type == "decision")
118+
.take(limit)
119+
.collect::<Vec<_>>()
120+
} else {
121+
db_shim::block_on_pub(async { repo.list_by_type("decision", limit).await })
122+
.unwrap_or_else(|e| {
123+
error_exit(&format!("Failed to query decisions: {e}"));
124+
})
125+
};
126+
127+
if json_mode {
128+
let items: Vec<_> = events
129+
.iter()
130+
.map(|e| {
131+
let payload: serde_json::Value = e
132+
.payload
133+
.as_deref()
134+
.and_then(|p| serde_json::from_str(p).ok())
135+
.unwrap_or(json!(null));
136+
json!({
137+
"id": e.id,
138+
"timestamp": e.timestamp,
139+
"epic_id": e.epic_id,
140+
"task_id": e.task_id,
141+
"key": payload["key"],
142+
"value": payload["value"],
143+
"reason": payload["reason"],
144+
})
145+
})
146+
.collect();
147+
json_output(json!({ "decisions": items, "count": items.len() }));
148+
} else {
149+
if events.is_empty() {
150+
pretty_output("log", "No decisions recorded.");
151+
return;
152+
}
153+
pretty_output("log", &format!("Decisions ({}):", events.len()));
154+
for e in &events {
155+
let payload: serde_json::Value = e
156+
.payload
157+
.as_deref()
158+
.and_then(|p| serde_json::from_str(p).ok())
159+
.unwrap_or(json!(null));
160+
pretty_output(
161+
"log",
162+
&format!(
163+
" [{}] {}={} — {} (epic: {}, task: {})",
164+
&e.timestamp[..19],
165+
payload["key"].as_str().unwrap_or("?"),
166+
payload["value"].as_str().unwrap_or("?"),
167+
payload["reason"].as_str().unwrap_or("?"),
168+
e.epic_id,
169+
e.task_id.as_deref().unwrap_or("-"),
170+
),
171+
);
172+
}
173+
}
174+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ pub mod dep;
1010
pub mod epic;
1111
pub mod gap;
1212
pub mod hook;
13+
pub mod log;
1314
pub mod memory;
1415
pub mod outputs;
1516
pub mod query;

0 commit comments

Comments
 (0)