Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
67 changes: 67 additions & 0 deletions skills/interview/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,70 @@ Socratic interview to crystallize vague requirements into clear specifications.

```
ooo interview [topic]
ooo interview+ [topic]
/ouroboros:interview [topic]
```

**Trigger keywords:** "interview me", "clarify requirements"

### Standard vs Interview+

| Variant | Command | Behavior |
|---------|---------|----------|
| Standard | `ooo interview` | Single interviewer persona throughout |
| Interview+ | `ooo interview+` | Rotates consulting personas between questions for diverse perspectives |

**Interview+** enhances the interview by rotating the system prompt through
five consulting personas between questions. Each persona brings a distinct
analytical lens, surfacing requirements that a single viewpoint might miss.
The total question count does **not** increase — the same number of questions
are asked, but from varied perspectives.

#### Available Consulting Personas

| Persona | Lens | What it surfaces |
|---------|------|------------------|
| `contrarian` | Challenges assumptions | Edge cases, hidden risks, unstated constraints |
| `architect` | System design & scalability | Component boundaries, integration points, NFRs |
| `researcher` | Evidence & prior art | Existing solutions, academic patterns, data needs |
| `hacker` | Exploit & stress-test | Security gaps, failure modes, abuse scenarios |
| `ontologist` | Naming & domain modeling | Ubiquitous language, entity relationships, taxonomies |

Personas are loaded via `load_persona_prompt_data` from `ouroboros.agents.loader`
and injected as system prompt rotations — **not** as separate LLM calls.

#### Interview+ Examples

```
User: ooo interview+ Build a REST API

[consultant: architect]
Q1: What domain entities will this API expose, and how do they relate to each other?
User: Tasks and projects — tasks belong to projects.

[consultant: hacker]
Q2: How will you authenticate API consumers, and what happens if a token is
stolen or replayed?
User: JWT with refresh tokens, short-lived access tokens.

[consultant: ontologist]
Q3: You said "tasks belong to projects" — is a task always scoped to exactly
one project, or can it exist independently?
User: Always scoped to one project.

[consultant: contrarian]
Q4: You chose JWT — have you considered that JWTs can't be revoked server-side
without a blacklist? Would session tokens be simpler for your use case?
User: Good point, we'll add a token blacklist.

[consultant: researcher]
Q5: Similar task-management APIs (Asana, Linear) expose webhooks for
real-time updates. Will your API need event-driven integrations?
User: Yes, webhooks for task status changes.

📍 Next: `ooo seed` to crystallize these requirements into a specification
```

## Instructions

When the user invokes this skill:
Expand Down Expand Up @@ -56,6 +115,14 @@ Compare the result with the current version in `.claude-plugin/plugin.json`.
- If "Skip": proceed immediately.
- If versions match, the check fails (network error, timeout, rate limit 403/429), or parsing fails/returns empty: **silently skip** and proceed.

**Detecting interview+ mode**: If the user's command includes the `+` suffix
(e.g., `ooo interview+`), pass the consulting personas to the interview engine.
The five personas — `contrarian`, `architect`, `researcher`, `hacker`,
`ontologist` — are rotated as system prompt enrichments between questions.
The `interview_started` event metadata records which consultants are active
(e.g., `"consultants": ["contrarian", "architect", "researcher", "hacker", "ontologist"]`).
Default `ooo interview` (without `+`) uses no personas and behaves exactly as before.

Then choose the execution path:

### Step 0.5: Load MCP Tools (Required before Path A/B decision)
Expand Down
21 changes: 20 additions & 1 deletion src/ouroboros/bigbang/interview.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ class InterviewState(BaseModel):
explore_completed: bool = False
ambiguity_score: float | None = Field(default=None, ge=0.0, le=1.0)
ambiguity_breakdown: dict[str, Any] | None = None
consulting_personas: tuple[str, ...] = ()

@property
def current_round_number(self) -> int:
Expand Down Expand Up @@ -167,6 +168,7 @@ class InterviewEngine:
model: str = _FALLBACK_MODEL
temperature: float = 0.7
max_tokens: int = 2048
consulting_personas: tuple[str, ...] = ()

def __post_init__(self) -> None:
"""Ensure state directory exists."""
Expand Down Expand Up @@ -490,7 +492,7 @@ def _build_system_prompt(self, state: InterviewState) -> str:
"""
import os

from ouroboros.agents.loader import load_agent_prompt
from ouroboros.agents.loader import load_agent_prompt, load_persona_prompt_data

round_info = f"Round {state.current_round_number}"
preferred_web_tool = os.environ.get("OUROBOROS_WEB_SEARCH_TOOL", "").strip()
Expand Down Expand Up @@ -520,6 +522,23 @@ def _build_system_prompt(self, state: InterviewState) -> str:
if web_search_hint:
base_prompt = base_prompt.replace("## TOOL USAGE", f"## TOOL USAGE{web_search_hint}\n")

# Inject consulting persona lens when interview+ mode is active
if self.consulting_personas:
persona_name = self.consulting_personas[
(state.current_round_number - 1) % len(self.consulting_personas)
]
persona_data = load_persona_prompt_data(persona_name)
approach_lines = "\n".join(
f" {i}. {instr}"
for i, instr in enumerate(persona_data.approach_instructions, 1)
)
dynamic_header += (
f"\n\n## Consulting Persona Lens: {persona_name.upper()}\n"
f"{persona_data.system_prompt}\n\n"
f"Apply this perspective when forming your questions this round:\n"
f"{approach_lines}\n"
)

# Inject codebase context for brownfield projects
if state.is_brownfield and state.codebase_context:
dynamic_header += (
Expand Down
22 changes: 18 additions & 4 deletions src/ouroboros/events/interview.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,29 @@
def interview_started(
interview_id: str,
initial_context: str,
*,
consultants_enabled: bool = False,
consultants: tuple[str, ...] | list[str] = (),
) -> BaseEvent:
"""Create event when a new interview session starts."""
"""Create event when a new interview session starts.

Args:
interview_id: Unique interview session identifier.
initial_context: The user-supplied project description (truncated to 500 chars).
consultants_enabled: Whether consulting persona rotation is active.
consultants: Names of the consulting personas in use (e.g. ``["contrarian", "architect"]``).
"""
data: dict[str, object] = {
"initial_context": initial_context[:500],
"consultants_enabled": consultants_enabled,
}
if consultants:
data["consultants"] = list(consultants)
return BaseEvent(
type="interview.started",
aggregate_type="interview",
aggregate_id=interview_id,
data={
"initial_context": initial_context[:500],
},
data=data,
)


Expand Down
50 changes: 50 additions & 0 deletions src/ouroboros/mcp/tools/definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1109,9 +1109,45 @@ def definition(self) -> MCPToolDefinition:
),
required=False,
),
MCPToolParameter(
name="consulting_personas",
type=ToolInputType.STRING,
description=(
"Comma-separated list of consulting personas to rotate "
"through during the interview (interview+ mode). "
"Available personas: contrarian, architect, researcher, "
"hacker, ontologist. Example: 'contrarian,architect,researcher'. "
"If omitted, default interview behavior is used."
),
required=False,
),
),
)

@staticmethod
def _parse_consulting_personas(raw: str | None) -> tuple[str, ...]:
"""Parse comma-separated consulting personas string into a validated tuple.

Args:
raw: Comma-separated persona names, or None.

Returns:
Tuple of valid persona name strings. Empty tuple if raw is None/empty.
"""
if not raw:
return ()
valid_personas = {"contrarian", "architect", "researcher", "hacker", "ontologist"}
all_tokens = [p.strip().lower() for p in raw.split(",") if p.strip()]
parsed = tuple(t for t in all_tokens if t in valid_personas)
rejected = [t for t in all_tokens if t not in valid_personas]
if rejected:
log.warning(
"mcp.tool.interview.unknown_personas",
rejected=rejected,
valid=sorted(valid_personas),
)
return parsed

async def handle(
self,
arguments: dict[str, Any],
Expand All @@ -1127,11 +1163,15 @@ async def handle(
initial_context = arguments.get("initial_context")
session_id = arguments.get("session_id")
answer = arguments.get("answer")
consulting_personas = self._parse_consulting_personas(
arguments.get("consulting_personas")
)

# Use injected or create interview engine
engine = self.interview_engine or InterviewEngine(
llm_adapter=ClaudeAgentAdapter(permission_mode="bypassPermissions"),
state_dir=Path.home() / ".ouroboros" / "data",
consulting_personas=consulting_personas,
)

_interview_id: str | None = None # Track for error event emission
Expand Down Expand Up @@ -1197,6 +1237,10 @@ async def handle(
)
state.mark_updated()

# Store consulting_personas in state for resume persistence
if consulting_personas:
state.consulting_personas = consulting_personas

# Persist state to disk so subsequent calls can resume
save_result = await engine.save_state(state)
if save_result.is_err:
Expand All @@ -1212,6 +1256,8 @@ async def handle(
interview_started(
state.interview_id,
initial_context,
consultants_enabled=bool(engine.consulting_personas),
consultants=engine.consulting_personas,
)
)

Expand Down Expand Up @@ -1247,6 +1293,10 @@ async def handle(
state = load_result.value
_interview_id = session_id

# Restore consulting_personas from state if not provided in this call
if not consulting_personas and state.consulting_personas:
engine.consulting_personas = state.consulting_personas

# If answer provided, record it first
if answer:
if not state.rounds:
Expand Down
Loading
Loading