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
59 changes: 31 additions & 28 deletions src/codeforerunner/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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("<!-- forerunner init --full: section 1/2 (scan) -->\n")
rc = _doc_for(args, "scan")
rc = _emit_task(repo, "scan")
if rc != 0:
return rc
sys.stdout.write("\n<!-- forerunner init --full: section 2/2 (onboarding) -->\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 "
Expand Down Expand Up @@ -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:
Expand Down
22 changes: 12 additions & 10 deletions src/codeforerunner/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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}")
Expand Down
40 changes: 40 additions & 0 deletions src/codeforerunner/prompt_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
32 changes: 31 additions & 1 deletion tests/test_prompt_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -48,3 +48,33 @@ def test_bundle_for_resolves_prompt_text():
session = PromptSession(PROMPTS_ROOT, scan_satisfied=True)
text = session.bundle_for("readme")
assert "<!-- task: readme.md -->" 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 "<!-- task: readme.md -->" 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