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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/bin/kairos-chain-daemon +111 -0
  3. data/lib/kairos_mcp/lifecycle_hook.rb +61 -0
  4. data/lib/kairos_mcp/signal_handle.rb +75 -0
  5. data/lib/kairos_mcp/skillset.rb +13 -0
  6. data/lib/kairos_mcp/tool_registry.rb +133 -0
  7. data/lib/kairos_mcp/version.rb +1 -1
  8. data/templates/knowledge/multi_llm_review_workflow/multi_llm_review_workflow.md +91 -0
  9. data/templates/skills/kairos_tutorial.md +70 -21
  10. data/templates/skillsets/agent/config/agent.yml +15 -2
  11. data/templates/skillsets/agent/lib/agent/review_hint.rb +84 -0
  12. data/templates/skillsets/agent/lib/agent/trigger_validator.rb +69 -0
  13. data/templates/skillsets/agent/lib/agent.rb +2 -0
  14. data/templates/skillsets/agent/test/test_agent_complexity_review.rb +205 -4
  15. data/templates/skillsets/agent/test/test_decide_prompt_contract.rb +91 -0
  16. data/templates/skillsets/agent/test/test_review_hint.rb +168 -0
  17. data/templates/skillsets/agent/tools/agent_start.rb +14 -0
  18. data/templates/skillsets/agent/tools/agent_step.rb +319 -20
  19. data/templates/skillsets/daemon_runtime/lib/daemon_runtime/attach_auth.rb +196 -0
  20. data/templates/skillsets/daemon_runtime/lib/daemon_runtime/main_loop.rb +87 -0
  21. data/templates/skillsets/daemon_runtime/lib/daemon_runtime/main_loop_supervisor.rb +84 -0
  22. data/templates/skillsets/daemon_runtime/lib/daemon_runtime/signal_coordinator.rb +176 -0
  23. data/templates/skillsets/daemon_runtime/lib/daemon_runtime.rb +16 -0
  24. data/templates/skillsets/daemon_runtime/skillset.json +16 -0
  25. data/templates/skillsets/daemon_runtime/test/test_attach_auth.rb +254 -0
  26. data/templates/skillsets/daemon_runtime/test/test_daemon_runtime.rb +250 -0
  27. data/templates/skillsets/llm_client/config/llm_client.yml +1 -1
  28. data/templates/skillsets/llm_client/lib/llm_client/call_router.rb +187 -0
  29. data/templates/skillsets/llm_client/lib/llm_client/codex_adapter.rb +9 -0
  30. data/templates/skillsets/llm_client/lib/llm_client/cursor_adapter.rb +20 -2
  31. data/templates/skillsets/llm_client/lib/llm_client/headless.rb +85 -0
  32. data/templates/skillsets/llm_client/test/test_call_router.rb +316 -0
  33. data/templates/skillsets/llm_client/test/test_cursor_adapter_model.rb +111 -0
  34. data/templates/skillsets/llm_client/test/test_cursor_adapter_retryable.rb +70 -0
  35. data/templates/skillsets/multi_llm_review/bin/dispatch_worker.rb +309 -0
  36. data/templates/skillsets/multi_llm_review/config/multi_llm_review.yml +91 -2
  37. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/build_review_bundle.rb +170 -0
  38. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/consensus.rb +19 -1
  39. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/dispatcher.rb +30 -3
  40. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/feedback_formatter.rb +58 -0
  41. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/main_state.rb +64 -0
  42. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/pending_state.rb +429 -0
  43. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/persona_assembly.rb +184 -0
  44. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/pin_resolver.rb +146 -0
  45. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/sanitizer.rb +184 -0
  46. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/wait_for_worker.rb +143 -0
  47. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/worker_reaper.rb +96 -0
  48. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/worker_spawner.rb +67 -0
  49. data/templates/skillsets/multi_llm_review/skillset.json +7 -4
  50. data/templates/skillsets/multi_llm_review/test/test_dispatcher_usage.rb +36 -0
  51. data/templates/skillsets/multi_llm_review/test/test_feedback_formatter.rb +97 -0
  52. data/templates/skillsets/multi_llm_review/test/test_multi_llm_review.rb +885 -0
  53. data/templates/skillsets/multi_llm_review/test/test_multi_llm_review_bundle.rb +167 -0
  54. data/templates/skillsets/multi_llm_review/test/test_pending_state_v3.rb +409 -0
  55. data/templates/skillsets/multi_llm_review/test/test_pin_resolver.rb +173 -0
  56. data/templates/skillsets/multi_llm_review/test/test_sanitizer.rb +213 -0
  57. data/templates/skillsets/multi_llm_review/test/test_worker_spawner.rb +145 -0
  58. data/templates/skillsets/multi_llm_review/tools/multi_llm_review.rb +370 -13
  59. data/templates/skillsets/multi_llm_review/tools/multi_llm_review_bundle.rb +139 -0
  60. data/templates/skillsets/multi_llm_review/tools/multi_llm_review_collect.rb +381 -0
  61. metadata +42 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d2b6b702508caf49a43e1fdc09beb4b6d5a7dd478a89492d07503643744855c
4
- data.tar.gz: faaceed727c7de49424391d32768bff4790411a414cd4b2a671fcd31501aadf5
3
+ metadata.gz: 35124b7f595066816a5e59823ca5f871bcb2c009f12db8127f7570ee0e923206
4
+ data.tar.gz: 482cef573f07539663a119db81cbdcb5b600362a551b3ee45459d9727c78c755
5
5
  SHA512:
6
- metadata.gz: 727829e5b3672c2b694336baabb14af1a0906ee3b7c196bb320ea0060db98bc163ab36a7971f405eb88e0c1d0393dcb370fed337bb6220e0e8dd1716885e7166
7
- data.tar.gz: 12be3d20add101e4ac13871b4c36dce043e104fcbe67ab1af95ad55793b018b2cc0e785324d34a4f8887a2969c7d6b5db9da61a9f777496d560296a8d94f14cc
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
@@ -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
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.17.0"
2
+ VERSION = "3.23.1"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -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: