Skip to content
Open
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
42 changes: 27 additions & 15 deletions skills/pm/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,41 +66,53 @@ Then check: does `meta.ask_user_question` exist?
```
Do NOT modify it. Do NOT add options. Do NOT rephrase the question.

- **NO** → This is an interview question. Use `AskUserQuestion` with `meta.question` and generate 2-3 suggested answers.
- **NO** → This is an interview question. Use `AskUserQuestion` with `meta.question`.
- If `meta.skip_eligible == true`: add a skip option based on `meta.classification`:
- `classification == "decide_later"` → add option `{"label": "Decide later", "description": "Skip — will be recorded as an open item in the PRD"}`
- `classification == "deferred"` → add option `{"label": "Defer to dev", "description": "Skip — this technical decision will be deferred to the development phase"}`
- Generate 2-3 suggested answers as the other options.

**C. Relay answer back:**

If the user chose "Decide later" → send `answer="[decide_later]"`.
If the user chose "Defer to dev" → send `answer="[deferred]"`.
Otherwise → send the user's answer normally.

```
Tool: ouroboros_pm_interview
Arguments:
session_id: <meta.session_id>
<meta.response_param>: <user's answer>
<meta.response_param>: <user's answer or "[decide_later]" or "[deferred]">
```

**D. Check completion:**

If `meta.is_complete == true` → go to Step 4.
Otherwise → repeat Step 3.
Completion is determined ONLY by `meta.is_complete` — NEVER by the response text.
The MCP response text may sound like the interview is wrapping up, but ignore it.

### Step 4: Generate
If `meta.is_complete == true`:
- If `meta.generation_failed == true` → retry generation:
```
Tool: ouroboros_pm_interview
Arguments:
session_id: <session_id>
action: "generate"
cwd: <current working directory>
```
- Otherwise → go to Step 4. The MCP auto-generated the PM document.
`meta.pm_path` and `meta.seed_path` contain the file paths.

```
Tool: ouroboros_pm_interview
Arguments:
session_id: <session_id>
action: "generate"
cwd: <current working directory>
```
Otherwise → repeat Step 3, regardless of what the response text says.

### Step 5: Copy to Clipboard
### Step 4: Copy to Clipboard

After generation, read the pm.md file from `meta.pm_path` and copy its contents to the clipboard:
Read the pm.md file from `meta.pm_path` and copy its contents to the clipboard:

```bash
cat <meta.pm_path> | pbcopy
```

### Step 6: Show Result & Next Step
### Step 5: Show Result & Next Step

Show the following to the user:

Expand Down
143 changes: 98 additions & 45 deletions src/ouroboros/bigbang/pm_interview.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,18 +55,7 @@
_PM_SYSTEM_PROMPT_PREFIX = """\
You are a Product Requirements interviewer helping a PM define their product.

Focus on PRODUCT-LEVEL questions:
- What problem does this solve and for whom?
- What are the business goals and success metrics?
- What are the user stories and workflows?
- What constraints exist (timeline, budget, compliance)?
- What is in scope vs out of scope?
- What are the acceptance criteria?

Do NOT ask about:
- Implementation details (databases, frameworks, APIs)
- Architecture decisions (microservices, deployment)
- Code-level patterns or testing strategies
Focus on: goal, user stories, constraints, success criteria, assumptions.

"""

Expand Down Expand Up @@ -158,7 +147,8 @@ class PMInterviewEngine:
"""Original question text for questions classified as DECIDE_LATER.

These are questions that are premature or unknowable at the PM stage.
They are auto-answered with a placeholder and stored here so the PMSeed
The main session presents the question to the user with a "decide later"
option; when chosen, the caller records the item here so the PMSeed
and PM document can surface them as explicit "decide later" decisions.
"""
classifications: list[ClassificationResult] = field(default_factory=list)
Expand Down Expand Up @@ -484,47 +474,39 @@ async def ask_next_question(
output_type = classification.output_type

if output_type == ClassifierOutputType.DEFERRED:
# Track as deferred item and generate a new question
self.deferred_items.append(classification.original_question)
# Return the question to the caller (main session) so the user
# can choose to defer it themselves. The main session detects
# classification == "deferred" via response_meta and offers
# a "skip / defer to dev" option. If the user picks it, the
# caller calls skip_as_deferred() which records the deferral
# and appends to deferred_items.
#
# Previously this branch auto-answered and recursed, which could
# trigger MCP 120s timeouts on consecutive DEFERRED runs.
log.info(
"pm.question_deferred",
"pm.question_deferred_candidate",
question=classification.original_question[:100],
reasoning=classification.reasoning,
output_type=output_type,
)
# Feed an automatic response back to the inner InterviewEngine
# so the round is properly recorded and the engine advances.
# This prevents the inner engine from re-generating similar
# technical questions it doesn't know were already handled.
await self.record_response(
state,
user_response="[Deferred to development phase] "
"This technical decision will be addressed during the "
"development interview.",
question=classification.original_question,
)
# Recursively ask for the next real question
return await self.ask_next_question(state)
return Result.ok(classification.original_question)

if output_type == ClassifierOutputType.DECIDE_LATER:
# Auto-answer with placeholder — no PM interaction needed
placeholder = classification.placeholder_response
self.decide_later_items.append(classification.original_question)
# Return the question to the caller (main session) so the user
# can choose "decide later" themselves. The main session detects
# classification == "decide_later" via response_meta and offers
# the option. If the user picks it, the caller calls
# skip_as_decide_later() which records the placeholder and
# appends to decide_later_items.
#
# Previously this branch auto-answered and recursed, which could
# trigger MCP 120s timeouts on consecutive DECIDE_LATER runs.
log.info(
"pm.question_decide_later",
question=classification.original_question[:100],
placeholder=placeholder[:100],
reasoning=classification.reasoning,
)
# Record the placeholder as the response so the interview
# engine advances its round count
await self.record_response(
state,
user_response=f"[Decide later] {placeholder}",
question=classification.original_question,
)
# Recursively ask for the next real question
return await self.ask_next_question(state)
return Result.ok(classification.original_question)

if output_type == ClassifierOutputType.REFRAMED:
# Use the reframed version and track the mapping
Expand Down Expand Up @@ -595,6 +577,76 @@ async def record_response(

return await self.inner.record_response(state, user_response, question)

async def skip_as_decide_later(
self,
state: InterviewState,
question: str,
) -> Result[InterviewState, ValidationError]:
"""Skip a question as "decide later" at the user's explicit request.

Records the question in ``decide_later_items`` and feeds a placeholder
response to the inner InterviewEngine so the round is properly recorded
and the engine advances.

This is called when the main session detects that the user chose the
"decide later" option for a DECIDE_LATER-classified question, instead
of the old auto-skip behavior inside ``ask_next_question``.

Args:
state: Current interview state.
question: The question the user chose to decide later.

Returns:
Result containing updated state or ValidationError.
"""
if question not in self.decide_later_items:
self.decide_later_items.append(question)

log.info(
"pm.question_decide_later_by_user",
question=question[:100],
)

return await self.record_response(
state,
user_response="[Decide later] To be determined — user chose to decide later.",
question=question,
)

async def skip_as_deferred(
self,
state: InterviewState,
question: str,
) -> Result[InterviewState, ValidationError]:
"""Skip a question as "deferred to dev" at the user's explicit request.

Records the question in ``deferred_items`` and feeds a deferral
response to the inner InterviewEngine so the round is properly recorded
and the engine advances.

Args:
state: Current interview state.
question: The question the user chose to defer.

Returns:
Result containing updated state or ValidationError.
"""
if question not in self.deferred_items:
self.deferred_items.append(question)

log.info(
"pm.question_deferred_by_user",
question=question[:100],
)

return await self.record_response(
state,
user_response="[Deferred to development phase] "
"This technical decision will be addressed during the "
"development interview.",
question=question,
)

async def complete_interview(
self,
state: InterviewState,
Expand Down Expand Up @@ -1129,13 +1181,14 @@ def _parse_pm_seed(
for s in data.get("user_stories", [])
)

# Merge deferred items from classifier with extraction
# Merge LLM-extracted items with engine-tracked items, deduplicating.
# The extraction prompt includes raw items as context so the LLM may
# already emit them, but engine-tracked items are authoritative and
# must survive even if the extractor omits them.
all_deferred = list(data.get("deferred_items", []))
for item in self.deferred_items:
if item not in all_deferred:
all_deferred.append(item)

# Merge decide-later items — stored as original question text
all_decide_later = list(data.get("decide_later_items", []))
for item in self.decide_later_items:
if item not in all_decide_later:
Expand Down
36 changes: 24 additions & 12 deletions src/ouroboros/bigbang/question_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
Classifies generated questions as PM-answerable (planning), DEV-only
(development/technical), or decide-later (premature/unknowable).

DEV-only questions are reframed into PM-friendly language or deferred to
the development interview phase. Decide-later questions get an automatic
placeholder response without PM interaction.
DEV-only questions are reframed into PM-friendly language or returned to
the user with the option to defer to the development interview phase.
Decide-later questions are returned to the user with the option to defer
rather than being automatically skipped.

Uses a Sonnet-grade model for accurate judgment and reframing.
"""
Expand Down Expand Up @@ -51,8 +52,8 @@ class ClassifierOutputType(StrEnum):

PASSTHROUGH: Planning question forwarded unchanged to the PM.
REFRAMED: Development question rewritten in PM-friendly language.
DEFERRED: Deeply technical question deferred to the dev interview phase.
DECIDE_LATER: Premature question auto-answered with a placeholder.
DEFERRED: Deeply technical question returned to user with defer option.
DECIDE_LATER: Premature question returned to user with decide-later option.
"""

PASSTHROUGH = "passthrough"
Expand All @@ -73,8 +74,9 @@ class ClassificationResult:
reasoning: Why the classifier chose this category.
defer_to_dev: Whether this question should be deferred entirely.
decide_later: Whether this question is premature and should be
auto-answered with a placeholder.
placeholder_response: Automatic response for decide-later questions.
returned to the user with the option to defer.
placeholder_response: Placeholder response for decide-later questions
(used if the user chooses to defer).
"""

original_question: str
Expand All @@ -90,8 +92,10 @@ def output_type(self) -> ClassifierOutputType:
"""Determine the classifier output type for this result.

Returns:
DECIDE_LATER if premature/unknowable question (auto-answered),
DEFERRED if deeply technical (skipped entirely),
DECIDE_LATER if premature/unknowable question (returned to user
with option to defer),
DEFERRED if deeply technical (returned to user with option to
defer to dev phase),
PASSTHROUGH if planning question (forwarded unchanged),
REFRAMED if development question rewritten for PM.
"""
Expand All @@ -109,13 +113,20 @@ def question_for_pm(self) -> str:

For PASSTHROUGH: returns original_question unchanged.
For REFRAMED: returns the reframed_question.
For DEFERRED: returns empty string (should not be shown).
For DECIDE_LATER: returns empty string (auto-answered).
For DEFERRED: returns original_question (shown to user
so they can choose to answer or defer to dev).
For DECIDE_LATER: returns original_question (shown to user
so they can choose to answer or defer).
"""
if self.output_type == ClassifierOutputType.PASSTHROUGH:
return self.original_question
if self.output_type == ClassifierOutputType.REFRAMED:
return self.reframed_question
if self.output_type in (
ClassifierOutputType.DECIDE_LATER,
ClassifierOutputType.DEFERRED,
):
return self.original_question
return ""


Expand Down Expand Up @@ -181,7 +192,8 @@ class QuestionClassifier:

Uses a Sonnet-grade LLM to judge whether questions are appropriate
for a PM audience, reframes technical questions when possible, and
identifies premature questions that should be auto-answered.
identifies premature questions that should be returned to the user
with the option to defer.

Attributes:
llm_adapter: LLM adapter for classification calls.
Expand Down
Loading
Loading