Class: RubynCode::Megaplan::InterviewSession
- Inherits:
-
Object
- Object
- RubynCode::Megaplan::InterviewSession
- Defined in:
- lib/rubyn_code/megaplan/interview_session.rb
Overview
Drives a multi-turn LLM conversation that gathers enough context to produce a megaplan. The model has a small whitelist of READ-ONLY tools (read_file, grep, glob, git_status, git_diff, git_log) so it can inspect the codebase before asking sharper questions — but it cannot edit, run shell mutations, or call any side-effecting tool.
Each interview turn ends in one of two JSON shapes:
{ "question": { "text": "...", "options": ["a", "b"] | null } }
{ "plan": { "slug": ..., "feature": ..., "phases": [...] } }
Validation of the plan payload is delegated to PlanProposer’s existing rules so both /megaplan paths stay consistent.
Defined Under Namespace
Classes: InvalidAnswerError, MalformedResponseError, Question
Constant Summary collapse
- SKILL_PATH =
The megaplan skill lives in the gem’s shared skill catalog (skills/megaplan/megaplan.md) so it’s also reachable as ‘/skill megaplan` from the REPL and the chat. We load the file body directly (skipping the YAML frontmatter) for the system prompt.
File.('../../../skills/megaplan/megaplan.md', __dir__)
- INTERVIEW_TOOLS =
Whitelist of read-only tools the interviewer may call. Picked from the existing Tools::Registry by name. Anything that writes, runs shell mutations, or spawns sub-agents is intentionally excluded.
%w[ read_file glob grep git_status git_diff git_log ].freeze
- MAX_TOOL_TURNS =
Safety cap on the interview’s per-turn tool loop. A well-behaved interviewer should read at most a handful of files before asking its next question; this stops a runaway model from stalling the session indefinitely. Per-turn, not per-session.
10- JSON_OUTPUT_CONTRACT =
Strict output contract bolted on top of the megaplan skill body. The skill teaches what a megaplan is and how to interview; this contract teaches the LLM the wire format the gem expects on every turn AND that its tool palette is read-only.
<<~CONTRACT.freeze # Output contract (overrides any other formatting instinct) You are an interviewer, not a coding agent. You have a READ-ONLY tool palette: `read_file`, `glob`, `grep`, `git_status`, `git_diff`, `git_log`. Use them sparingly — only when looking at the code would let you ask a SHARPER question (e.g. confirming a column already exists before asking about it). You must NOT edit, write, run shell mutations, or call any other tool. There are no other tools available. After any tool use, your next message must be a single JSON object — no markdown fences, no prose before or after — in one of these two shapes: { "question": { "text": "<one focused question>", "options": ["a", "b", "c"] | null } } { "plan": { "slug": "<kebab-case>", "feature": "<short description>", "phases": [{ "number": 1, "slug": "<kebab>", "name": "<name>", "summary": "<one sentence>", "requirements_md": "<markdown>", "design_md": "<markdown>", "tasks_md": "<markdown>" }, ...] } } Interview rules: - Ask one question at a time. Never bundle multiple. - Prefer numbered options (3-5 choices) when there's an obvious option set. - Use null `options` only for genuinely open questions (end-state, constraints prose). - Walk the megaplan-skill agenda (goal → constraints → assets → ordering → external deps → destructive ops → tests → done-per-phase). Skip topics already obvious from context — including anything you've confirmed via a read-only tool. - Stop interviewing when you're 95% sure of the shape; emit the plan. Plan rules: - 1 to 12 phases. Each phase is a vertical slice that ships independently. - Trunk works at every phase boundary. - tasks_md uses `[ ]` checkboxes; requirements_md uses EARS-style SHALL statements when phrasing acceptance criteria. When you emit your final answer for a turn (a question or a plan), produce ONLY the JSON object. No prefatory text. No trailing commentary. Never produce free-form coding-agent output. CONTRACT
- DEFAULT_INTERVIEW_PROMPT =
"#{load_skill_body}\n\n#{JSON_OUTPUT_CONTRACT}".freeze
Instance Attribute Summary collapse
-
#session_id ⇒ Object
readonly
Returns the value of attribute session_id.
Class Method Summary collapse
Instance Method Summary collapse
-
#answer(question_id, answer_text) ⇒ Question, Hash
The next question OR the final plan payload.
-
#initialize(llm_client: nil, system_prompt: nil, workspace_path: nil, executor: nil) ⇒ InterviewSession
constructor
A new instance of InterviewSession.
-
#start ⇒ Object
Returns a Question to ask the user, or a Hash (validated plan payload) if the LLM jumped straight to the plan.
Constructor Details
#initialize(llm_client: nil, system_prompt: nil, workspace_path: nil, executor: nil) ⇒ InterviewSession
Returns a new instance of InterviewSession.
117 118 119 120 121 122 123 124 125 |
# File 'lib/rubyn_code/megaplan/interview_session.rb', line 117 def initialize(llm_client: nil, system_prompt: nil, workspace_path: nil, executor: nil) @llm_client = llm_client || LLM::Client.new @system_prompt = system_prompt || DEFAULT_INTERVIEW_PROMPT @session_id = SecureRandom.uuid @history = [] @last_question = nil @workspace_path = workspace_path || Dir.pwd @executor = executor || Tools::Executor.new(project_root: @workspace_path) end |
Instance Attribute Details
#session_id ⇒ Object (readonly)
Returns the value of attribute session_id.
115 116 117 |
# File 'lib/rubyn_code/megaplan/interview_session.rb', line 115 def session_id @session_id end |
Class Method Details
.load_skill_body ⇒ Object
41 42 43 44 |
# File 'lib/rubyn_code/megaplan/interview_session.rb', line 41 def self.load_skill_body raw = File.read(SKILL_PATH) raw.sub(/\A---\s*\n.+?\n---\s*\n/m, '') end |
Instance Method Details
#answer(question_id, answer_text) ⇒ Question, Hash
Returns the next question OR the final plan payload.
136 137 138 139 140 141 142 |
# File 'lib/rubyn_code/megaplan/interview_session.rb', line 136 def answer(question_id, answer_text) raise InvalidAnswerError, 'no question awaiting answer' unless @last_question raise InvalidAnswerError, 'wrong question id' unless @last_question.id == question_id @history << { role: 'user', content: answer_text.to_s } ask_llm(answer_text.to_s) end |
#start ⇒ Object
Returns a Question to ask the user, or a Hash (validated plan payload) if the LLM jumped straight to the plan.
129 130 131 |
# File 'lib/rubyn_code/megaplan/interview_session.rb', line 129 def start ask_llm('Begin the interview. Ask your first question.') end |