Class: RubynCode::Megaplan::PlanProposer

Inherits:
Object
  • Object
show all
Defined in:
lib/rubyn_code/megaplan/plan_proposer.rb

Overview

Proposes a multi-phase megaplan for a feature description.

Asks the LLM to produce a JSON payload that matches the extension's plan_proposal shape — one folder per phase, three documents per phase, vertical-slice ordering. The handler validates the response and returns it to the IDE.

The LLM call is the slow part (~5-30s); callers should run this off the main JSON-RPC thread.

Defined Under Namespace

Classes: InvalidProposalError

Constant Summary collapse

MAX_PHASES =
12
DEFAULT_SYSTEM_PROMPT =
"You are a senior Ruby/Rails architect breaking a feature request into a megaplan.\n\nA megaplan is a multi-phase development plan where each phase is a\nVERTICAL SLICE that can ship independently. Trunk works at every phase\nboundary. No \"scaffolding first, behavior later\" \u2014 every phase delivers\na thin, end-to-end working increment.\n\nOutput a single JSON object with this exact shape:\n\n{\n  \"slug\": \"kebab-case-feature-slug\",\n  \"feature\": \"Short feature description\",\n  \"phases\": [\n    {\n      \"number\": 1,\n      \"slug\": \"kebab-case-phase-slug\",\n      \"name\": \"Human-readable phase name\",\n      \"summary\": \"One-sentence summary of what this phase ships\",\n      \"requirements_md\": \"# Phase 1 \u2014 <name>: Requirements\\\\n\\\\n...\",\n      \"design_md\":       \"# Phase 1 \u2014 <name>: Design\\\\n\\\\n...\",\n      \"tasks_md\":        \"# Phase 1 \u2014 <name>: Tasks\\\\n\\\\n## [ ] 1. ...\\\\n\\\\n- [ ] 1.1 ...\"\n    }\n  ]\n}\n\nConstraints:\n  - 1 to 12 phases. Smaller, sharper phases beat fewer mega-phases.\n  - Each phase must be a vertical slice.\n  - tasks_md is a checklist with `[ ]` boxes (megaplan convention).\n  - Every phase needs requirements_md, design_md, tasks_md.\n  - Return ONLY the JSON. No markdown fences. No commentary.\n"

Instance Method Summary collapse

Constructor Details

#initialize(llm_client: nil, system_prompt: nil, max_phases: MAX_PHASES) ⇒ PlanProposer

Returns a new instance of PlanProposer.



55
56
57
58
59
# File 'lib/rubyn_code/megaplan/plan_proposer.rb', line 55

def initialize(llm_client: nil, system_prompt: nil, max_phases: MAX_PHASES)
  @llm_client = llm_client || LLM::Client.new
  @system_prompt = system_prompt || DEFAULT_SYSTEM_PROMPT
  @max_phases = max_phases
end

Instance Method Details

#propose(feature) ⇒ Hash

Returns payload with slug, feature, phases.

Parameters:

  • feature (String) —

    the user's feature description

Returns:

  • (Hash) —

    payload with slug, feature, phases

Raises:



64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/rubyn_code/megaplan/plan_proposer.rb', line 64

def propose(feature)
  raise ArgumentError, 'feature is required' if feature.nil? || feature.strip.empty?

  response = @llm_client.chat(
    messages: [{ role: 'user', content: feature_prompt(feature) }],
    system: @system_prompt
  )

  text = extract_text(response)
  payload = parse_payload(text)
  validate!(payload, feature)
  normalize(payload, feature)
end

#validate!(payload, _feature = nil) ⇒ Object

Validate a parsed plan_proposal Hash. Public so the interview path (which produces the same shape via a different LLM workflow) can reuse the rule set without reaching into a private method.



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/rubyn_code/megaplan/plan_proposer.rb', line 81

def validate!(payload, _feature = nil)
  raise InvalidProposalError, 'payload is not an object' unless payload.is_a?(Hash)

  phases = payload['phases']
  raise InvalidProposalError, 'phases must be an array' unless phases.is_a?(Array)
  raise InvalidProposalError, 'phases is empty' if phases.empty?
  raise InvalidProposalError, "too many phases (max #{@max_phases})" if phases.size > @max_phases

  phases.each_with_index do |phase, idx|
    %w[name summary requirements_md design_md tasks_md].each do |key|
      next unless phase[key].nil? || phase[key].to_s.strip.empty?

      raise InvalidProposalError, "phase #{idx + 1} missing #{key}"
    end
  end
end