Module: KairosMcp::Daemon::Planner

Defined in:
lib/kairos_mcp/daemon/planner.rb

Overview

Planner — converts a Chronos FiredEvent into a plan for WAL commit.

Design (v0.2 P3.0):

One plan per cycle. Each OODA phase becomes a WAL step with a
content-addressed params_hash, plus pre/expected-post hashes that
let WAL recovery idempotency-check each step on restart.

Why 5 steps (observe/orient/decide/act/reflect):

The CognitiveLoop semantics map 1:1 onto these phases. Making each
phase a WAL step gives crash recovery the finest granularity the
loop currently distinguishes. If future CognitiveLoop variants add
or collapse phases, the Planner is where that change lives — the
WAL contract remains unchanged.

Constant Summary collapse

OODA_PHASES =
%w[observe orient decide act reflect].freeze

Class Method Summary collapse

Class Method Details

.build_step(phase:, idx:, cycle:, plan_id:, mandate_name:) ⇒ Object

—————————————————————- helpers



58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/kairos_mcp/daemon/planner.rb', line 58

def build_step(phase:, idx:, cycle:, plan_id:, mandate_name:)
  params = {
    phase:        phase,
    order:        idx,
    cycle:        cycle,
    plan_id:      plan_id,
    mandate_name: mandate_name
  }
  {
    step_id:            step_id_for(phase, cycle),
    tool:               "ooda.#{phase}",
    params_hash:        Canonical.sha256_json(params),
    pre_hash:           Canonical.sha256_json(phase_marker(phase, cycle, 'pre')),
    expected_post_hash: Canonical.sha256_json(phase_marker(phase, cycle, 'post'))
  }
end

.event_name(fired_event) ⇒ Object



79
80
81
82
# File 'lib/kairos_mcp/daemon/planner.rb', line 79

def event_name(fired_event)
  return fired_event.name.to_s if fired_event.respond_to?(:name) && fired_event.name
  'unnamed_event'
end

.generate_plan_id(fired_event) ⇒ Object

plan_id is deterministic on (name, fired_at) when both are present, otherwise falls back to a random suffix so distinct calls never collide. Determinism matters for recovery — the same event fired twice with the same timestamp should resolve to the same plan.



88
89
90
91
92
93
94
95
96
97
# File 'lib/kairos_mcp/daemon/planner.rb', line 88

def generate_plan_id(fired_event)
  name     = event_name(fired_event)
  fired_at = fired_event.respond_to?(:fired_at) ? fired_event.fired_at : nil
  if fired_at && !fired_at.to_s.empty?
    digest = Digest::SHA256.hexdigest("#{name}|#{fired_at}")
    "plan_#{digest[0, 12]}"
  else
    "plan_#{name}_#{SecureRandom.hex(4)}"
  end
end

.phase_marker(phase, cycle, state) ⇒ Object



75
76
77
# File 'lib/kairos_mcp/daemon/planner.rb', line 75

def phase_marker(phase, cycle, state)
  { phase: phase, cycle: cycle, state: state }
end

.plan_from_fired_event(fired_event, cycle: 1, plan_id: nil) ⇒ Hash

Build a plan for a single cycle of a fired event.

Parameters:

  • fired_event (#name, #fired_at, #mandate)

    a FiredEvent.

  • cycle (Integer) (defaults to: 1)

    1-based cycle counter.

  • plan_id (String, nil) (defaults to: nil)

    override for testing.

Returns:

  • (Hash)

    { plan_id:, cycle:, steps: [ { step_id:, tool:, params_hash:, pre_hash:, expected_post_hash: }, … ] }

Raises:

  • (ArgumentError)


36
37
38
39
40
41
42
43
44
45
46
47
48
# File 'lib/kairos_mcp/daemon/planner.rb', line 36

def plan_from_fired_event(fired_event, cycle: 1, plan_id: nil)
  cycle = Integer(cycle)
  raise ArgumentError, 'cycle must be positive' unless cycle.positive?

  pid = plan_id || generate_plan_id(fired_event)
  name = event_name(fired_event)

  steps = OODA_PHASES.each_with_index.map do |phase, idx|
    build_step(phase: phase, idx: idx, cycle: cycle, plan_id: pid, mandate_name: name)
  end

  { plan_id: pid, cycle: cycle, steps: steps }
end

.step_id_for(phase, cycle) ⇒ Object

Canonical step id for a phase within a cycle. Exposed as a helper so WalPhaseRecorder can derive the same ids without re-planning.



52
53
54
# File 'lib/kairos_mcp/daemon/planner.rb', line 52

def step_id_for(phase, cycle)
  format('%s_%03d', phase.to_s, Integer(cycle))
end