CascadingDev/assets/runtime/create_feature.py

317 lines
10 KiB
Python

#!/usr/bin/env python3
"""
create_feature.py — create a new feature request (+ discussion & summary)
Usage:
python create_feature.py --title "My Idea"
python create_feature.py --no-ramble
python create_feature.py --dir /path/to/repo
Behavior:
- Prefer Ramble (ramble.py in repo root) unless --no-ramble is passed.
- If Ramble not present or fails, prompt for fields in terminal.
- Fields come from the feature_request.md template when possible.
"""
from __future__ import annotations
import argparse, datetime, json, os, re, subprocess, sys
from pathlib import Path
from typing import Dict, List, Tuple
try:
from automation.ai_config import load_ai_settings
except ImportError:
load_ai_settings = None # type: ignore
# --------- helpers ---------
def say(msg: str) -> None:
print(msg, flush=True)
def git_root_or_cwd(start: Path) -> Path:
try:
cp = subprocess.run(["git", "rev-parse", "--show-toplevel"],
text=True, capture_output=True, check=True, cwd=start)
return Path(cp.stdout.strip())
except Exception:
return start
def read_text(p: Path) -> str:
return p.read_text(encoding="utf-8") if p.exists() else ""
def write_text(p: Path, s: str) -> None:
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(s, encoding="utf-8")
def slugify(s: str) -> str:
s = s.strip().lower()
s = re.sub(r"[^a-z0-9]+", "-", s)
s = re.sub(r"-{2,}", "-", s).strip("-")
return s or "feature"
def today() -> str:
return datetime.date.today().isoformat()
def find_template_fields(tmpl: str) -> List[Tuple[str, str]]:
"""
Scan template for lines like:
**Intent**: <...>
Return list of (FieldName, placeholderText).
"""
fields = []
for m in re.finditer(r"^\s*\*\*(.+?)\*\*:\s*(<[^>]+>|.*)$", tmpl, flags=re.M):
label = m.group(1).strip()
placeholder = m.group(2).strip()
# skip meta/system fields the script will generate
if label.lower().startswith("feature id") or label.lower().startswith("meta"):
continue
fields.append((label, placeholder))
return fields
def default_fields() -> List[str]:
return ["Title", "Intent", "Motivation / Problem", "Constraints / Non-Goals",
"Rough Proposal", "Open Questions", "Author"]
def collect_via_prompts(field_labels: List[str]) -> Dict[str, str]:
say("[•] Ramble disabled or not found; collecting fields in terminal…")
out = {}
for label in field_labels:
try:
val = input(f"{label}: ").strip()
except EOFError:
val = ""
out[label] = val
if "Title" not in out or not out["Title"].strip():
out["Title"] = "initialProjectDesign"
return out
def try_ramble(repo_root: Path, field_labels: List[str], provider: str, claude_cmd: str) -> Dict[str, str] | None:
ramble = repo_root / "ramble.py"
if not ramble.exists():
return None
args = [sys.executable, str(ramble),
"--provider", provider,
"--claude-cmd", claude_cmd,
"--prompt", "Describe your feature idea in your own words",
"--fields"] + field_labels + [
"--criteria", json.dumps({
"Title": "camelCase or kebab-case, <= 32 chars",
"Intent": "<= 2 sentences"
})
]
say("[•] Launching Ramble… (submit to return)")
cp = subprocess.run(args, text=True, capture_output=True, cwd=repo_root)
if cp.stderr and cp.stderr.strip():
say("[ramble stderr]\n" + cp.stderr.strip())
try:
data = json.loads((cp.stdout or "").strip())
except Exception:
return None
# Normalize: accept either {"fields":{...}} or flat {"Title":...}
fields = data.get("fields") if isinstance(data, dict) else None
if not isinstance(fields, dict):
fields = {k: data.get(k, "") for k in field_labels}
return {k: (fields.get(k) or "").strip() for k in field_labels}
def render_request_from_template(tmpl: str, fields: Dict[str, str], fid: str, created: str) -> str:
# if template has <title>, replace; also replace known placeholders if present
body = tmpl
replacements = {
"<title>": fields.get("Title", ""),
"<one paragraph describing purpose>": fields.get("Intent", ""),
"<why this is needed now>": fields.get("Motivation / Problem", ""),
"<bulleted list of limitations>": fields.get("Constraints / Non-Goals", ""),
"<short implementation outline>": fields.get("Rough Proposal", ""),
"<bulleted list of uncertainties>": fields.get("Open Questions", ""),
"<name>": fields.get("Author", ""),
}
for needle, val in replacements.items():
body = body.replace(needle, val)
# Append meta block if not already present
if "Feature ID" not in body or "Meta" not in body:
meta = f"""
**Feature ID**: {fid}
**Meta**: Created: {created} • Author: {fields.get('Author','').strip() or ''}
""".lstrip()
body = body.strip() + "\n\n" + meta
return body.strip() + "\n"
def seed_discussion_files(dir_disc: Path, fid: str, created: str) -> None:
req = f"""---
type: feature-discussion
stage: feature
status: OPEN
feature_id: {fid}
created: {created}
promotion_rule:
allow_agent_votes: false
ready_min_eligible_votes: 2
reject_min_eligible_votes: 1
participation:
instructions: |
- Append your input at the end as: "YourName: your comment…"
- Every comment must end with a vote line: "VOTE: READY|CHANGES|REJECT"
- Agents/bots must prefix names with "AI_". Example: "AI_Claude: … VOTE: CHANGES"
voting:
values: [READY, CHANGES, REJECT]
---
## Summary
Initial discussion for feature `{fid}`. Append your comments below.
## Participation
- Maintainer: Kickoff. VOTE: READY
"""
write_text(dir_disc / "feature.discussion.md", req)
sum_md = f"""# Summary — Feature
<!-- SUMMARY:DECISIONS START -->
## Decisions (ADR-style)
- (none yet)
<!-- SUMMARY:DECISIONS END -->
<!-- SUMMARY:OPEN_QUESTIONS START -->
## Open Questions
- (none yet)
<!-- SUMMARY:OPEN_QUESTIONS END -->
<!-- SUMMARY:AWAITING START -->
## Awaiting Replies
- (none yet)
<!-- SUMMARY:AWAITING END -->
<!-- SUMMARY:ACTION_ITEMS START -->
## Action Items
- (none yet)
<!-- SUMMARY:ACTION_ITEMS END -->
<!-- SUMMARY:VOTES START -->
## Votes (latest per participant)
READY: 1 • CHANGES: 0 • REJECT: 0
- Maintainer: READY
<!-- SUMMARY:VOTES END -->
<!-- SUMMARY:PARTICIPANTS START -->
## Participants
- (none yet)
<!-- SUMMARY:PARTICIPANTS END -->
<!-- SUMMARY:TIMELINE START -->
## Timeline (most recent first)
- {created} Maintainer: Kickoff (READY)
<!-- SUMMARY:TIMELINE END -->
<!-- SUMMARY:LINKS START -->
## Links
- Design/Plan: ../design/design.md
<!-- SUMMARY:LINKS END -->
"""
write_text(dir_disc / "feature.discussion.sum.md", sum_md)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--dir", help="Repo root (defaults to git root or CWD)")
ap.add_argument("--title", help="Feature title (useful without Ramble)")
ap.add_argument("--no-ramble", action="store_true", help="Disable Ramble UI")
ap.add_argument("--provider", help="Override Ramble provider (defaults to config)")
ap.add_argument("--claude-cmd", help="Override claude CLI path")
args = ap.parse_args()
start = Path(args.dir).expanduser().resolve() if args.dir else Path.cwd()
repo = git_root_or_cwd(start)
say(f"[=] Using repository: {repo}")
provider = args.provider
claude_cmd = args.claude_cmd
provider_map: Dict[str, Dict[str, object]] = {}
default_provider = "mock"
if load_ai_settings is not None:
try:
settings = load_ai_settings(repo)
provider_map = dict(settings.ramble.providers)
candidate_default = settings.ramble.default_provider
if isinstance(candidate_default, str) and candidate_default.strip():
default_provider = candidate_default.strip()
except Exception:
provider_map = {}
if "mock" not in provider_map:
provider_map["mock"] = {"kind": "mock"}
if not provider:
provider = default_provider if default_provider in provider_map else "mock"
elif provider not in provider_map:
say(f"[WARN] Unknown Ramble provider '{provider}', defaulting to {default_provider}")
provider = default_provider if default_provider in provider_map else "mock"
if not claude_cmd:
selected = provider_map.get(provider, {})
maybe_cmd = selected.get("command") if isinstance(selected, dict) else None
if isinstance(maybe_cmd, str) and maybe_cmd.strip():
claude_cmd = maybe_cmd.strip()
if not claude_cmd:
claude_cmd = "claude"
tmpl_path = repo / "process" / "templates" / "feature_request.md"
tmpl = read_text(tmpl_path)
parsed_fields = find_template_fields(tmpl) or [(f, "") for f in default_fields()]
field_labels = [name for (name, _) in parsed_fields]
if "Title" not in field_labels:
field_labels = ["Title"] + field_labels
# Try Ramble unless disabled
fields: Dict[str, str] | None = None
if not args.no_ramble:
fields = try_ramble(repo, field_labels, provider=provider, claude_cmd=claude_cmd)
# Terminal prompts fallback
if not fields:
fields = collect_via_prompts(field_labels)
if args.title:
fields["Title"] = args.title
# Derive slug & feature id
slug = slugify(fields.get("Title", "") or args.title or "feature")
fid = f"FR_{today()}_{slug}"
# Build target paths
fr_dir = repo / "Docs" / "features" / fid
disc_dir = fr_dir / "discussions"
fr_dir.mkdir(parents=True, exist_ok=True)
disc_dir.mkdir(parents=True, exist_ok=True)
# Render request.md
if tmpl:
body = render_request_from_template(tmpl, fields, fid=fid, created=today())
else:
# fallback body
body = f"""# Feature Request: {fields.get('Title','')}
**Intent**: {fields.get('Intent','')}
**Motivation / Problem**: {fields.get('Motivation / Problem','')}
**Constraints / Non-Goals**:
{fields.get('Constraints / Non-Goals','')}
**Rough Proposal**:
{fields.get('Rough Proposal','')}
**Open Questions**:
{fields.get('Open Questions','')}
**Feature ID**: {fid}
**Meta**: Created: {today()} • Author: {fields.get('Author','')}
"""
write_text(fr_dir / "request.md", body)
# Seed discussion & summary
seed_discussion_files(disc_dir, fid=fid, created=today())
say(f"[✓] Created feature at: {fr_dir}")
say("Next:")
say(f" git add {fr_dir}")
say(f" git commit -m \"feat: start {fid}\"")
if __name__ == "__main__":
main()