From 2bff8f3a5e3cdfdd6b36e8fb321857037b458aa8 Mon Sep 17 00:00:00 2001 From: riddim-developer-bot Date: Mon, 25 May 2026 01:24:15 -0400 Subject: [PATCH] Add effort estimate to bugfix SPEC intake --- .factory/prompts/bugfix-intake.md | 2 + .factory/schemas/bugfix-spec.schema.json | 6 + .factory/templates/bugfix-SPEC.md | 5 + scripts/intake/bugfix_spec.py | 38 ++++ scripts/intake/test_bugfix_spec.py | 220 +++++++++++++++-------- 5 files changed, 199 insertions(+), 72 deletions(-) diff --git a/.factory/prompts/bugfix-intake.md b/.factory/prompts/bugfix-intake.md index d3ed4968..1651accb 100644 --- a/.factory/prompts/bugfix-intake.md +++ b/.factory/prompts/bugfix-intake.md @@ -25,6 +25,7 @@ You are running bugfix intake for `RiddimSoftware/epac`. Your job is to turn an - observed behavior - expected behavior - reproduction steps +- estimate - evidence plan - validation plan @@ -48,6 +49,7 @@ python3 scripts/intake/bugfix_spec.py new \ --expected "The card explains that epac currently shows past debates and archival data." \ --step "Launch epac." \ --step "Open the Home tab." \ + --estimate 8 \ --evidence "Before/after screenshot of the Home status card." \ --validation "Reporter confirms the TestFlight build no longer implies live data." ``` diff --git a/.factory/schemas/bugfix-spec.schema.json b/.factory/schemas/bugfix-spec.schema.json index 3b63b6bf..b0bddfb7 100644 --- a/.factory/schemas/bugfix-spec.schema.json +++ b/.factory/schemas/bugfix-spec.schema.json @@ -14,6 +14,7 @@ "affectedSurface", "observedBehavior", "expectedBehavior", + "estimate", "reproductionSteps", "acceptanceCriteria", "evidencePlan", @@ -30,6 +31,11 @@ "affectedSurface": { "type": "string", "minLength": 1 }, "observedBehavior": { "type": "string", "minLength": 20 }, "expectedBehavior": { "type": "string", "minLength": 20 }, + "estimate": { + "type": "integer", + "enum": [1, 2, 4, 8, 16, 32, 64], + "description": "SF effort estimate from the exponential ladder 1, 2, 4, 8, 16, 32, 64. Opus maps this to Linear complexity as 1->1, 2->2, 4->4, 8->8, 16->16, 32->24, 64->40." + }, "reproductionSteps": { "type": "array", "minItems": 1, diff --git a/.factory/templates/bugfix-SPEC.md b/.factory/templates/bugfix-SPEC.md index 6c46127e..7374fb30 100644 --- a/.factory/templates/bugfix-SPEC.md +++ b/.factory/templates/bugfix-SPEC.md @@ -31,6 +31,11 @@ This bugfix SPEC turns a contributor or LLM-session report into a factory-ready When [evidence plan runs] Then [artifact proves the behavior] +## Effort estimate +- Estimate: [1, 2, 4, 8, 16, 32, 64] +- Confidence: [low|medium|high] +- Reasoning: [One-sentence rationale] + ## Evidence Plan [Screenshots, videos, XCTest/SnapshotTesting, Maestro flow, logs, manifest, or TestFlight validation needed to prove the fix.] diff --git a/scripts/intake/bugfix_spec.py b/scripts/intake/bugfix_spec.py index 2f8a73db..689d5bf2 100755 --- a/scripts/intake/bugfix_spec.py +++ b/scripts/intake/bugfix_spec.py @@ -17,6 +17,8 @@ ROOT = Path(__file__).resolve().parents[2] DEFAULT_OUT_DIR = ROOT / ".factory" / "intake" TARGET_REPO = "RiddimSoftware/epac" +EFFORT_ESTIMATE_LADDER = (1, 2, 4, 8, 16, 32, 64) +EFFORT_ESTIMATE_CHOICES = ", ".join(str(v) for v in EFFORT_ESTIMATE_LADDER) REQUIRED_SECTIONS = [ "## Original Prompt", @@ -25,6 +27,7 @@ "### Expected Behavior", "## Reproduction Steps", "## Acceptance Criteria", + "## Effort estimate", "## Evidence Plan", "## Validation Plan", "## Non-goals", @@ -55,6 +58,7 @@ class BugfixSpecInput: steps: list[str] evidence: str validation: str + estimate: int original_prompt: str non_goals: str trace_id: str @@ -93,6 +97,16 @@ def collect_steps(args: argparse.Namespace) -> list[str]: return collected +def parse_estimate(value: str) -> int: + try: + estimate = int(value) + except ValueError as error: + raise ValueError(f"--estimate must be one of {EFFORT_ESTIMATE_CHOICES}") from error + if estimate not in EFFORT_ESTIMATE_LADDER: + raise ValueError(f"--estimate must be one of {EFFORT_ESTIMATE_CHOICES}") + return estimate + + def value_or_prompt(args: argparse.Namespace, attr: str, label: str) -> str: value = getattr(args, attr) if value: @@ -111,6 +125,7 @@ def build_input(args: argparse.Namespace) -> BugfixSpecInput: expected = value_or_prompt(args, "expected", "Expected behavior") evidence = value_or_prompt(args, "evidence", "Required evidence") validation = value_or_prompt(args, "validation", "Reporter/TestFlight validation") + estimate = parse_estimate(value_or_prompt(args, "estimate", f"Effort estimate ({EFFORT_ESTIMATE_CHOICES})")) steps = collect_steps(args) missing = [ @@ -124,6 +139,7 @@ def build_input(args: argparse.Namespace) -> BugfixSpecInput: ("--expected", expected), ("--evidence", evidence), ("--validation", validation), + ("--estimate", str(estimate)), ] if not value ] @@ -152,6 +168,7 @@ def build_input(args: argparse.Namespace) -> BugfixSpecInput: expected=expected, steps=steps, evidence=evidence, + estimate=estimate, validation=validation, original_prompt=original_prompt, non_goals=non_goals, @@ -200,6 +217,11 @@ def render_spec(spec: BugfixSpecInput) -> str: When the reporter validates the affected surface Then their approval or blocker is recorded before release promotion. +## Effort estimate +- Estimate: {spec.estimate} +- Confidence: medium +- Reasoning: Initial SF effort estimate captured during intake; Opus may revise this during enrichment. + ## Evidence Plan {spec.evidence} @@ -295,6 +317,11 @@ def section_body(text: str, heading: str) -> str: return match.group("body").strip() if match else "" +def section_field(section: str, label: str) -> str: + match = re.search(rf"^\s*-?\s*{re.escape(label)}:\s*(.+)$", section, re.MULTILINE) + return match.group(1).strip() if match else "" + + def validate_text(text: str) -> list[str]: errors: list[str] = [] if not text.startswith("# Bugfix SPEC: "): @@ -316,6 +343,16 @@ def validate_text(text: str) -> list[str]: if len(criteria) < 2: errors.append("missing at least two acceptance criteria starting with '- Given'") + effort_section = section_body(text, "## Effort estimate") + estimate_text = section_field(effort_section, "Estimate") + if not estimate_text: + errors.append("missing required effort estimate field: Estimate") + else: + try: + parse_estimate(estimate_text) + except ValueError as error: + errors.append(f"invalid effort estimate: {error}") + if PLACEHOLDER_RE.search(text): errors.append("SPEC contains placeholder text; replace TBD/TODO/FIXME/bracket placeholders before intake") @@ -409,6 +446,7 @@ def build_parser() -> argparse.ArgumentParser: new.add_argument("--surface") new.add_argument("--observed") new.add_argument("--expected") + new.add_argument("--estimate") new.add_argument("--step", action="append", default=[]) new.add_argument("--evidence") new.add_argument("--validation") diff --git a/scripts/intake/test_bugfix_spec.py b/scripts/intake/test_bugfix_spec.py index 144fdf53..dd4a5c25 100644 --- a/scripts/intake/test_bugfix_spec.py +++ b/scripts/intake/test_bugfix_spec.py @@ -11,6 +11,49 @@ class BugfixSpecCLITests(unittest.TestCase): + def run_new_spec( + self, + estimate: int = 8, + tmpdir: Path | None = None, + overrides: list[str] | None = None, + ) -> Path: + if tmpdir is None: + raise ValueError("tmpdir is required in tests so output is automatically cleaned up") + output = Path(tmpdir) + spec_path = output / "SPEC.md" + args = [ + "new", + "--title", + "Home screen implies live debate data", + "--reporter", + "demo-reporter", + "--source", + "llm-session", + "--surface", + "Home status card", + "--observed", + "The card says cached live data even when only archived debates are available.", + "--expected", + "The card explains that epac currently shows past debates and archival data.", + "--estimate", + str(estimate), + "--step", + "Launch epac.", + "--step", + "Open the Home tab.", + "--evidence", + "before/after screenshot of the Home status card", + "--validation", + "Reporter confirms the TestFlight build no longer implies live data.", + "--out", + str(spec_path), + ] + if overrides: + args.extend(overrides) + result = self.run_cli(*args) + self.assertEqual(result.returncode, 0, result.stderr) + return spec_path + def run_cli(self, *args: str, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: return subprocess.run( [sys.executable, str(SCRIPT), *args], @@ -23,39 +66,12 @@ def run_cli(self, *args: str, cwd: Path | None = None) -> subprocess.CompletedPr def test_new_writes_valid_spec(self) -> None: with tempfile.TemporaryDirectory() as tmp: - output = Path(tmp) / "SPEC.md" - - result = self.run_cli( - "new", - "--title", - "Home screen implies live debate data", - "--reporter", - "demo-reporter", - "--source", - "llm-session", - "--surface", - "Home status card", - "--observed", - "The card says cached live data even when only archived debates are available.", - "--expected", - "The card explains that epac currently shows past debates and archival data.", - "--step", - "Launch epac.", - "--step", - "Open the Home tab.", - "--evidence", - "before/after screenshot of the Home status card", - "--validation", - "Reporter confirms the TestFlight build no longer implies live data.", - "--out", - str(output), - ) - - self.assertEqual(result.returncode, 0, result.stderr) + output = self.run_new_spec(tmpdir=tmp) contents = output.read_text() self.assertIn("# Bugfix SPEC: Home screen implies live debate data", contents) self.assertIn("Trace ID:", contents) self.assertIn("Reporter: demo-reporter", contents) + self.assertIn("Estimate: 8", contents) self.assertIn("Given the Home status card is visible", contents) validate = self.run_cli("validate", str(output)) @@ -76,37 +92,15 @@ def test_new_writes_valid_spec(self) -> None: def test_new_receipt_includes_session_and_source_tool_when_supplied(self) -> None: with tempfile.TemporaryDirectory() as tmp: - output = Path(tmp) / "SPEC.md" - - result = self.run_cli( - "new", - "--title", - "Home screen implies live debate data", - "--reporter", - "demo-reporter", - "--source", - "llm-session", - "--surface", - "Home status card", - "--observed", - "The card says cached live data even when only archived debates are available.", - "--expected", - "The card explains that epac currently shows past debates and archival data.", - "--step", - "Launch epac.", - "--evidence", - "before/after screenshot of the Home status card", - "--validation", - "Reporter confirms the TestFlight build no longer implies live data.", - "--source-tool", - "claude", - "--session-id", - "sess-demo", - "--out", - str(output), + output = self.run_new_spec( + tmpdir=tmp, + overrides=[ + "--source-tool", + "claude", + "--session-id", + "sess-demo", + ], ) - - self.assertEqual(result.returncode, 0, result.stderr) data = json.loads(output.with_name("receipt.json").read_text()) self.assertEqual(data["sourceTool"], "claude") self.assertEqual(data["sessionId"], "sess-demo") @@ -121,35 +115,117 @@ def test_validate_rejects_incomplete_spec(self) -> None: self.assertNotEqual(result.returncode, 0) self.assertIn("missing required section", result.stderr) - def test_issue_body_renders_from_valid_spec(self) -> None: + def test_new_rejects_invalid_estimate_values(self) -> None: with tempfile.TemporaryDirectory() as tmp: output = Path(tmp) / "SPEC.md" - create = self.run_cli( + result = self.run_cli( "new", "--title", - "Search result tap opens wrong debate", + "Home screen implies live debate data", "--reporter", - "octocat", + "demo-reporter", "--source", - "github-issue", + "llm-session", "--surface", - "Search results", + "Home status card", "--observed", - "Tapping one result opens a different debate.", + "The card says cached live data even when only archived debates are available.", "--expected", - "Tapping a result opens the selected debate.", + "The card explains that epac currently shows past debates and archival data.", + "--estimate", + "3", "--step", - "Search for budget.", + "Launch epac.", "--step", - "Tap the first result.", + "Open the Home tab.", "--evidence", - "Maestro flow video showing search result navigation", + "before/after screenshot of the Home status card", "--validation", - "Reporter retests the linked TestFlight build.", + "Reporter confirms the TestFlight build no longer implies live data.", "--out", str(output), ) - self.assertEqual(create.returncode, 0, create.stderr) + self.assertNotEqual(result.returncode, 0) + self.assertIn("--estimate must be one of", result.stderr) + + def test_validate_rejects_missing_or_off_ladder_estimate(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + output = self.run_new_spec(tmpdir=tmp) + missing = output.read_text().replace("- Estimate: 8", "") + missing_output = Path(tmp) / "missing.md" + missing_output.write_text(missing) + + missing_result = self.run_cli("validate", str(missing_output)) + self.assertNotEqual(missing_result.returncode, 0) + self.assertIn("missing required effort estimate field: Estimate", missing_result.stderr) + + off_ladder = output.read_text().replace("- Estimate: 8", "- Estimate: 10") + off_ladder_output = Path(tmp) / "off-ladder.md" + off_ladder_output.write_text(off_ladder) + + off_ladder_result = self.run_cli("validate", str(off_ladder_output)) + self.assertNotEqual(off_ladder_result.returncode, 0) + self.assertIn("invalid effort estimate", off_ladder_result.stderr) + + def test_new_accepts_each_valid_estimate_value(self) -> None: + for estimate in (1, 2, 4, 8, 16, 32, 64): + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / "SPEC.md" + result = self.run_cli( + "new", + "--title", + "Home screen implies live debate data", + "--reporter", + "demo-reporter", + "--source", + "llm-session", + "--surface", + "Home status card", + "--observed", + "The card says cached live data even when only archived debates are available.", + "--expected", + "The card explains that epac currently shows past debates and archival data.", + "--estimate", + str(estimate), + "--step", + "Launch epac.", + "--step", + "Open the Home tab.", + "--evidence", + "before/after screenshot of the Home status card", + "--validation", + "Reporter confirms the TestFlight build no longer implies live data.", + "--out", + str(output), + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(f"Estimate: {estimate}", output.read_text()) + validate = self.run_cli("validate", str(output)) + self.assertEqual(validate.returncode, 0, validate.stderr) + + def test_issue_body_renders_from_valid_spec(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + output = self.run_new_spec( + tmpdir=tmp, + overrides=[ + "--title", + "Search result tap opens wrong debate", + "--reporter", + "octocat", + "--source", + "github-issue", + "--surface", + "Search results", + "--observed", + "Tapping one result opens a different debate.", + "--expected", + "Tapping a result opens the selected debate.", + "--evidence", + "Maestro flow video showing search result navigation", + "--validation", + "Reporter retests the linked TestFlight build.", + ], + ) result = self.run_cli("issue-body", str(output))