Class: RubynCode::Megaplan::InterviewSession

Inherits:
Object
  • Object
show all
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.expand_path('../../../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

Class Method Summary collapse

Instance Method Summary collapse

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_idObject (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_bodyObject



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.

Parameters:

  • question_id (String)

    echoes back the question’s id (anti-race)

  • answer_text (String)

    the user’s answer

Returns:

  • (Question, Hash)

    the next question OR the final plan payload

Raises:



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

#startObject

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