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 =
<<~PROMPT.freeze
  You are a senior Ruby/Rails architect breaking a feature request into a megaplan.

  A megaplan is a multi-phase development plan where each phase is a
  VERTICAL SLICE that can ship independently. Trunk works at every phase
  boundary. No "scaffolding first, behavior later" — every phase delivers
  a thin, end-to-end working increment.

  Output a single JSON object with this exact shape:

  {
    "slug": "kebab-case-feature-slug",
    "feature": "Short feature description",
    "phases": [
      {
        "number": 1,
        "slug": "kebab-case-phase-slug",
        "name": "Human-readable phase name",
        "summary": "One-sentence summary of what this phase ships",
        "requirements_md": "# Phase 1 — <name>: Requirements\\n\\n...",
        "design_md":       "# Phase 1 — <name>: Design\\n\\n...",
        "tasks_md":        "# Phase 1 — <name>: Tasks\\n\\n## [ ] 1. ...\\n\\n- [ ] 1.1 ..."
      }
    ]
  }

  Constraints:
    - 1 to 12 phases. Smaller, sharper phases beat fewer mega-phases.
    - Each phase must be a vertical slice.
    - tasks_md is a checklist with `[ ]` boxes (megaplan convention).
    - Every phase needs requirements_md, design_md, tasks_md.
    - Return ONLY the JSON. No markdown fences. No commentary.
PROMPT

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