From e3f8074ad2149ccd5dc3e6e00ea2098627d65932 Mon Sep 17 00:00:00 2001 From: "Derek Palmer (Creative)" Date: Sat, 30 May 2026 10:40:20 -0400 Subject: [PATCH] refactor(prompt-session): own bundle outcome behind resolve() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both adapters re-derived the gate→bundle branch order and the bundle-resolution try/except. Move it into PromptSession.resolve(), which returns a closed Outcome (ALLOWED/UNKNOWN_TASK/SCAN_REQUIRED/ MISSING); CLI and MCP now only encode that into exit codes / JSON-RPC. Scan-state signal sourcing stays per-adapter (ADR-0001): each still injects scan_satisfied and MCP sets scan_called on an allowed scan. CLI rc 0/1/2 and MCP -32602/-32000/-32603 unchanged. Closes #82 Co-Authored-By: Claude Opus 4.8 --- src/codeforerunner/cli.py | 59 +++++++++++++++------------- src/codeforerunner/mcp_server.py | 22 ++++++----- src/codeforerunner/prompt_session.py | 40 +++++++++++++++++++ tests/test_prompt_session.py | 32 ++++++++++++++- 4 files changed, 114 insertions(+), 39 deletions(-) diff --git a/src/codeforerunner/cli.py b/src/codeforerunner/cli.py index a7404ab..63d5298 100644 --- a/src/codeforerunner/cli.py +++ b/src/codeforerunner/cli.py @@ -9,7 +9,7 @@ from typing import Sequence from codeforerunner.bundle import find_prompts_root -from codeforerunner.prompt_session import Denial, PromptSession +from codeforerunner.prompt_session import OutcomeKind, PromptSession from codeforerunner.tasks import refresh_tasks as _refresh_tasks SCAN_DONE_ENV = "FORERUNNER_SCAN_DONE" @@ -23,64 +23,67 @@ def _scan_satisfied(repo_root: Path) -> bool: ) -def _get_bundle(args: argparse.Namespace) -> tuple[str, int]: - """Resolve bundle for args.task. Returns (bundle_text, exit_code). exit_code != 0 on error.""" +def _resolve_bundle(repo, task: str) -> tuple[str, int]: + """Resolve bundle text for *task* under *repo*. Returns (text, exit_code). + + Encodes the session's closed Outcome into CLI exit codes; the gate/order + lives in the Prompt Session, this is just the encoder. + """ try: - prompts_root = find_prompts_root(args.repo) + prompts_root = find_prompts_root(repo) except FileNotFoundError as e: print(f"error: {e}", file=sys.stderr) return "", 2 - repo_root = Path(args.repo).resolve() if args.repo else Path.cwd() + repo_root = Path(repo).resolve() if repo else Path.cwd() session = PromptSession(prompts_root, _scan_satisfied(repo_root)) - decision = session.can_run(args.task) - if not decision.allowed: - if decision.reason is Denial.UNKNOWN_TASK: - print(f"error: unknown task '{args.task}'", file=sys.stderr) - return "", 2 + outcome = session.resolve(task) + if outcome.kind is OutcomeKind.ALLOWED: + return outcome.text, 0 + if outcome.kind is OutcomeKind.UNKNOWN_TASK: + print(f"error: unknown task '{task}'", file=sys.stderr) + return "", 2 + if outcome.kind is OutcomeKind.SCAN_REQUIRED: print( f"error: scan-first required — run `forerunner scan` first " f"(writes .forerunner/scan.md). Set {SCAN_DONE_ENV}=1 to skip.", file=sys.stderr, ) return "", 1 - - try: - return session.bundle_for(args.task), 0 - except FileNotFoundError as e: - print(f"error: {e}", file=sys.stderr) - return "", 2 + # MISSING + print(f"error: {outcome.message}", file=sys.stderr) + return "", 2 -def cmd_doc(args: argparse.Namespace) -> int: - """Resolve base + partials + task bundle to stdout.""" - bundle, rc = _get_bundle(args) +def _emit_task(repo, task: str) -> int: + """Resolve *task* under *repo* and write its bundle to stdout. Returns rc.""" + bundle, rc = _resolve_bundle(repo, task) if rc != 0: return rc sys.stdout.write(bundle) return 0 -def _doc_for(args: argparse.Namespace, task: str) -> int: - """Emit bundle for *task* by delegating to cmd_doc with a synthetic Namespace.""" - ns = argparse.Namespace(repo=getattr(args, "repo", None), task=task) - return cmd_doc(ns) +def cmd_doc(args: argparse.Namespace) -> int: + """Resolve base + partials + task bundle to stdout.""" + return _emit_task(args.repo, args.task) def cmd_init(args: argparse.Namespace) -> int: """Emit onboarding bundle; prepend scan bundle when --full is given.""" + repo = getattr(args, "repo", None) if getattr(args, "full", False): sys.stdout.write("\n") - rc = _doc_for(args, "scan") + rc = _emit_task(repo, "scan") if rc != 0: return rc sys.stdout.write("\n\n") - return _doc_for(args, "init-agent-onboarding") + return _emit_task(repo, "init-agent-onboarding") def cmd_scan(args: argparse.Namespace) -> int: """Emit the scan prompt bundle and hint about scan artifact.""" - rc = _doc_for(args, "scan") + rc = _emit_task(getattr(args, "repo", None), "scan") if rc == 0: print( "hint: write the scan result to .forerunner/scan.md to satisfy the " @@ -123,10 +126,10 @@ def cmd_mcp_server(args: argparse.Namespace) -> int: def cmd_refresh(args: argparse.Namespace) -> int: """Emit scan + check + all doc-task bundles to stdout for a full doc refresh.""" + repo = getattr(args, "repo", None) task_names = [t.name for t in _refresh_tasks()] for i, task in enumerate(task_names): - ns = argparse.Namespace(repo=getattr(args, "repo", None), task=task) - rc = cmd_doc(ns) + rc = _emit_task(repo, task) if rc != 0: return rc if i < len(task_names) - 1: diff --git a/src/codeforerunner/mcp_server.py b/src/codeforerunner/mcp_server.py index 70bb68d..ac16abf 100644 --- a/src/codeforerunner/mcp_server.py +++ b/src/codeforerunner/mcp_server.py @@ -12,7 +12,7 @@ from codeforerunner import __version__ as _pkg_version from codeforerunner.bundle import find_prompts_root -from codeforerunner.prompt_session import Denial, PromptSession +from codeforerunner.prompt_session import OutcomeKind, PromptSession from codeforerunner.tasks import all_tasks as _all_tasks PROTOCOL_VERSION = "2025-03-26" @@ -86,24 +86,26 @@ def _handle(prompts_root: Path, msg: dict[str, Any], state: dict[str, Any]) -> d if not isinstance(name, str) or "/" in name or "\\" in name or ".." in name: return _err(req_id, -32602, f"invalid tool name: {name!r}") session = PromptSession(prompts_root, scan_satisfied=bool(state.get("scan_called"))) - decision = session.can_run(name) - if not decision.allowed: - if decision.reason is Denial.UNKNOWN_TASK: - return _err(req_id, -32602, f"unknown tool: {name!r}") + try: + outcome = session.resolve(name) + except Exception as e: # pragma: no cover - defensive + return _err(req_id, -32603, f"internal error: {e}") + if outcome.kind is OutcomeKind.UNKNOWN_TASK: + return _err(req_id, -32602, f"unknown tool: {name!r}") + if outcome.kind is OutcomeKind.SCAN_REQUIRED: return _err( req_id, -32000, "scan-first required: call tools/call name=scan before this task (SPEC V2)", ) + if outcome.kind is OutcomeKind.MISSING: + return _err(req_id, -32603, f"internal error: {outcome.message}") + # ALLOWED — source the per-adapter scan signal (ADR-0001) and return text. if name == "scan": state["scan_called"] = True - try: - text = session.bundle_for(name) - except Exception as e: # pragma: no cover - defensive - return _err(req_id, -32603, f"internal error: {e}") return _ok( req_id, - {"content": [{"type": "text", "text": text}], "isError": False}, + {"content": [{"type": "text", "text": outcome.text}], "isError": False}, ) return _err(req_id, -32601, f"method not found: {method!r}") diff --git a/src/codeforerunner/prompt_session.py b/src/codeforerunner/prompt_session.py index 4f797c5..5912c24 100644 --- a/src/codeforerunner/prompt_session.py +++ b/src/codeforerunner/prompt_session.py @@ -30,6 +30,28 @@ class Decision: message: str | None = None +class OutcomeKind(Enum): + ALLOWED = auto() + UNKNOWN_TASK = auto() + SCAN_REQUIRED = auto() + MISSING = auto() + + +@dataclass(frozen=True) +class Outcome: + """Closed result of resolving a task end-to-end: gate + bundle in one. + + Adapters encode this into their surface (exit codes for CLI, JSON-RPC for + MCP) without re-deriving the branch order. ``text`` is set only for + ``ALLOWED``; ``message`` carries the human/error detail otherwise. + """ + + kind: OutcomeKind + text: str | None = None + task: tasks.Task | None = None + message: str | None = None + + class PromptSession: def __init__(self, prompts_root: Path, scan_satisfied: bool) -> None: self._prompts_root = prompts_root @@ -51,3 +73,21 @@ def can_run(self, name: str) -> Decision: def bundle_for(self, name: str) -> str: """Resolve the prompt bundle text for *name*. Call only after can_run allows.""" return resolve_bundle(self._prompts_root, name) + + def resolve(self, name: str) -> Outcome: + """Gate *name* and, if allowed, resolve its bundle — as one closed Outcome. + + Owns the branch order (unknown → scan-gate → bundle) so adapters only + encode the result. Scan-state signal sourcing stays with the adapter + (it injects ``scan_satisfied``); see docs/adr/0001. + """ + decision = self.can_run(name) + if not decision.allowed: + if decision.reason is Denial.UNKNOWN_TASK: + return Outcome(OutcomeKind.UNKNOWN_TASK, message=decision.message) + return Outcome(OutcomeKind.SCAN_REQUIRED, task=decision.task, message=decision.message) + try: + text = self.bundle_for(name) + except FileNotFoundError as e: + return Outcome(OutcomeKind.MISSING, task=decision.task, message=str(e)) + return Outcome(OutcomeKind.ALLOWED, text=text, task=decision.task) diff --git a/tests/test_prompt_session.py b/tests/test_prompt_session.py index 1a17b39..57ae404 100644 --- a/tests/test_prompt_session.py +++ b/tests/test_prompt_session.py @@ -3,7 +3,7 @@ from pathlib import Path import codeforerunner -from codeforerunner.prompt_session import Denial, PromptSession +from codeforerunner.prompt_session import Denial, OutcomeKind, PromptSession PROMPTS_ROOT = Path(codeforerunner.__file__).parent / "prompts" @@ -48,3 +48,33 @@ def test_bundle_for_resolves_prompt_text(): session = PromptSession(PROMPTS_ROOT, scan_satisfied=True) text = session.bundle_for("readme") assert "" in text + + +# ── resolve(): closed Outcome for adapters to encode ────────────────────────── + + +def test_resolve_allowed_carries_bundle_text(): + session = PromptSession(PROMPTS_ROOT, scan_satisfied=True) + outcome = session.resolve("readme") + assert outcome.kind is OutcomeKind.ALLOWED + assert "" in outcome.text + + +def test_resolve_unknown_task(): + session = PromptSession(PROMPTS_ROOT, scan_satisfied=True) + assert session.resolve("not-a-real-task").kind is OutcomeKind.UNKNOWN_TASK + + +def test_resolve_scan_required(): + session = PromptSession(PROMPTS_ROOT, scan_satisfied=False) + assert session.resolve("readme").kind is OutcomeKind.SCAN_REQUIRED + + +def test_resolve_missing_bundle(monkeypatch): + import codeforerunner.prompt_session as ps + + monkeypatch.setattr(ps, "resolve_bundle", lambda *a, **k: (_ for _ in ()).throw(FileNotFoundError("gone"))) + session = PromptSession(PROMPTS_ROOT, scan_satisfied=True) + outcome = session.resolve("readme") + assert outcome.kind is OutcomeKind.MISSING + assert "gone" in outcome.message