#!/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 , 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: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()