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
58 changes: 57 additions & 1 deletion docs/flowctl.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ CLI for `.flow/` task tracking. Agents must use flowctl for all writes.
## Available Commands

```
init, detect, epic, task, dep, gap, show, epics, tasks, list, cat, ready, next, start, done, block, validate, config, invariants, guard, stack, memory, prep-chat, rp, codex, checkpoint, status, state-path, migrate-state
init, detect, epic, task, dep, gap, show, epics, tasks, list, cat, ready, next, start, done, block, validate, config, invariants, guard, stack, memory, parse-findings, prep-chat, rp, codex, checkpoint, status, state-path, migrate-state
```

## Multi-User Safety
Expand Down Expand Up @@ -605,6 +605,62 @@ flowctl memory read --type pitfalls [--json]

Types: `pitfall`, `convention`, `decision`

### parse-findings

Extract structured findings from review output and optionally register them as gaps.

```bash
# Extract findings from a review output file
flowctl parse-findings --file /tmp/review-output.txt [--json]

# Extract and auto-register as gaps on an epic
flowctl parse-findings --file /tmp/review-output.txt --epic fn-1-add-auth --register --source plan-review [--json]

# Read from stdin
echo "$REVIEW_OUTPUT" | flowctl parse-findings --file - --epic fn-1 --register --source impl-review --json
```

Options:
- `--file FILE` (required): Review text file, or `-` for stdin
- `--epic EPIC_ID`: Required when `--register` is used
- `--register`: Auto-call `gap add` for each critical/major finding
- `--source SOURCE`: Gap source label (default: `manual`). Typical values: `plan-review`, `impl-review`, `epic-review`
- `--json`: JSON output

**Extraction strategy** (tiered, no external deps):
1. Regex `<findings>...</findings>` tag
2. Fallback: bare JSON array `[{...}]`
3. Fallback: markdown code block `` ```json...``` ``
4. Graceful empty: returns `[]` with warning if no findings found

**Severity-to-priority mapping** (used with `--register`):
| Severity | Priority |
|----------|----------|
| critical | required |
| major | important |
| minor | nice-to-have |
| nitpick | nice-to-have |

Output:
```json
{
"success": true,
"findings": [
{
"title": "Missing input validation",
"severity": "major",
"location": "src/auth.py:42",
"recommendation": "Add input sanitization"
}
],
"count": 1,
"registered": 1,
"warnings": []
}
```

Without `--register`, the `registered` field is omitted.

### prep-chat

Generate properly escaped JSON for RepoPrompt chat. Avoids shell escaping issues with complex prompts.
Expand Down
25 changes: 25 additions & 0 deletions scripts/flowctl/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
cmd_gap_resolve,
cmd_gap_check,
)
from flowctl.commands.findings import cmd_parse_findings
from flowctl.commands.epic import (
cmd_epic_create,
cmd_epic_set_plan,
Expand Down Expand Up @@ -544,6 +545,30 @@ def main() -> None:
p_gap_check.add_argument("--json", action="store_true", help="JSON output")
p_gap_check.set_defaults(func=cmd_gap_check)

# parse-findings
p_pf = subparsers.add_parser(
"parse-findings",
help="Extract structured findings from review output",
)
p_pf.add_argument(
"--file", required=True,
help="Review output file (or '-' for stdin)",
)
p_pf.add_argument(
"--epic", default=None,
help="Epic ID (required with --register)",
)
p_pf.add_argument(
"--register", action="store_true",
help="Auto-register critical/major findings as gaps",
)
p_pf.add_argument(
"--source", default="manual",
help="Gap source label (default: manual)",
)
p_pf.add_argument("--json", action="store_true", help="JSON output")
p_pf.set_defaults(func=cmd_parse_findings)

# show
p_show = subparsers.add_parser("show", help="Show epic or task")
p_show.add_argument("id", help="Epic or task ID (e.g., fn-1-add-auth, fn-1-add-auth.2)")
Expand Down
219 changes: 219 additions & 0 deletions scripts/flowctl/commands/findings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
"""parse-findings command: extract structured <findings> JSON from review output."""

import argparse
import json
import re
import sys
from typing import List, Tuple

from flowctl.core.io import error_exit, json_output, read_file_or_stdin
from flowctl.core.ids import is_epic_id
from flowctl.core.paths import ensure_flow_exists

# Severity → gap priority mapping
SEVERITY_TO_PRIORITY = {
"critical": "required",
"major": "important",
"minor": "nice-to-have",
"nitpick": "nice-to-have",
}

REQUIRED_KEYS = ("title", "severity", "location", "recommendation")
MAX_FINDINGS = 50


def _repair_json(text: str) -> str:
"""Stdlib-only JSON repair: strip fences, trailing commas, single quotes."""
# Strip markdown code fences
text = re.sub(r"^```(?:json)?\s*\n?", "", text.strip())
text = re.sub(r"\n?```\s*$", "", text.strip())

# Remove trailing commas before ] or }
text = re.sub(r",\s*([}\]])", r"\1", text)

# Replace single quotes with double quotes (simple heuristic —
# only when they look like JSON string delimiters)
# This handles {'key': 'value'} but not contractions inside values.
# We only apply this if the text doesn't parse as-is.
try:
json.loads(text)
return text
except (json.JSONDecodeError, ValueError):
pass

# Try replacing single-quote delimiters
repaired = re.sub(r"(?<=[\[{,:\s])'|'(?=[\]},:.\s])", '"', text)
return repaired


def parse_findings(text: str) -> Tuple[List[dict], List[str]]:
"""Extract structured findings from review output text.

Tiered extraction:
1. <findings>...</findings> tag
2. Bare JSON array [{...}]
3. Markdown code block ```json...```
4. Graceful empty

Returns (findings_list, warnings).
"""
warnings: List[str] = []
raw_json = None

# Tier 1: <findings> tag
match = re.search(r"<findings>\s*(.*?)\s*</findings>", text, re.DOTALL)
if match:
raw_json = match.group(1).strip()
else:
# Tier 2: bare JSON array
match = re.search(r"(\[\s*\{.*?\}\s*\])", text, re.DOTALL)
if match:
raw_json = match.group(1).strip()
warnings.append("No <findings> tag found; extracted bare JSON array")
else:
# Tier 3: markdown code block
match = re.search(r"```(?:json)?\s*\n(\[.*?\])\s*\n?```", text, re.DOTALL)
if match:
raw_json = match.group(1).strip()
warnings.append("No <findings> tag found; extracted from code block")
else:
# Tier 4: graceful empty
warnings.append("No findings found in review output")
return [], warnings

# Repair and parse JSON
repaired = _repair_json(raw_json)
try:
findings = json.loads(repaired)
except (json.JSONDecodeError, ValueError) as e:
warnings.append(f"Failed to parse findings JSON: {e}")
return [], warnings

if not isinstance(findings, list):
warnings.append("Findings JSON is not a list")
return [], warnings

# Validate each finding
valid_findings: List[dict] = []
for i, finding in enumerate(findings):
if not isinstance(finding, dict):
warnings.append(f"Finding {i} is not an object, skipping")
continue
missing = [k for k in REQUIRED_KEYS if k not in finding]
if missing:
warnings.append(f"Finding {i} missing keys: {', '.join(missing)}, skipping")
continue
# Normalize severity to lowercase
finding["severity"] = finding["severity"].strip().lower()
valid_findings.append(finding)

# Cap at MAX_FINDINGS
if len(valid_findings) > MAX_FINDINGS:
warnings.append(
f"Found {len(valid_findings)} findings, capping at {MAX_FINDINGS}"
)
valid_findings = valid_findings[:MAX_FINDINGS]

return valid_findings, warnings


def cmd_parse_findings(args: argparse.Namespace) -> None:
"""Parse structured findings from review output text."""
text = read_file_or_stdin(args.file, "review output", use_json=args.json)
findings, warnings = parse_findings(text)

registered = 0
if args.register:
if not args.epic:
error_exit(
"--epic is required when --register is used", use_json=args.json
)
if not ensure_flow_exists():
error_exit(
".flow/ does not exist. Run 'flowctl init' first.",
use_json=args.json,
)
if not is_epic_id(args.epic):
error_exit(f"Invalid epic ID: {args.epic}", use_json=args.json)

# Import gap internals (avoid circular at module level)
from flowctl.commands.gap import cmd_gap_add

for finding in findings:
severity = finding["severity"]
priority = SEVERITY_TO_PRIORITY.get(severity)
if priority is None:
warnings.append(
f"Unknown severity '{severity}' for '{finding['title']}', "
f"defaulting to 'important'"
)
priority = "important"

# Only register critical/major (required/important priorities)
if priority not in ("required", "important"):
continue

# Build a mock args namespace to reuse cmd_gap_add
gap_args = argparse.Namespace(
epic=args.epic,
capability=finding["title"],
priority=priority,
source=args.source,
task=None,
json=True, # always JSON to capture output
)

# Capture stdout to avoid polluting our output
old_stdout = sys.stdout
sys.stdout = _CaptureStdout()
try:
cmd_gap_add(gap_args)
registered += 1
except SystemExit:
# cmd_gap_add may exit on duplicate (which is fine — idempotent)
# Check if it was a success (gap already exists = still counts)
registered += 1
finally:
sys.stdout = old_stdout

# Handle unknown severity warnings for non-register mode
if not args.register:
for finding in findings:
severity = finding["severity"]
if severity not in SEVERITY_TO_PRIORITY:
warnings.append(
f"Unknown severity '{severity}' for '{finding['title']}', "
f"would default to 'important'"
)

result = {
"findings": findings,
"count": len(findings),
"registered": registered,
"warnings": warnings,
}

if args.json:
json_output(result)
else:
print(f"Found {len(findings)} finding(s)")
if registered:
print(f"Registered {registered} gap(s)")
for w in warnings:
print(f" Warning: {w}", file=sys.stderr)
for f in findings:
sev = f["severity"]
print(f" [{sev}] {f['title']} — {f['location']}")


class _CaptureStdout:
"""Minimal stdout capture to suppress gap add output."""

def __init__(self):
self.data = []

def write(self, s):
self.data.append(s)

def flush(self):
pass
39 changes: 39 additions & 0 deletions scripts/flowctl/commands/review/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,19 @@ def build_review_prompt(
- **Problem**: What's wrong
- **Suggestion**: How to fix

**Structured findings (optional):** If you found issues, include a `<findings>` block with machine-readable JSON. SHIP reviews with no issues may omit this block.

<findings>
[
{
"title": "Short description of the issue",
"severity": "critical | major | minor | nitpick",
"location": "task ID, file:line, or spec section",
"recommendation": "How to fix"
}
]
</findings>

Be critical. Find real issues.

**REQUIRED**: End your response with exactly one verdict tag:
Expand Down Expand Up @@ -183,6 +196,19 @@ def build_review_prompt(
- **Problem**: What's wrong
- **Suggestion**: How to fix

**Structured findings (optional):** If you found issues, include a `<findings>` block with machine-readable JSON. SHIP reviews with no issues may omit this block.

<findings>
[
{
"title": "Short description of the issue",
"severity": "critical | major | minor | nitpick",
"location": "task ID, file:line, or spec section",
"recommendation": "How to fix"
}
]
</findings>

Be critical. Find real issues.

**REQUIRED**: End your response with exactly one verdict tag:
Expand Down Expand Up @@ -529,6 +555,19 @@ def build_completion_review_prompt(
[For each GAP, describe what's missing and suggest fix]
```

**Structured findings (optional):** If you found gaps, include a `<findings>` block with machine-readable JSON. SHIP reviews with no issues may omit this block.

<findings>
[
{
"title": "Short description of the issue",
"severity": "critical | major | minor | nitpick",
"location": "task ID, file:line, or spec section",
"recommendation": "How to fix"
}
]
</findings>

## Verdict

**SHIP** - All requirements covered. Epic can close.
Expand Down
Loading