kairos-chain 3.17.0 → 3.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/bin/kairos-chain-daemon +111 -0
- data/lib/kairos_mcp/lifecycle_hook.rb +61 -0
- data/lib/kairos_mcp/signal_handle.rb +75 -0
- data/lib/kairos_mcp/skillset.rb +13 -0
- data/lib/kairos_mcp/tool_registry.rb +133 -0
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/knowledge/multi_llm_review_workflow/multi_llm_review_workflow.md +91 -0
- data/templates/skills/kairos_tutorial.md +70 -21
- data/templates/skillsets/agent/config/agent.yml +15 -2
- data/templates/skillsets/agent/lib/agent/review_hint.rb +84 -0
- data/templates/skillsets/agent/lib/agent/trigger_validator.rb +69 -0
- data/templates/skillsets/agent/lib/agent.rb +2 -0
- data/templates/skillsets/agent/test/test_agent_complexity_review.rb +205 -4
- data/templates/skillsets/agent/test/test_decide_prompt_contract.rb +91 -0
- data/templates/skillsets/agent/test/test_review_hint.rb +168 -0
- data/templates/skillsets/agent/tools/agent_start.rb +14 -0
- data/templates/skillsets/agent/tools/agent_step.rb +319 -20
- data/templates/skillsets/daemon_runtime/lib/daemon_runtime/attach_auth.rb +196 -0
- data/templates/skillsets/daemon_runtime/lib/daemon_runtime/main_loop.rb +87 -0
- data/templates/skillsets/daemon_runtime/lib/daemon_runtime/main_loop_supervisor.rb +84 -0
- data/templates/skillsets/daemon_runtime/lib/daemon_runtime/signal_coordinator.rb +176 -0
- data/templates/skillsets/daemon_runtime/lib/daemon_runtime.rb +16 -0
- data/templates/skillsets/daemon_runtime/skillset.json +16 -0
- data/templates/skillsets/daemon_runtime/test/test_attach_auth.rb +254 -0
- data/templates/skillsets/daemon_runtime/test/test_daemon_runtime.rb +250 -0
- data/templates/skillsets/llm_client/config/llm_client.yml +1 -1
- data/templates/skillsets/llm_client/lib/llm_client/call_router.rb +187 -0
- data/templates/skillsets/llm_client/lib/llm_client/codex_adapter.rb +9 -0
- data/templates/skillsets/llm_client/lib/llm_client/cursor_adapter.rb +20 -2
- data/templates/skillsets/llm_client/lib/llm_client/headless.rb +85 -0
- data/templates/skillsets/llm_client/test/test_call_router.rb +316 -0
- data/templates/skillsets/llm_client/test/test_cursor_adapter_model.rb +111 -0
- data/templates/skillsets/llm_client/test/test_cursor_adapter_retryable.rb +70 -0
- data/templates/skillsets/multi_llm_review/bin/dispatch_worker.rb +309 -0
- data/templates/skillsets/multi_llm_review/config/multi_llm_review.yml +91 -2
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/build_review_bundle.rb +170 -0
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/consensus.rb +19 -1
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/dispatcher.rb +30 -3
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/feedback_formatter.rb +58 -0
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/main_state.rb +64 -0
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/pending_state.rb +429 -0
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/persona_assembly.rb +184 -0
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/pin_resolver.rb +146 -0
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/sanitizer.rb +184 -0
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/wait_for_worker.rb +143 -0
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/worker_reaper.rb +96 -0
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/worker_spawner.rb +67 -0
- data/templates/skillsets/multi_llm_review/skillset.json +7 -4
- data/templates/skillsets/multi_llm_review/test/test_dispatcher_usage.rb +36 -0
- data/templates/skillsets/multi_llm_review/test/test_feedback_formatter.rb +97 -0
- data/templates/skillsets/multi_llm_review/test/test_multi_llm_review.rb +885 -0
- data/templates/skillsets/multi_llm_review/test/test_multi_llm_review_bundle.rb +167 -0
- data/templates/skillsets/multi_llm_review/test/test_pending_state_v3.rb +409 -0
- data/templates/skillsets/multi_llm_review/test/test_pin_resolver.rb +173 -0
- data/templates/skillsets/multi_llm_review/test/test_sanitizer.rb +213 -0
- data/templates/skillsets/multi_llm_review/test/test_worker_spawner.rb +145 -0
- data/templates/skillsets/multi_llm_review/tools/multi_llm_review.rb +370 -13
- data/templates/skillsets/multi_llm_review/tools/multi_llm_review_bundle.rb +139 -0
- data/templates/skillsets/multi_llm_review/tools/multi_llm_review_collect.rb +381 -0
- metadata +42 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 35124b7f595066816a5e59823ca5f871bcb2c009f12db8127f7570ee0e923206
|
|
4
|
+
data.tar.gz: 482cef573f07539663a119db81cbdcb5b600362a551b3ee45459d9727c78c755
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a07f31d1e33713c4993aaf396838ffaac1ac5c9979ba7ff24f0d590a39fa4341f64cb1899d061507cc13b7fdfff3a85ee6177dc21b54578a7ef2af9b8de5fe81
|
|
7
|
+
data.tar.gz: aebf6ebc84bf36682c74ebf7b000c5168051cab9685842c75dcd2a9ebc4b733713b4f1389e7e2d8fe00f61eefd3dc201885e9582a620c77e937a53999b349e3b
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
# encoding: utf-8
|
|
4
|
+
|
|
5
|
+
# KairosChain 24/7 daemon entry point (Bootstrap layer, v0.4 §2.2).
|
|
6
|
+
#
|
|
7
|
+
# Bootstrap's job:
|
|
8
|
+
# 1. Load the gem and scan .kairos/skillsets/.
|
|
9
|
+
# 2. Find a SkillSet that declares the :daemon_main lifecycle hook.
|
|
10
|
+
# 3. Install OS signal traps for TERM/INT (shutdown), HUP (reload),
|
|
11
|
+
# USR2 (diagnostic snapshot) and forward them as flags on the
|
|
12
|
+
# Bootstrap SignalHandle.
|
|
13
|
+
# 4. Hand control to the SkillSet's main loop.
|
|
14
|
+
#
|
|
15
|
+
# Everything substantive (OODA cycle, Chronos, WAL, attach endpoint,
|
|
16
|
+
# safe-mode, canary promotion) lives in SkillSets — not here.
|
|
17
|
+
|
|
18
|
+
Encoding.default_external = Encoding::UTF_8
|
|
19
|
+
Encoding.default_internal = Encoding::UTF_8
|
|
20
|
+
|
|
21
|
+
require 'optparse'
|
|
22
|
+
|
|
23
|
+
options = { data_dir: nil }
|
|
24
|
+
OptionParser.new do |opts|
|
|
25
|
+
opts.banner = 'Usage: kairos-chain-daemon [--data-dir PATH]'
|
|
26
|
+
opts.on('--data-dir PATH', 'Override .kairos/ location') { |v| options[:data_dir] = v }
|
|
27
|
+
opts.on('-h', '--help') do
|
|
28
|
+
puts opts
|
|
29
|
+
exit 0
|
|
30
|
+
end
|
|
31
|
+
end.parse!
|
|
32
|
+
|
|
33
|
+
if options[:data_dir]
|
|
34
|
+
if options[:data_dir].to_s.strip.empty?
|
|
35
|
+
warn '[kairos-chain-daemon] --data-dir must not be empty'
|
|
36
|
+
exit 4
|
|
37
|
+
end
|
|
38
|
+
expanded = File.expand_path(options[:data_dir])
|
|
39
|
+
# R2 P3 (Codex/4.7): reject a path that points to an existing regular
|
|
40
|
+
# file (or any non-directory). Either the directory exists, or its
|
|
41
|
+
# parent exists (so the daemon can create it), and in no case may the
|
|
42
|
+
# path itself be a non-directory.
|
|
43
|
+
if File.exist?(expanded) && !File.directory?(expanded)
|
|
44
|
+
warn "[kairos-chain-daemon] --data-dir is not a directory: #{expanded}"
|
|
45
|
+
exit 4
|
|
46
|
+
end
|
|
47
|
+
unless File.directory?(expanded) || File.directory?(File.dirname(expanded))
|
|
48
|
+
warn "[kairos-chain-daemon] --data-dir parent does not exist: #{expanded}"
|
|
49
|
+
exit 4
|
|
50
|
+
end
|
|
51
|
+
options[:data_dir] = expanded
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
|
55
|
+
require 'kairos_mcp'
|
|
56
|
+
require 'kairos_mcp/signal_handle'
|
|
57
|
+
require 'kairos_mcp/lifecycle_hook'
|
|
58
|
+
require 'kairos_mcp/tool_registry'
|
|
59
|
+
|
|
60
|
+
KairosMcp.data_dir = options[:data_dir] if options[:data_dir]
|
|
61
|
+
|
|
62
|
+
begin
|
|
63
|
+
registry = KairosMcp::ToolRegistry.new
|
|
64
|
+
rescue KairosMcp::LifecycleHook::Conflict => e
|
|
65
|
+
warn "[kairos-chain-daemon] #{e.message}"
|
|
66
|
+
warn '[kairos-chain-daemon] Disable one of the conflicting SkillSets and retry.'
|
|
67
|
+
exit 3
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
begin
|
|
71
|
+
klass = registry.lifecycle_hook_class(:daemon_main)
|
|
72
|
+
rescue KairosMcp::LifecycleHook::UnknownClass => e
|
|
73
|
+
warn "[kairos-chain-daemon] lifecycle hook lookup failed: #{e.message}"
|
|
74
|
+
exit 3
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
unless klass
|
|
78
|
+
warn '[kairos-chain-daemon] 24/7 daemon runtime SkillSet not installed.'
|
|
79
|
+
warn '[kairos-chain-daemon] Install a SkillSet declaring lifecycle_hooks.daemon_main ' \
|
|
80
|
+
'(e.g. daemon_runtime).'
|
|
81
|
+
exit 2
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# R9→R10 (Codex P1 / 4.6 P2): use the shared helper so bin/ gets the
|
|
85
|
+
# pathological-return guard. Narrow rescue around ONLY the instantiation
|
|
86
|
+
# (not the class lookup). Distinct exit codes:
|
|
87
|
+
# 3 = lookup failure
|
|
88
|
+
# 9 = instantiation failure (R9 P3 4.7 distinction ask)
|
|
89
|
+
begin
|
|
90
|
+
runtime = registry.instantiate_lifecycle_hook(klass)
|
|
91
|
+
rescue KairosMcp::LifecycleHook::InstanceViolation => e
|
|
92
|
+
# R10→R11 (Codex P1): distinct exception class for wrong-type return.
|
|
93
|
+
warn "[kairos-chain-daemon] lifecycle hook produced wrong type: #{e.message}"
|
|
94
|
+
exit 9
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
warn "[kairos-chain-daemon] lifecycle hook instantiation failed: " \
|
|
97
|
+
"#{e.class}: #{e.message}"
|
|
98
|
+
exit 9
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
signal = KairosMcp::SignalHandle.new
|
|
102
|
+
# R1 P1 (3-voice): trap HUP and USR2 so the reload/diagnostic paths in
|
|
103
|
+
# SignalCoordinator are actually reachable from the real process.
|
|
104
|
+
{ 'TERM' => :request_shutdown,
|
|
105
|
+
'INT' => :request_shutdown,
|
|
106
|
+
'HUP' => :request_reload,
|
|
107
|
+
'USR2' => :request_diagnostic }.each do |sig, m|
|
|
108
|
+
Signal.trap(sig) { signal.public_send(m) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
runtime.run_main_loop(registry: registry, signal: signal)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KairosMcp
|
|
4
|
+
# LifecycleHook protocol (24/7 v0.4 §2.3).
|
|
5
|
+
#
|
|
6
|
+
# A SkillSet advertises one or more lifecycle hooks in its skillset.json:
|
|
7
|
+
#
|
|
8
|
+
# {
|
|
9
|
+
# "name": "daemon_runtime",
|
|
10
|
+
# "lifecycle_hooks": { "daemon_main": "KairosMcp::SkillSets::DaemonRuntime::MainLoop" }
|
|
11
|
+
# }
|
|
12
|
+
#
|
|
13
|
+
# Only one SkillSet may claim a given hook name. Conflicts raise at load
|
|
14
|
+
# time (Conflict) — silent override would violate the audit guarantees of
|
|
15
|
+
# the Bootstrap layer.
|
|
16
|
+
module LifecycleHook
|
|
17
|
+
class Conflict < StandardError; end
|
|
18
|
+
class NotImplementedHook < StandardError; end
|
|
19
|
+
# Lookup-phase failures (class not defined, not a Class, does not
|
|
20
|
+
# include LifecycleHook when resolved from the constant).
|
|
21
|
+
class UnknownClass < StandardError; end
|
|
22
|
+
class ForbiddenNamespace < StandardError; end
|
|
23
|
+
# R10→R11 (Codex P1): distinct exception for instantiation-phase
|
|
24
|
+
# contract violations — specifically, a `.new` override that returns
|
|
25
|
+
# an object not including LifecycleHook. Distinguishes programmatic
|
|
26
|
+
# callers from lookup failures and lets bin/ map to a different
|
|
27
|
+
# exit code without relying on call-site context.
|
|
28
|
+
class InstanceViolation < StandardError; end
|
|
29
|
+
|
|
30
|
+
# Allowlist for class-name resolution (R1 P1, 2-voice security):
|
|
31
|
+
# skillset.json is untrusted input, so `Object.const_get` must not
|
|
32
|
+
# instantiate arbitrary classes. Only classes under these namespaces
|
|
33
|
+
# may be bound to a lifecycle hook.
|
|
34
|
+
ALLOWED_NAMESPACES = [
|
|
35
|
+
'KairosMcp::SkillSets::'
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
# Valid Ruby constant path: Foo::Bar::Baz (no leading colons).
|
|
39
|
+
CLASS_NAME_RE = /\A[A-Z][A-Za-z0-9_]*(::[A-Z][A-Za-z0-9_]*)*\z/
|
|
40
|
+
|
|
41
|
+
def self.validate_class_name!(class_name)
|
|
42
|
+
name = class_name.to_s
|
|
43
|
+
unless name =~ CLASS_NAME_RE
|
|
44
|
+
raise UnknownClass, "invalid class name: #{class_name.inspect}"
|
|
45
|
+
end
|
|
46
|
+
unless ALLOWED_NAMESPACES.any? { |prefix| name.start_with?(prefix) }
|
|
47
|
+
raise ForbiddenNamespace,
|
|
48
|
+
"class '#{name}' is not under an allowed namespace " \
|
|
49
|
+
"(#{ALLOWED_NAMESPACES.join(', ')})"
|
|
50
|
+
end
|
|
51
|
+
name
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Contract an implementing class must fulfill.
|
|
55
|
+
# Bootstrap calls run_main_loop(registry:, signal:) and expects the
|
|
56
|
+
# callee to block until signal.shutdown_requested? is true, then return.
|
|
57
|
+
def run_main_loop(registry:, signal:)
|
|
58
|
+
raise NotImplementedHook, "#{self.class} must implement run_main_loop"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KairosMcp
|
|
4
|
+
# Async-signal-safe signal flag bundle for the Bootstrap layer
|
|
5
|
+
# (24/7 v0.4 §2.2). Carries three one-way signals (shutdown / reload /
|
|
6
|
+
# diagnostic) across the signal-trap boundary.
|
|
7
|
+
#
|
|
8
|
+
# Async-signal safety:
|
|
9
|
+
# - `shutdown` is a one-way latch — set in trap, never cleared.
|
|
10
|
+
# - `reload` and `diagnostic` are edge-triggered. To avoid losing an
|
|
11
|
+
# edge when a signal arrives mid-consume, they are tracked with a
|
|
12
|
+
# ticket counter pattern (R2 Codex P1):
|
|
13
|
+
# • trap handler: `@reload_seen += 1` (increment-only)
|
|
14
|
+
# • consumer: diff = seen − consumed; consumed += diff
|
|
15
|
+
# A signal firing between the consumer's read of `seen` and its
|
|
16
|
+
# write to `consumed` is safely picked up on the next call because
|
|
17
|
+
# `consumed += diff` accumulates rather than overwriting.
|
|
18
|
+
# - MRI serializes trap handlers and runs them at safepoints — no
|
|
19
|
+
# two handlers execute concurrently, and `@x += 1` in a trap is
|
|
20
|
+
# atomic with respect to the main thread (the main thread is
|
|
21
|
+
# paused while the trap runs). On JRuby/TruffleRuby, additional
|
|
22
|
+
# memory fences may be needed; this class targets MRI only for the
|
|
23
|
+
# Bootstrap layer. Richer SkillSet coordinators (see
|
|
24
|
+
# daemon_runtime §2.7 SignalCoordinator) use pipes + ConditionVariable
|
|
25
|
+
# and are runtime-portable.
|
|
26
|
+
#
|
|
27
|
+
# Single-consumer invariant (R3 P3, 4.6):
|
|
28
|
+
# - `consume_reload!` and `consume_diagnostic!` must be called from
|
|
29
|
+
# EXACTLY ONE thread. Two concurrent consumers can both read
|
|
30
|
+
# `@reload_seen` and `@reload_consumed`, then each accumulate the
|
|
31
|
+
# same diff, double-consuming an edge or mis-ordering the `+=`. The
|
|
32
|
+
# Bootstrap layer honors this by having only MainLoop#forward_flags
|
|
33
|
+
# (a dedicated bridge thread) call these. Writers (trap handlers)
|
|
34
|
+
# may be concurrent with the consumer; that case is covered by the
|
|
35
|
+
# ticket accumulation semantics above.
|
|
36
|
+
# - `shutdown_requested` is a one-way latch: trap sets true, nobody
|
|
37
|
+
# ever clears it. This asymmetry is deliberate — shutdown does not
|
|
38
|
+
# need edge semantics because it is terminal.
|
|
39
|
+
class SignalHandle
|
|
40
|
+
def initialize
|
|
41
|
+
@shutdown_requested = false
|
|
42
|
+
@reload_seen = 0
|
|
43
|
+
@reload_consumed = 0
|
|
44
|
+
@diagnostic_seen = 0
|
|
45
|
+
@diagnostic_consumed = 0
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# --- setters (called from Signal.trap context) ---
|
|
49
|
+
def request_shutdown; @shutdown_requested = true; end
|
|
50
|
+
def request_reload; @reload_seen += 1; end
|
|
51
|
+
def request_diagnostic; @diagnostic_seen += 1; end
|
|
52
|
+
|
|
53
|
+
# --- readers (non-consuming) ---
|
|
54
|
+
def shutdown_requested?; @shutdown_requested; end
|
|
55
|
+
def reload_requested?; @reload_seen > @reload_consumed; end
|
|
56
|
+
def diagnostic_requested?; @diagnostic_seen > @diagnostic_consumed; end
|
|
57
|
+
|
|
58
|
+
# Edge-safe consume: returns true if at least one signal arrived
|
|
59
|
+
# since the last consume. Accumulates rather than overwrites, so a
|
|
60
|
+
# trap firing mid-consume is never lost.
|
|
61
|
+
def consume_reload!
|
|
62
|
+
seen = @reload_seen
|
|
63
|
+
diff = seen - @reload_consumed
|
|
64
|
+
@reload_consumed += diff
|
|
65
|
+
diff.positive?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def consume_diagnostic!
|
|
69
|
+
seen = @diagnostic_seen
|
|
70
|
+
diff = seen - @diagnostic_consumed
|
|
71
|
+
@diagnostic_consumed += diff
|
|
72
|
+
diff.positive?
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/kairos_mcp/skillset.rb
CHANGED
|
@@ -70,6 +70,19 @@ module KairosMcp
|
|
|
70
70
|
@metadata['tool_classes'] || []
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
+
# 24/7 v0.4 §2.3 — LifecycleHook declarations.
|
|
74
|
+
# Returns a Hash of { hook_name(String) => class_name(String) }.
|
|
75
|
+
# Non-Hash values and entries with non-string values are coerced away
|
|
76
|
+
# so downstream registration never encounters malformed input.
|
|
77
|
+
def lifecycle_hooks
|
|
78
|
+
raw = @metadata['lifecycle_hooks'] || {}
|
|
79
|
+
return {} unless raw.is_a?(Hash)
|
|
80
|
+
raw.each_with_object({}) do |(k, v), acc|
|
|
81
|
+
next unless k.is_a?(String) && v.is_a?(String) && !k.empty? && !v.empty?
|
|
82
|
+
acc[k] = v
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
73
86
|
def config_files
|
|
74
87
|
@metadata['config_files'] || []
|
|
75
88
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require_relative 'safety'
|
|
2
2
|
require_relative 'tools/base_tool'
|
|
3
3
|
require_relative 'skills_config'
|
|
4
|
+
require_relative 'lifecycle_hook'
|
|
4
5
|
|
|
5
6
|
module KairosMcp
|
|
6
7
|
class ToolRegistry
|
|
@@ -50,9 +51,135 @@ module KairosMcp
|
|
|
50
51
|
@safety = Safety.new
|
|
51
52
|
@safety.set_user(user_context) if user_context
|
|
52
53
|
@tools = {}
|
|
54
|
+
@lifecycle_hooks = {} # { hook_name(Symbol) => { skillset:, class_name: } }
|
|
53
55
|
register_tools
|
|
54
56
|
end
|
|
55
57
|
|
|
58
|
+
# 24/7 v0.4 §2.3 — LifecycleHook registry.
|
|
59
|
+
#
|
|
60
|
+
# Register a hook declaration from a SkillSet. Conflicts (same hook
|
|
61
|
+
# name claimed by two SkillSets) raise LifecycleHook::Conflict — the
|
|
62
|
+
# Bootstrap layer refuses to silently pick a winner.
|
|
63
|
+
def register_lifecycle_hook(hook_name, class_name, skillset_name:)
|
|
64
|
+
key = hook_name.to_sym
|
|
65
|
+
# R1 P1 (2-voice security): validate class name + enforce namespace
|
|
66
|
+
# allowlist before trusting any skillset-sourced class identifier.
|
|
67
|
+
validated = LifecycleHook.validate_class_name!(class_name)
|
|
68
|
+
|
|
69
|
+
existing = @lifecycle_hooks[key]
|
|
70
|
+
if existing && existing[:skillset] != skillset_name
|
|
71
|
+
raise LifecycleHook::Conflict,
|
|
72
|
+
"LifecycleHook '#{hook_name}' claimed by both " \
|
|
73
|
+
"'#{existing[:skillset]}' and '#{skillset_name}'"
|
|
74
|
+
end
|
|
75
|
+
# R1 P2 (3-voice): same-skillset re-registration must not silently
|
|
76
|
+
# overwrite with a DIFFERENT class. Same-class re-registration is a
|
|
77
|
+
# harmless idempotent load (tests, reload).
|
|
78
|
+
if existing && existing[:class_name] != validated
|
|
79
|
+
raise LifecycleHook::Conflict,
|
|
80
|
+
"LifecycleHook '#{hook_name}' re-registered by " \
|
|
81
|
+
"'#{skillset_name}' with different class " \
|
|
82
|
+
"('#{existing[:class_name]}' → '#{validated}')"
|
|
83
|
+
end
|
|
84
|
+
@lifecycle_hooks[key] = { skillset: skillset_name, class_name: validated }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Resolve a registered hook to its Class (without instantiating).
|
|
88
|
+
# Returns nil if no SkillSet declared the hook. Raises
|
|
89
|
+
# `LifecycleHook::UnknownClass` if the registered class name cannot
|
|
90
|
+
# be constantized or does not include `LifecycleHook`.
|
|
91
|
+
#
|
|
92
|
+
# R8→R9 (3-voice: Codex P1 / 4.6 P2 / 4.7 P2): split class-resolution
|
|
93
|
+
# from instantiation so bin/ can `.new` under a precise rescue. The
|
|
94
|
+
# broad `rescue StandardError` in the entrypoint otherwise mislabels
|
|
95
|
+
# any registry-logic bug as an instantiation failure.
|
|
96
|
+
def lifecycle_hook_class(hook_name)
|
|
97
|
+
entry = @lifecycle_hooks[hook_name.to_sym]
|
|
98
|
+
return nil unless entry
|
|
99
|
+
begin
|
|
100
|
+
klass = Object.const_get(entry[:class_name])
|
|
101
|
+
rescue NameError => e
|
|
102
|
+
raise LifecycleHook::UnknownClass,
|
|
103
|
+
"lifecycle hook class '#{entry[:class_name]}' is not defined " \
|
|
104
|
+
"(declared by '#{entry[:skillset]}'): #{e.message}"
|
|
105
|
+
end
|
|
106
|
+
unless klass.is_a?(Class) && klass.include?(KairosMcp::LifecycleHook)
|
|
107
|
+
raise LifecycleHook::UnknownClass,
|
|
108
|
+
"class '#{entry[:class_name]}' does not include KairosMcp::LifecycleHook"
|
|
109
|
+
end
|
|
110
|
+
klass
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Instantiate a pre-resolved lifecycle hook class and verify the
|
|
114
|
+
# resulting instance actually includes LifecycleHook (guards against
|
|
115
|
+
# pathological `.new` overrides that return unrelated objects).
|
|
116
|
+
#
|
|
117
|
+
# Raises `LifecycleHook::InstanceViolation` if `.new` returns the
|
|
118
|
+
# wrong type (a distinct contract violation, separate from lookup
|
|
119
|
+
# failures that raise UnknownClass). Any other error from `.new`
|
|
120
|
+
# propagates unchanged so the caller's rescue stays precise.
|
|
121
|
+
#
|
|
122
|
+
# R9→R10 (Codex P1 / 4.6 P2): shared helper so both `find_lifecycle_hook`
|
|
123
|
+
# and bin/ get the same pathological-return guard.
|
|
124
|
+
# R10→R11 (Codex P1 / 4.6 P3 / 4.7 P3): distinct exception class for
|
|
125
|
+
# wrong-type returns — UnknownClass is semantically wrong here (the
|
|
126
|
+
# class IS known; it violates the contract at instantiation).
|
|
127
|
+
# R12→R13: `.new` return values may be arbitrary — including
|
|
128
|
+
# `BasicObject` descendants that don't respond to `.class`, `.is_a?`,
|
|
129
|
+
# or `.inspect`. Use `Module#===` for the type check (works on
|
|
130
|
+
# BasicObject) and `Object.instance_method(:…).bind_call(obj)` with
|
|
131
|
+
# rescue-everything fallbacks for error-message formatting.
|
|
132
|
+
KERNEL_CLASS = Object.instance_method(:class).freeze
|
|
133
|
+
KERNEL_INSPECT = Object.instance_method(:inspect).freeze
|
|
134
|
+
private_constant :KERNEL_CLASS, :KERNEL_INSPECT
|
|
135
|
+
|
|
136
|
+
def instantiate_lifecycle_hook(klass)
|
|
137
|
+
instance = klass.new
|
|
138
|
+
# `KairosMcp::LifecycleHook === instance` uses Module#=== — does
|
|
139
|
+
# not call any method on `instance`, so it works on BasicObject.
|
|
140
|
+
unless KairosMcp::LifecycleHook === instance
|
|
141
|
+
class_label =
|
|
142
|
+
(klass.name && !klass.name.empty?) ? klass.name : klass.inspect
|
|
143
|
+
raise LifecycleHook::InstanceViolation,
|
|
144
|
+
"#{class_label}.new returned #{safe_inspect(instance)} " \
|
|
145
|
+
"(#{safe_class_name(instance)}) which does not include " \
|
|
146
|
+
'KairosMcp::LifecycleHook'
|
|
147
|
+
end
|
|
148
|
+
instance
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Safely render the class of an arbitrary object — including
|
|
152
|
+
# BasicObject descendants — without calling methods that may be
|
|
153
|
+
# missing on the object.
|
|
154
|
+
def safe_class_name(obj)
|
|
155
|
+
KERNEL_CLASS.bind_call(obj).to_s
|
|
156
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
157
|
+
'<class unavailable>'
|
|
158
|
+
end
|
|
159
|
+
private :safe_class_name
|
|
160
|
+
|
|
161
|
+
# Safe .inspect — tolerates pathological objects whose inspect or
|
|
162
|
+
# class is undefined/raises.
|
|
163
|
+
def safe_inspect(obj)
|
|
164
|
+
KERNEL_INSPECT.bind_call(obj)
|
|
165
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
166
|
+
"<#{safe_class_name(obj)} (inspect raised)>"
|
|
167
|
+
end
|
|
168
|
+
private :safe_inspect
|
|
169
|
+
|
|
170
|
+
# Convenience: resolve the class and instantiate. Retained for tests
|
|
171
|
+
# and callers that do not need to distinguish lookup failures from
|
|
172
|
+
# constructor failures.
|
|
173
|
+
def find_lifecycle_hook(hook_name)
|
|
174
|
+
klass = lifecycle_hook_class(hook_name)
|
|
175
|
+
return nil unless klass
|
|
176
|
+
instantiate_lifecycle_hook(klass)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def lifecycle_hook_names
|
|
180
|
+
@lifecycle_hooks.keys
|
|
181
|
+
end
|
|
182
|
+
|
|
56
183
|
def register_tools
|
|
57
184
|
# Load all tool files
|
|
58
185
|
Dir[File.join(__dir__, 'tools', '*.rb')].each do |file|
|
|
@@ -145,7 +272,13 @@ module KairosMcp
|
|
|
145
272
|
skillset.tool_class_names.each do |cls|
|
|
146
273
|
register_if_defined(cls)
|
|
147
274
|
end
|
|
275
|
+
# 24/7 v0.4 §2.3 — register lifecycle hooks declared by this SkillSet.
|
|
276
|
+
skillset.lifecycle_hooks.each do |hook_name, class_name|
|
|
277
|
+
register_lifecycle_hook(hook_name, class_name, skillset_name: skillset.name)
|
|
278
|
+
end
|
|
148
279
|
end
|
|
280
|
+
rescue LifecycleHook::Conflict
|
|
281
|
+
raise # never swallow — Bootstrap integrity depends on detection
|
|
149
282
|
rescue StandardError => e
|
|
150
283
|
warn "[ToolRegistry] Failed to load SkillSet tools: #{e.message}"
|
|
151
284
|
end
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -249,6 +249,97 @@ agent --list-models 2>&1 | grep "(current\|default)"
|
|
|
249
249
|
# Claude Code: known from session
|
|
250
250
|
```
|
|
251
251
|
|
|
252
|
+
### Orchestrator Self-Identification (Self-Referential Model Reporting)
|
|
253
|
+
|
|
254
|
+
**Rule**: When invoking `multi_llm_review` (or running this workflow manually), the
|
|
255
|
+
orchestrating LLM MUST pass its own model identifier as `orchestrator_model`.
|
|
256
|
+
|
|
257
|
+
**Rationale**: The reviewer roster typically contains both Opus 4.6 and Opus 4.7
|
|
258
|
+
entries. To avoid the orchestrator reviewing its own output (no independent signal),
|
|
259
|
+
the dispatcher excludes any roster entry whose `model` matches `orchestrator_model`.
|
|
260
|
+
This keeps the same SkillSet useful regardless of which Opus version the user has
|
|
261
|
+
toggled to via `/model` — review composition adapts automatically.
|
|
262
|
+
|
|
263
|
+
**Why "argument-passing" not "file-introspection"**:
|
|
264
|
+
- The orchestrator's model identity lives in *its own context* (system prompt
|
|
265
|
+
declares e.g. "You are powered by Opus 4.7"). No external file or env var is
|
|
266
|
+
authoritative — `/model` switches change context immediately.
|
|
267
|
+
- MCP protocol does not transmit caller-model info; only the orchestrator can
|
|
268
|
+
truthfully report its own identity. This is genuine self-reference: the system
|
|
269
|
+
reports its own state to itself.
|
|
270
|
+
- Reading `~/.claude/projects/<cwd>/<sessionId>.jsonl` works but depends on
|
|
271
|
+
Claude Code internals (format may change between versions). Argument-passing
|
|
272
|
+
has zero internal-format dependency.
|
|
273
|
+
|
|
274
|
+
**How orchestrator obtains its model ID**:
|
|
275
|
+
- Claude Code sessions: read the system prompt line "You are powered by the
|
|
276
|
+
model named ... The exact model ID is `claude-opus-X-Y`". Use the exact ID.
|
|
277
|
+
- Other hosts: use whatever introspection the host provides; if none, pass
|
|
278
|
+
`null` and accept that no exclusion happens.
|
|
279
|
+
|
|
280
|
+
**Tool invocation example**:
|
|
281
|
+
```
|
|
282
|
+
multi_llm_review(
|
|
283
|
+
artifact_path: "log/design.md",
|
|
284
|
+
review_type: "design",
|
|
285
|
+
orchestrator_model: "claude-opus-4-7" # MUST be set by caller
|
|
286
|
+
)
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
**Dispatcher behavior** (config: `exclude_orchestrator_model: true`, default `true`):
|
|
290
|
+
- If `orchestrator_model` matches a roster entry's `model`, that entry is skipped.
|
|
291
|
+
- `min_quorum` and `convergence_rule` apply to the remaining reviewers.
|
|
292
|
+
- 4-reviewer roster → 3 reviewer; recommended `convergence_rule: "2/3 APPROVE"`
|
|
293
|
+
when one Opus is excluded.
|
|
294
|
+
- If `orchestrator_model` is `null` or unmatched, full roster runs (back-compat).
|
|
295
|
+
|
|
296
|
+
**Manual-mode equivalent**: When orchestrating by hand, do not assign yourself
|
|
297
|
+
as a reviewer. Pick the *other* Opus version for the Claude CLI subprocess
|
|
298
|
+
reviewer (4.6 if you are 4.7, and vice versa).
|
|
299
|
+
|
|
300
|
+
### Orchestrator Delegation Protocol (Two-Phase, opt-in)
|
|
301
|
+
|
|
302
|
+
The `delegate` strategy lets the orchestrator perform persona-based "Agent Team"
|
|
303
|
+
review in its own context — preserving inherited project context that a fresh
|
|
304
|
+
`claude -p` subprocess loses. Subprocess reviewers (codex, cursor, opposite-Opus)
|
|
305
|
+
remain single-LLM.
|
|
306
|
+
|
|
307
|
+
**Why**: The orchestrator already holds the artifact in context with full project
|
|
308
|
+
awareness. Re-shipping it to a sandboxed subprocess discards that context. Same-
|
|
309
|
+
model persona switching gives stylistic / framing diversity (validated empirically);
|
|
310
|
+
cross-model subprocess reviewers give epistemic diversity. The two are complementary.
|
|
311
|
+
|
|
312
|
+
**Call 1**: `multi_llm_review(orchestrator_strategy: "delegate", orchestrator_model: "...")`
|
|
313
|
+
- SkillSet drops the orchestrator-matching reviewer from dispatch
|
|
314
|
+
- Subprocess reviewers run synchronously (no background threads)
|
|
315
|
+
- Subprocess results persisted to `.kairos/multi_llm_review/pending/<uuid>.json`
|
|
316
|
+
- Returns `status: "delegation_pending"`, `collect_token`, `persona_count_min/max`
|
|
317
|
+
|
|
318
|
+
**Orchestrator's obligation** (between calls):
|
|
319
|
+
- Recognize the `delegation_pending` status
|
|
320
|
+
- Spawn 2-4 parallel `Agent` tool calls with self-chosen personas appropriate to
|
|
321
|
+
the artifact (e.g. design → architect/security/operability; code → correctness/
|
|
322
|
+
performance/api-design; doc → ontologist/skeptic/integration)
|
|
323
|
+
- Collect persona results: each as `{persona, verdict (APPROVE|REVISE|REJECT),
|
|
324
|
+
reasoning, findings: [{severity, issue}, ...]}`
|
|
325
|
+
|
|
326
|
+
**Call 2**: `multi_llm_review_collect(collect_token, orchestrator_reviews: [...])`
|
|
327
|
+
- Persona Assembly: any REJECT → REJECT; else any REVISE → REVISE; else APPROVE
|
|
328
|
+
- Assembled into one synthetic reviewer entry `claude_team_<orchestrator_model>`
|
|
329
|
+
- Combined with persisted subprocess results, run Consensus, return final verdict
|
|
330
|
+
- Idempotent: repeated calls with the same token return the cached result
|
|
331
|
+
|
|
332
|
+
**Failure modes**:
|
|
333
|
+
- `expired_or_unknown_token`: orchestrator missed `must_collect_by` deadline
|
|
334
|
+
(default 600s), or token never existed. The pending review is gone; call
|
|
335
|
+
`multi_llm_review` again from scratch.
|
|
336
|
+
- `error: invalid orchestrator_reviews`: persona count outside 2-4 or missing
|
|
337
|
+
required fields. Fix and retry collect with the same token.
|
|
338
|
+
- All-subprocess-failed at Call 1: returns error immediately; no token issued.
|
|
339
|
+
|
|
340
|
+
**Default**: `orchestrator_strategy` defaults to `"exclude"` (back-compat). Use
|
|
341
|
+
`"delegate"` explicitly until validated by use.
|
|
342
|
+
|
|
252
343
|
### Critical CLI Notes
|
|
253
344
|
|
|
254
345
|
- **Cursor Agent stdin**: `cat file | agent -p -` does NOT work. Use file-reference:
|