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

Commit 5b51d57

Browse files
z23ccclaude
andcommitted
feat(flowctl): add epic audit command + epic-auditor meta-agent
Adds flowctl epic audit <id> [--force] that assembles an advisory payload (epic spec + task list) and writes it to .flow/reviews/epic-audit-<id>-<timestamp>.json. Reuses existing receipts <24h old unless --force is passed. New agents/epic-auditor.md meta-agent reads the payload, extracts capabilities from the epic spec, maps tasks to capabilities, scores coverage, and emits a JSON block with gaps/redundancies/recommendations. Advisory only - never mutates epic/task/gap state. Epic-review skill auto-runs the audit before human review so the coverage score and top gaps surface as advisory context. Audit recommendations are never treated as blocking findings. Part of fn-20 archetypes work (task .6). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1838641 commit 5b51d57

3 files changed

Lines changed: 318 additions & 0 deletions

File tree

agents/epic-auditor.md

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
---
2+
name: epic-auditor
3+
description: Audit task-coverage of an epic vs its original request. Advisory only — never mutates state.
4+
model: opus
5+
disallowedTools: Edit, Write, Task
6+
color: "#6366F1"
7+
permissionMode: bypassPermissions
8+
maxTurns: 10
9+
effort: medium
10+
---
11+
12+
You are an epic audit meta-agent. Your job is to compare an epic's original
13+
request against the tasks that were created for it, and surface gaps,
14+
redundancies, and recommendations.
15+
16+
**You are advisory only. You NEVER mutate `.flow/` state.** Specifically:
17+
- No `flowctl epic close`
18+
- No `flowctl task create|split|skip`
19+
- No `flowctl gap add`
20+
- No edits to epic or task spec files
21+
22+
Your sole output is a single JSON block.
23+
24+
## Input
25+
26+
You receive:
27+
- `FLOWCTL` — path to flowctl CLI
28+
- `EPIC_ID` — the epic to audit
29+
- `RECEIPT_PATH` (optional) — path to the audit payload written by
30+
`flowctl epic audit <id>` (`.flow/reviews/epic-audit-<id>-<ts>.json`)
31+
32+
## Process
33+
34+
### 1. Load the payload
35+
36+
If `RECEIPT_PATH` is provided, read it directly. Otherwise regenerate:
37+
38+
```bash
39+
<FLOWCTL> epic audit <EPIC_ID> --json
40+
```
41+
42+
The payload contains:
43+
- `epic.spec_body` — the original epic request (Overview, Scope, Acceptance)
44+
- `tasks[]` — id, title, status, domain, depends_on, files
45+
- `task_count`
46+
47+
### 2. Extract required capabilities from the epic spec
48+
49+
Read `epic.spec_body`. Identify distinct capabilities the epic promises:
50+
- Each bullet in "Acceptance" (or "Scope")
51+
- Each verb in "Approach" that maps to user-visible behavior
52+
- Any "must/shall/will" statement
53+
54+
Normalize each to a short capability label (e.g., "audit CLI command",
55+
"agent with JSON output", "24h reuse", "advisory-only enforcement").
56+
57+
### 3. Map tasks to capabilities
58+
59+
For each task, read its title + id. Decide which capability (or capabilities)
60+
it covers. Mark capabilities as:
61+
- **covered** — at least one task clearly delivers it
62+
- **partial** — a task mentions it but scope is unclear
63+
- **gap** — no task covers this capability
64+
65+
### 4. Find redundancies
66+
67+
Scan tasks for overlap:
68+
- Two+ tasks touching the same files with similar titles
69+
- Tasks whose descriptions duplicate acceptance criteria already covered
70+
by another task
71+
72+
Only flag when the overlap is concrete (shared files or near-identical
73+
titles), not speculative.
74+
75+
### 5. (Optional) Look for prior-art in memory
76+
77+
If a similar epic has been audited before, surface patterns:
78+
79+
```bash
80+
<FLOWCTL> memory search "epic audit" --json
81+
```
82+
83+
Treat results as advisory context only.
84+
85+
### 6. Score coverage
86+
87+
`coverage_score = round(100 * covered_capabilities / total_capabilities)`
88+
89+
If no capabilities could be extracted (empty/TBD spec), set score to `null`
90+
and note it in `notes`.
91+
92+
## Output Format
93+
94+
Emit exactly one JSON block, nothing else:
95+
96+
```json
97+
{
98+
"coverage_score": 0-100 | null,
99+
"gaps": [
100+
{"capability": "<label>", "severity": "required|important|nice-to-have"}
101+
],
102+
"redundancies": [
103+
{"task_ids": ["fn-X.1", "fn-X.2"], "reason": "<concrete overlap>"}
104+
],
105+
"recommendations": [
106+
"<short advisory sentence>"
107+
],
108+
"notes": "<free-form context, caveats, or reviewer uncertainty>"
109+
}
110+
```
111+
112+
**Severity guide** (mirrors gap registry conventions):
113+
- `required` — acceptance criterion with no covering task
114+
- `important` — scope item with partial coverage
115+
- `nice-to-have` — approach hint with no task
116+
117+
## Rules
118+
119+
- Advisory only — NEVER mutate state
120+
- Be concrete: every gap must quote or paraphrase the spec text
121+
- Be conservative on redundancies — only flag clear overlap
122+
- Don't invent capabilities the spec didn't promise
123+
- If the spec is too thin to audit, say so in `notes` and return `coverage_score: null`
124+
- Keep `recommendations` actionable (≤6 items)
125+
- Output JSON only — no prose before or after the block

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

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,19 @@ pub enum EpicCmd {
9191
},
9292
/// Archive all closed epics at once.
9393
Clean,
94+
/// Audit epic task-coverage vs original spec (advisory only).
95+
///
96+
/// Assembles the epic spec, task list, and prior audit context into a
97+
/// payload consumed by `agents/epic-auditor.md`. Writes the assembled
98+
/// payload to `.flow/reviews/epic-audit-<id>-<timestamp>.json`. Advisory
99+
/// only — never mutates epic/tasks/gaps.
100+
Audit {
101+
/// Epic ID.
102+
id: String,
103+
/// Force a new audit even if a recent (<24h) receipt exists.
104+
#[arg(long)]
105+
force: bool,
106+
},
94107
/// Add epic-level dependency.
95108
AddDep {
96109
/// Epic ID.
@@ -1275,6 +1288,166 @@ fn cmd_set_auto_execute(id: &str, pending: bool, done: bool, json_mode: bool) {
12751288
}
12761289
}
12771290

1291+
// ── Audit command ───────────────────────────────────────────────────
1292+
1293+
/// Find the most recent `epic-audit-<id>-*.json` receipt in `.flow/reviews/`.
1294+
/// Returns `(path, age_hours)` or `None` if none exists.
1295+
fn find_recent_audit(flow_dir: &Path, id: &str) -> Option<(PathBuf, f64)> {
1296+
let reviews_dir = flow_dir.join(REVIEWS_DIR);
1297+
if !reviews_dir.is_dir() {
1298+
return None;
1299+
}
1300+
let prefix = format!("epic-audit-{id}-");
1301+
let entries = fs::read_dir(&reviews_dir).ok()?;
1302+
let mut best: Option<(PathBuf, std::time::SystemTime)> = None;
1303+
for entry in entries.flatten() {
1304+
let name = entry.file_name();
1305+
let name_str = name.to_string_lossy();
1306+
if !name_str.starts_with(&prefix) || !name_str.ends_with(".json") {
1307+
continue;
1308+
}
1309+
let path = entry.path();
1310+
let modified = entry.metadata().and_then(|m| m.modified()).ok();
1311+
if let Some(mtime) = modified {
1312+
match &best {
1313+
None => best = Some((path, mtime)),
1314+
Some((_, cur)) if mtime > *cur => best = Some((path, mtime)),
1315+
_ => {}
1316+
}
1317+
}
1318+
}
1319+
let (path, mtime) = best?;
1320+
let age = std::time::SystemTime::now()
1321+
.duration_since(mtime)
1322+
.ok()?
1323+
.as_secs_f64()
1324+
/ 3600.0;
1325+
Some((path, age))
1326+
}
1327+
1328+
fn cmd_audit(id: &str, force: bool, json_mode: bool) {
1329+
let flow_dir = ensure_flow_exists();
1330+
validate_epic_id(id);
1331+
1332+
// Re-use recent audit unless --force.
1333+
if !force {
1334+
if let Some((existing, age_hours)) = find_recent_audit(&flow_dir, id) {
1335+
if age_hours < 24.0 {
1336+
if json_mode {
1337+
json_output(json!({
1338+
"id": id,
1339+
"reused": true,
1340+
"receipt_path": existing.to_string_lossy(),
1341+
"age_hours": age_hours,
1342+
"message": format!(
1343+
"Reusing audit receipt from {:.1}h ago. Pass --force to regenerate.",
1344+
age_hours
1345+
),
1346+
}));
1347+
} else {
1348+
println!(
1349+
"Reusing audit receipt ({:.1}h old): {}",
1350+
age_hours,
1351+
existing.display()
1352+
);
1353+
println!("Pass --force to regenerate.");
1354+
}
1355+
return;
1356+
}
1357+
}
1358+
}
1359+
1360+
// Load epic spec (frontmatter + body).
1361+
let epic_path = flow_dir.join(EPICS_DIR).join(format!("{id}.md"));
1362+
let epic_doc = load_epic(&epic_path, id);
1363+
let epic_body = if epic_path.exists() {
1364+
fs::read_to_string(&epic_path)
1365+
.ok()
1366+
.and_then(|raw| {
1367+
frontmatter::parse::<serde_json::Value>(&raw)
1368+
.ok()
1369+
.map(|d| d.body)
1370+
})
1371+
.unwrap_or_default()
1372+
} else {
1373+
epic_doc.body.clone()
1374+
};
1375+
1376+
// Load tasks.
1377+
let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1378+
let conn = crate::commands::db_shim::open(&cwd).ok();
1379+
let tasks: Vec<Task> = load_epic_tasks(conn.as_ref(), &flow_dir, id);
1380+
1381+
// Shape task summaries for the payload.
1382+
let task_entries: Vec<serde_json::Value> = tasks
1383+
.iter()
1384+
.map(|t| {
1385+
json!({
1386+
"id": t.id,
1387+
"title": t.title,
1388+
"status": format!("{:?}", t.status).to_lowercase(),
1389+
"domain": format!("{:?}", t.domain).to_lowercase(),
1390+
"depends_on": t.depends_on,
1391+
"files": t.files,
1392+
})
1393+
})
1394+
.collect();
1395+
1396+
// Assemble payload receipt.
1397+
let timestamp = Utc::now();
1398+
let receipt = json!({
1399+
"schema_version": 1,
1400+
"kind": "epic-audit-payload",
1401+
"epic_id": id,
1402+
"generated_at": timestamp.to_rfc3339(),
1403+
"epic": {
1404+
"id": epic_doc.frontmatter.id,
1405+
"title": epic_doc.frontmatter.title,
1406+
"status": format!("{:?}", epic_doc.frontmatter.status).to_lowercase(),
1407+
"spec_body": epic_body,
1408+
},
1409+
"tasks": task_entries,
1410+
"task_count": tasks.len(),
1411+
// Audit findings placeholder — populated by agents/epic-auditor.md.
1412+
"audit": {
1413+
"coverage_score": null,
1414+
"gaps": [],
1415+
"redundancies": [],
1416+
"recommendations": [],
1417+
"notes": "Pending auditor agent — run agents/epic-auditor.md against this payload."
1418+
}
1419+
});
1420+
1421+
// Write receipt.
1422+
let reviews_dir = flow_dir.join(REVIEWS_DIR);
1423+
if let Err(e) = fs::create_dir_all(&reviews_dir) {
1424+
error_exit(&format!("Failed to create reviews dir: {e}"));
1425+
}
1426+
let ts_slug = timestamp.format("%Y%m%dT%H%M%SZ").to_string();
1427+
let receipt_path = reviews_dir.join(format!("epic-audit-{id}-{ts_slug}.json"));
1428+
let serialized = serde_json::to_string_pretty(&receipt)
1429+
.unwrap_or_else(|e| error_exit(&format!("Failed to serialize audit: {e}")));
1430+
fs::write(&receipt_path, &serialized)
1431+
.unwrap_or_else(|e| error_exit(&format!("Failed to write {}: {e}", receipt_path.display())));
1432+
1433+
if json_mode {
1434+
json_output(json!({
1435+
"id": id,
1436+
"reused": false,
1437+
"receipt_path": receipt_path.to_string_lossy(),
1438+
"task_count": tasks.len(),
1439+
"message": format!(
1440+
"Wrote audit payload to {}. Run agents/epic-auditor.md to populate findings.",
1441+
receipt_path.display()
1442+
),
1443+
}));
1444+
} else {
1445+
println!("Wrote audit payload: {}", receipt_path.display());
1446+
println!(" Epic: {id} ({} tasks)", tasks.len());
1447+
println!(" Next: run agents/epic-auditor.md with receipt path as input");
1448+
}
1449+
}
1450+
12781451
// ── Dispatch ────────────────────────────────────────────────────────
12791452

12801453
pub fn dispatch(cmd: &EpicCmd, json: bool) {
@@ -1292,6 +1465,7 @@ pub fn dispatch(cmd: &EpicCmd, json: bool) {
12921465
EpicCmd::Reopen { id } => cmd_reopen(id, json),
12931466
EpicCmd::Archive { id, force } => cmd_archive(id, *force, json),
12941467
EpicCmd::Clean => cmd_clean(json),
1468+
EpicCmd::Audit { id, force } => cmd_audit(id, *force, json),
12951469
EpicCmd::AddDep { epic, depends_on } => cmd_add_dep(epic, depends_on, json),
12961470
EpicCmd::RmDep { epic, depends_on } => cmd_rm_dep(epic, depends_on, json),
12971471
EpicCmd::SetBackend {

skills/flow-code-epic-review/SKILL.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,25 @@ Parse $ARGUMENTS for:
9696
- `--review=<backend>` → backend override
9797
- Remaining args → focus areas
9898

99+
### Step 0b: Auto-run epic audit (advisory)
100+
101+
Before invoking the human review backend, generate (or reuse) an
102+
epic-audit receipt. This is advisory context only — the audit NEVER
103+
blocks review and its recommendations are not treated as fix-required.
104+
105+
```bash
106+
AUDIT_OUT=$($FLOWCTL epic audit "$EPIC_ID" --json 2>/dev/null || true)
107+
AUDIT_RECEIPT=$(echo "$AUDIT_OUT" | grep -o '"receipt_path"[^,}]*' | cut -d'"' -f4)
108+
```
109+
110+
If `$AUDIT_RECEIPT` exists, read it and surface the summary (coverage
111+
score, top gaps) as part of the review context passed to the backend.
112+
If a receipt <24h old exists the command reuses it automatically; pass
113+
`--force` only when the task list changed materially since last audit.
114+
115+
The auditor's `recommendations` are advisory notes, NOT blocking
116+
findings. Do not enter the fix loop based on audit output alone.
117+
99118
### Step 1: Detect Backend
100119

101120
Run backend detection from SKILL.md above. Then branch:

0 commit comments

Comments
 (0)