kairos-chain 3.16.0 → 3.19.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/CHANGELOG.md +42 -0
- data/README.md +1 -0
- data/bin/kairos-chain-daemon +111 -0
- data/lib/kairos_mcp/daemon/active_observe.rb +180 -0
- data/lib/kairos_mcp/daemon/approval_gate.rb +231 -0
- data/lib/kairos_mcp/daemon/attach_server.rb +415 -0
- data/lib/kairos_mcp/daemon/budget.rb +172 -0
- data/lib/kairos_mcp/daemon/canonical.rb +83 -0
- data/lib/kairos_mcp/daemon/chronos.rb +641 -0
- data/lib/kairos_mcp/daemon/code_gen_act.rb +247 -0
- data/lib/kairos_mcp/daemon/code_gen_phase_handler.rb +87 -0
- data/lib/kairos_mcp/daemon/command_mailbox.rb +91 -0
- data/lib/kairos_mcp/daemon/credentials.rb +182 -0
- data/lib/kairos_mcp/daemon/daemon_llm_caller.rb +253 -0
- data/lib/kairos_mcp/daemon/daemon_policy.rb +112 -0
- data/lib/kairos_mcp/daemon/edit_kernel.rb +54 -0
- data/lib/kairos_mcp/daemon/elevation_token.rb +36 -0
- data/lib/kairos_mcp/daemon/execution_context.rb +21 -0
- data/lib/kairos_mcp/daemon/heartbeat.rb +138 -0
- data/lib/kairos_mcp/daemon/idempotency_check.rb +132 -0
- data/lib/kairos_mcp/daemon/idempotent_chain_recorder.rb +140 -0
- data/lib/kairos_mcp/daemon/integration.rb +293 -0
- data/lib/kairos_mcp/daemon/llm_phase_functions.rb +184 -0
- data/lib/kairos_mcp/daemon/mandate_factory.rb +123 -0
- data/lib/kairos_mcp/daemon/ooda_cycle_runner.rb +275 -0
- data/lib/kairos_mcp/daemon/pdf_build.rb +96 -0
- data/lib/kairos_mcp/daemon/pid_lock.rb +103 -0
- data/lib/kairos_mcp/daemon/planner.rb +100 -0
- data/lib/kairos_mcp/daemon/policy_elevation.rb +74 -0
- data/lib/kairos_mcp/daemon/proposal_routes.rb +135 -0
- data/lib/kairos_mcp/daemon/restricted_shell/argv_validators.rb +114 -0
- data/lib/kairos_mcp/daemon/restricted_shell/binary_resolver.rb +70 -0
- data/lib/kairos_mcp/daemon/restricted_shell/errors.rb +30 -0
- data/lib/kairos_mcp/daemon/restricted_shell/runner.rb +153 -0
- data/lib/kairos_mcp/daemon/restricted_shell/sandbox_context.rb +26 -0
- data/lib/kairos_mcp/daemon/restricted_shell/sandbox_factory.rb +89 -0
- data/lib/kairos_mcp/daemon/restricted_shell.rb +129 -0
- data/lib/kairos_mcp/daemon/scope_classifier.rb +44 -0
- data/lib/kairos_mcp/daemon/signal_handler.rb +80 -0
- data/lib/kairos_mcp/daemon/task_dag.rb +322 -0
- data/lib/kairos_mcp/daemon/wal.rb +391 -0
- data/lib/kairos_mcp/daemon/wal_phase_recorder.rb +90 -0
- data/lib/kairos_mcp/daemon/wal_recovery.rb +86 -0
- data/lib/kairos_mcp/daemon.rb +363 -0
- data/lib/kairos_mcp/invocation_context.rb +44 -6
- data/lib/kairos_mcp/lifecycle_hook.rb +61 -0
- data/lib/kairos_mcp/logger.rb +164 -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/llm_cross_evaluation/assets/prompts/cross_evaluation.md.erb +35 -0
- data/templates/knowledge/llm_cross_evaluation/assets/prompts/cross_evaluation_philosophy.md.erb +65 -0
- data/templates/knowledge/llm_cross_evaluation/assets/prompts/incompleteness_report.md.erb +36 -0
- data/templates/knowledge/llm_cross_evaluation/assets/prompts/meta_evaluation.md.erb +37 -0
- data/templates/knowledge/llm_cross_evaluation/assets/prompts/meta_evaluation_philosophy.md.erb +45 -0
- data/templates/knowledge/llm_cross_evaluation/assets/prompts/nomic_postgame.md.erb +36 -0
- data/templates/knowledge/llm_cross_evaluation/assets/prompts/nomic_proposal.md.erb +34 -0
- data/templates/knowledge/llm_cross_evaluation/assets/prompts/nomic_vote.md.erb +32 -0
- data/templates/knowledge/llm_cross_evaluation/assets/prompts/self_calibration.md.erb +42 -0
- data/templates/knowledge/llm_cross_evaluation/assets/prompts/self_calibration_philosophy.md.erb +43 -0
- data/templates/knowledge/llm_cross_evaluation/assets/tasks/code_generation.yaml +20 -0
- data/templates/knowledge/llm_cross_evaluation/assets/tasks/kairoschain_philosophy.yaml +55 -0
- data/templates/knowledge/llm_cross_evaluation/assets/tasks/logic_reasoning.yaml +19 -0
- data/templates/knowledge/llm_cross_evaluation/assets/tasks/scientific_reasoning.yaml +27 -0
- data/templates/knowledge/llm_cross_evaluation/llm_cross_evaluation.md +409 -0
- data/templates/knowledge/llm_cross_evaluation/scripts/run_cross_eval.rb +1752 -0
- data/templates/knowledge/multi_llm_review_workflow/multi_llm_review_workflow.md +141 -16
- data/templates/knowledge/multi_llm_reviewer_evaluation/multi_llm_reviewer_evaluation.md +29 -8
- data/templates/skills/kairos_tutorial.md +70 -21
- data/templates/skillsets/agent/config/agent.yml +39 -0
- data/templates/skillsets/agent/lib/agent/cognitive_loop.rb +189 -20
- data/templates/skillsets/agent/tools/agent_step.rb +183 -5
- 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/external_tools/lib/external_tools/tool_support.rb +42 -0
- data/templates/skillsets/external_tools/lib/external_tools/workspace_confinement.rb +111 -0
- data/templates/skillsets/external_tools/lib/external_tools.rb +6 -0
- data/templates/skillsets/external_tools/skillset.json +24 -0
- data/templates/skillsets/external_tools/test/test_external_tools.rb +521 -0
- data/templates/skillsets/external_tools/test/test_http_tools.rb +451 -0
- data/templates/skillsets/external_tools/tools/safe_file_copy.rb +90 -0
- data/templates/skillsets/external_tools/tools/safe_file_delete.rb +75 -0
- data/templates/skillsets/external_tools/tools/safe_file_edit.rb +106 -0
- data/templates/skillsets/external_tools/tools/safe_file_list.rb +95 -0
- data/templates/skillsets/external_tools/tools/safe_file_read.rb +85 -0
- data/templates/skillsets/external_tools/tools/safe_file_write.rb +110 -0
- data/templates/skillsets/external_tools/tools/safe_git_branch.rb +140 -0
- data/templates/skillsets/external_tools/tools/safe_git_commit.rb +125 -0
- data/templates/skillsets/external_tools/tools/safe_git_push.rb +111 -0
- data/templates/skillsets/external_tools/tools/safe_git_status.rb +81 -0
- data/templates/skillsets/external_tools/tools/safe_http_get.rb +203 -0
- data/templates/skillsets/external_tools/tools/safe_http_post.rb +209 -0
- data/templates/skillsets/llm_client/config/llm_client.yml +4 -4
- data/templates/skillsets/llm_client/lib/llm_client/call_router.rb +187 -0
- data/templates/skillsets/llm_client/lib/llm_client/claude_code_adapter.rb +59 -13
- data/templates/skillsets/llm_client/lib/llm_client/codex_adapter.rb +166 -0
- data/templates/skillsets/llm_client/lib/llm_client/cursor_adapter.rb +158 -0
- data/templates/skillsets/llm_client/lib/llm_client/error_taxonomy.rb +124 -0
- data/templates/skillsets/llm_client/lib/llm_client/headless.rb +85 -0
- data/templates/skillsets/llm_client/lib/llm_client/safe_subprocess.rb +206 -0
- data/templates/skillsets/llm_client/test/test_call_router.rb +316 -0
- data/templates/skillsets/llm_client/test/test_cursor_adapter_retryable.rb +70 -0
- data/templates/skillsets/llm_client/test/test_error_taxonomy.rb +195 -0
- data/templates/skillsets/llm_client/tools/llm_call.rb +94 -17
- data/templates/skillsets/llm_client/tools/llm_configure.rb +11 -3
- data/templates/skillsets/llm_client/tools/llm_status.rb +45 -0
- data/templates/skillsets/mmp/lib/mmp/identity.rb +2 -1
- data/templates/skillsets/multi_llm_review/bin/dispatch_worker.rb +309 -0
- data/templates/skillsets/multi_llm_review/config/multi_llm_review.yml +129 -0
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/consensus.rb +145 -0
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/dispatcher.rb +216 -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/prompt_builder.rb +126 -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 +19 -0
- data/templates/skillsets/multi_llm_review/test/test_dispatcher_usage.rb +36 -0
- data/templates/skillsets/multi_llm_review/test/test_multi_llm_review.rb +1220 -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_worker_spawner.rb +145 -0
- data/templates/skillsets/multi_llm_review/tools/multi_llm_review.rb +606 -0
- data/templates/skillsets/multi_llm_review/tools/multi_llm_review_collect.rb +364 -0
- metadata +118 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ef45a8e58aedc056cd5303abb6463a66503da146fc2bb7abe8ee018569fde8e0
|
|
4
|
+
data.tar.gz: c62bc1328bc6a22e577cb1a8dad2de501bf184dd9022acb207257a4cfaa49955
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9e6236cd2860aa3cf244361962de9660b04aa47e0eed03689bb16dd4e95f3f6e1f9f6fe7377e9c0b5b6be87842346757c8b273f6ac8564d293f5cd2157baa727
|
|
7
|
+
data.tar.gz: e83f1c792045a5141c9914d6ef09d1ac4ecc33ae6664e27316cf9ab82987c8c5555981869ec18fbb0309cefe071f5d62eb748b6cd2d92b1d2ff9835bf5f5fed3
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,48 @@ All notable changes to the `kairos-chain` gem will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
This project follows [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [3.17.0] - 2026-04-22
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Multi-LLM Review SkillSet** — New `multi_llm_review` tool dispatching review
|
|
12
|
+
tasks in parallel to heterogeneous LLMs (Claude Opus 4.6/4.7 via Agent tool
|
|
13
|
+
and CLI, OpenAI Codex GPT-5.4, Cursor Composer-2). Returns consensus verdict
|
|
14
|
+
with aggregated findings. Convergence rule configurable (default `3/4 APPROVE`).
|
|
15
|
+
Complexity auto-detection from `review_type` + artifact size. Sandbox mode
|
|
16
|
+
via `review_context: independent` prevents CLAUDE.md contamination.
|
|
17
|
+
- **LLM adapter extensions** — `codex_adapter` (`codex exec` subprocess) and
|
|
18
|
+
`cursor_adapter` (`agent -p` subprocess) join the existing `claude_code_adapter`.
|
|
19
|
+
All share the `SafeSubprocess` wrapper with timeout, stderr isolation, and
|
|
20
|
+
ANSI stripping. `provider_override` parameter on `llm_call` routes to a
|
|
21
|
+
specific adapter independent of the default config.
|
|
22
|
+
- **Agent SkillSet integration** — Agent OODA loop can invoke `multi_llm_review`
|
|
23
|
+
as an ACT step for design/implementation review cycles.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **Adapter model label** — `codex_adapter` and `cursor_adapter` no longer fall
|
|
28
|
+
back to `llm_client.yml`'s Anthropic default model (`claude-opus-4-6`) when
|
|
29
|
+
the caller omits `model`. They now report `codex-cli-default` /
|
|
30
|
+
`cursor-cli-default` in response JSON, honestly reflecting that the CLI's
|
|
31
|
+
own default model was used. No change to dispatch behavior.
|
|
32
|
+
- **Runtime integration** — CLI auth flow, effort control per provider,
|
|
33
|
+
auto-complexity mapping (`low`/`medium`/`high`/`xhigh`) validated end-to-end.
|
|
34
|
+
- **MMP keypair persistence** — Keypair saved to `.kairos/keys/` instead of
|
|
35
|
+
ephemeral CWD, preventing loss on directory change.
|
|
36
|
+
- **paused_risk skip transition** — Agent state machine correctly handles
|
|
37
|
+
`skip` action from `paused_risk` state.
|
|
38
|
+
- **llm_call AuthError fallback** — Auto-switch to `claude_code` provider
|
|
39
|
+
when Anthropic API auth fails.
|
|
40
|
+
|
|
41
|
+
### Review
|
|
42
|
+
|
|
43
|
+
- Design: 2 rounds × 4 LLMs (Claude Opus 4.6, Claude Opus 4.7, Codex GPT-5.4,
|
|
44
|
+
Cursor Composer-2)
|
|
45
|
+
- Implementation: 2 rounds × 4 LLMs
|
|
46
|
+
- Runtime test: 4-LLM diversity verified on buggy Fibonacci artifact
|
|
47
|
+
(4/4 REJECT with distinct findings per reviewer characteristic)
|
|
48
|
+
|
|
7
49
|
## [3.16.0] - 2026-04-19
|
|
8
50
|
|
|
9
51
|
### Changed
|
data/README.md
CHANGED
|
@@ -20,6 +20,7 @@ A self-referential [Model Context Protocol (MCP)](https://modelcontextprotocol.i
|
|
|
20
20
|
- **Attestation System (Synoptis)** — Cryptographic attestation and trust scoring
|
|
21
21
|
- **Dream Mode** — Speculative knowledge proposals with community review
|
|
22
22
|
- **Claude Code Plugin Projection** — Auto-project SkillSets as Claude Code plugins (hooks, agents, slash commands)
|
|
23
|
+
- **Multi-LLM Review** — Parallel dispatch to heterogeneous LLMs (Claude, Codex, Cursor) via CLI subprocesses; consensus verdict with aggregated findings
|
|
23
24
|
|
|
24
25
|
## Installation
|
|
25
26
|
|
|
@@ -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,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KairosMcp
|
|
4
|
+
class Daemon
|
|
5
|
+
# ActiveObserve — policy-driven OBSERVE phase.
|
|
6
|
+
#
|
|
7
|
+
# Design (v0.2 §2, P3.1):
|
|
8
|
+
# The passive OBSERVE phase inspects mandate state in memory. The
|
|
9
|
+
# active variant additionally invokes a whitelisted set of READ-ONLY
|
|
10
|
+
# tools named in the mandate's `observe_policies`, collects their
|
|
11
|
+
# results, and runs a cheap triage step to highlight what looks
|
|
12
|
+
# relevant. In P3.1 the triage is a keyword filter stub; a future
|
|
13
|
+
# revision will slot in a cheap LLM call with the same interface.
|
|
14
|
+
#
|
|
15
|
+
# Safety:
|
|
16
|
+
# Only tools listed in READ_ONLY_ALLOWLIST (or the caller-supplied
|
|
17
|
+
# allowlist) are ever invoked. A mandate cannot widen its own
|
|
18
|
+
# observation surface — policies must be a subset of the allowlist.
|
|
19
|
+
class ActiveObserve
|
|
20
|
+
# A deliberately conservative default. Additional read-only tools may
|
|
21
|
+
# be allowed by passing an explicit `allowlist:` into #initialize.
|
|
22
|
+
READ_ONLY_ALLOWLIST = %w[
|
|
23
|
+
chain_history
|
|
24
|
+
chain_status
|
|
25
|
+
chain_verify
|
|
26
|
+
knowledge_get
|
|
27
|
+
knowledge_list
|
|
28
|
+
skills_list
|
|
29
|
+
skills_get
|
|
30
|
+
skills_dsl_list
|
|
31
|
+
skills_dsl_get
|
|
32
|
+
resource_list
|
|
33
|
+
resource_read
|
|
34
|
+
introspection_health
|
|
35
|
+
introspection_check
|
|
36
|
+
state_status
|
|
37
|
+
state_history
|
|
38
|
+
document_status
|
|
39
|
+
meeting_browse
|
|
40
|
+
meeting_check_freshness
|
|
41
|
+
skillset_browse
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
44
|
+
def initialize(allowlist: READ_ONLY_ALLOWLIST, keywords: nil, logger: nil)
|
|
45
|
+
@allowlist = allowlist.map(&:to_s).freeze
|
|
46
|
+
@keywords = keywords
|
|
47
|
+
@logger = logger
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Execute the active OBSERVE step.
|
|
51
|
+
#
|
|
52
|
+
# @param mandate_hash [Hash] must expose :observe_policies (or the
|
|
53
|
+
# 'observe_policies' key) as an Array of tool names. May expose
|
|
54
|
+
# :goal_name and :goal for keyword-based triage.
|
|
55
|
+
# @param tool_invoker [#call] a callable accepting
|
|
56
|
+
# (tool_name, args) and returning the tool's native result. The
|
|
57
|
+
# caller supplies this so ActiveObserve itself is I/O-agnostic
|
|
58
|
+
# and trivially testable.
|
|
59
|
+
# @return [Hash] structured observation with :policies_invoked,
|
|
60
|
+
# :policies_skipped, :results, :relevant, :errors.
|
|
61
|
+
def observe(mandate_hash, tool_invoker:)
|
|
62
|
+
raise ArgumentError, 'mandate_hash required' unless mandate_hash.is_a?(Hash)
|
|
63
|
+
raise ArgumentError, 'tool_invoker must respond to call' unless tool_invoker.respond_to?(:call)
|
|
64
|
+
|
|
65
|
+
policies = Array(mandate_hash[:observe_policies] || mandate_hash['observe_policies'])
|
|
66
|
+
# Deduplicate by normalized tool name — first-wins for args if dupes exist.
|
|
67
|
+
policies = policies.uniq { |e| normalize_policy(e).first }
|
|
68
|
+
invoked = []
|
|
69
|
+
skipped = []
|
|
70
|
+
results = {}
|
|
71
|
+
errors = {}
|
|
72
|
+
|
|
73
|
+
policies.each do |entry|
|
|
74
|
+
tool_name, args = normalize_policy(entry)
|
|
75
|
+
unless allowed?(tool_name)
|
|
76
|
+
skipped << tool_name
|
|
77
|
+
log(:warn, :active_observe_skip, tool: tool_name, reason: 'not_in_allowlist')
|
|
78
|
+
next
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
begin
|
|
82
|
+
results[tool_name] = tool_invoker.call(tool_name, args)
|
|
83
|
+
invoked << tool_name
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
errors[tool_name] = "#{e.class}: #{e.message}"
|
|
86
|
+
log(:error, :active_observe_error, tool: tool_name, error: errors[tool_name])
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
relevant = select_relevant(results, mandate_hash)
|
|
91
|
+
|
|
92
|
+
{
|
|
93
|
+
policies_invoked: invoked,
|
|
94
|
+
policies_skipped: skipped,
|
|
95
|
+
results: results,
|
|
96
|
+
relevant: relevant,
|
|
97
|
+
errors: errors
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Triage stub. In P3.1 we score results by simple keyword membership
|
|
102
|
+
# (mandate goal tokens). Returning the full result under a :match
|
|
103
|
+
# entry keeps the interface stable for the eventual LLM-backed
|
|
104
|
+
# implementation, which will add confidence scores without changing
|
|
105
|
+
# the key layout.
|
|
106
|
+
#
|
|
107
|
+
# @return [Hash] tool_name → { match: Boolean, score: Float, matched_keywords: [...] }
|
|
108
|
+
def select_relevant(results, mandate_hash)
|
|
109
|
+
keywords = effective_keywords(mandate_hash)
|
|
110
|
+
return {} if results.empty?
|
|
111
|
+
|
|
112
|
+
results.each_with_object({}) do |(tool, payload), acc|
|
|
113
|
+
matched = match_keywords(payload, keywords)
|
|
114
|
+
acc[tool] = {
|
|
115
|
+
match: !matched.empty? || keywords.empty?,
|
|
116
|
+
score: keywords.empty? ? 1.0 : matched.size.to_f / keywords.size,
|
|
117
|
+
matched_keywords: matched
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------------ helpers
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
# A policy entry is either a String tool-name or a Hash
|
|
127
|
+
# { tool: "...", args: {...} }. Normalizing here means the rest of
|
|
128
|
+
# the class can assume a pair.
|
|
129
|
+
def normalize_policy(entry)
|
|
130
|
+
case entry
|
|
131
|
+
when String
|
|
132
|
+
[entry, {}]
|
|
133
|
+
when Hash
|
|
134
|
+
tool = entry[:tool] || entry['tool'] || entry[:name] || entry['name']
|
|
135
|
+
args = entry[:args] || entry['args'] || {}
|
|
136
|
+
[tool.to_s, args]
|
|
137
|
+
else
|
|
138
|
+
[entry.to_s, {}]
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def allowed?(tool_name)
|
|
143
|
+
@allowlist.include?(tool_name.to_s)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def effective_keywords(mandate_hash)
|
|
147
|
+
return Array(@keywords).map(&:to_s).reject(&:empty?) if @keywords
|
|
148
|
+
|
|
149
|
+
raw = [
|
|
150
|
+
mandate_hash[:goal_name], mandate_hash['goal_name'],
|
|
151
|
+
mandate_hash[:goal], mandate_hash['goal']
|
|
152
|
+
].compact.map(&:to_s).join(' ')
|
|
153
|
+
raw.downcase.scan(/[a-z0-9]{3,}/).uniq
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def match_keywords(payload, keywords)
|
|
157
|
+
return [] if keywords.empty?
|
|
158
|
+
|
|
159
|
+
haystack = payload_to_string(payload).downcase
|
|
160
|
+
keywords.select { |k| haystack.include?(k) }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def payload_to_string(payload)
|
|
164
|
+
case payload
|
|
165
|
+
when String then payload
|
|
166
|
+
when Hash, Array then payload.to_s
|
|
167
|
+
else payload.to_s
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def log(level, event, **fields)
|
|
172
|
+
return unless @logger && @logger.respond_to?(level)
|
|
173
|
+
|
|
174
|
+
@logger.public_send(level, "#{event} #{fields.inspect}")
|
|
175
|
+
rescue StandardError
|
|
176
|
+
# Never let a logger crash mask the original tool error.
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
require 'time'
|
|
7
|
+
require 'digest'
|
|
8
|
+
|
|
9
|
+
module KairosMcp
|
|
10
|
+
class Daemon
|
|
11
|
+
# ApprovalGate — pending-file based approval system for code-gen proposals.
|
|
12
|
+
#
|
|
13
|
+
# Design (P3.2 v0.2 §4):
|
|
14
|
+
# Proposals are staged as JSON files in .kairos/run/proposals/.
|
|
15
|
+
# Human approval/rejection is recorded as a separate .decision.json file.
|
|
16
|
+
# proposal_hash binds reviewed content to applied content cryptographically.
|
|
17
|
+
class ApprovalGate
|
|
18
|
+
DEFAULT_TTL = 28_800 # 8 hours (daemon mode)
|
|
19
|
+
MAX_PENDING = 16
|
|
20
|
+
|
|
21
|
+
def initialize(dir:, clock: -> { Time.now.utc }, logger: nil)
|
|
22
|
+
@dir = dir
|
|
23
|
+
@clock = clock
|
|
24
|
+
@logger = logger
|
|
25
|
+
FileUtils.mkdir_p(@dir, mode: 0o700)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Stage a pending-approval proposal. Returns the stored Hash.
|
|
29
|
+
def stage(proposal)
|
|
30
|
+
id = proposal[:proposal_id] || proposal['proposal_id']
|
|
31
|
+
raise ArgumentError, 'proposal_id required' if id.to_s.empty?
|
|
32
|
+
check_backpressure!
|
|
33
|
+
|
|
34
|
+
now = @clock.call
|
|
35
|
+
ttl = proposal[:ttl_seconds] || proposal['ttl_seconds'] || DEFAULT_TTL
|
|
36
|
+
|
|
37
|
+
# Compute proposal_hash from canonical content (excludes mutable fields)
|
|
38
|
+
canonical = proposal.reject { |k, _| mutable_key?(k) }
|
|
39
|
+
p_hash = canonical_hash(canonical)
|
|
40
|
+
|
|
41
|
+
p = proposal.merge(
|
|
42
|
+
status: 'pending_approval',
|
|
43
|
+
proposal_hash: p_hash,
|
|
44
|
+
created_at: now.iso8601,
|
|
45
|
+
expires_at: (now + ttl).iso8601
|
|
46
|
+
)
|
|
47
|
+
write_atomic(file_for(id), JSON.pretty_generate(stringify_keys(p)))
|
|
48
|
+
p
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Auto-approve (fast path for L2 scopes).
|
|
52
|
+
def auto_approve(proposal)
|
|
53
|
+
id = proposal[:proposal_id] || proposal['proposal_id']
|
|
54
|
+
raise ArgumentError, 'proposal_id required' if id.to_s.empty?
|
|
55
|
+
|
|
56
|
+
now = @clock.call
|
|
57
|
+
ttl = proposal[:ttl_seconds] || proposal['ttl_seconds'] || DEFAULT_TTL
|
|
58
|
+
|
|
59
|
+
canonical = proposal.reject { |k, _| mutable_key?(k) }
|
|
60
|
+
p_hash = canonical_hash(canonical)
|
|
61
|
+
|
|
62
|
+
p = proposal.merge(
|
|
63
|
+
status: 'auto_approved',
|
|
64
|
+
proposal_hash: p_hash,
|
|
65
|
+
created_at: now.iso8601,
|
|
66
|
+
expires_at: (now + ttl).iso8601
|
|
67
|
+
)
|
|
68
|
+
write_atomic(file_for(id), JSON.pretty_generate(stringify_keys(p)))
|
|
69
|
+
write_decision(id,
|
|
70
|
+
decision: 'approve',
|
|
71
|
+
reviewer: 'policy:auto_approve',
|
|
72
|
+
proposal_hash: p_hash,
|
|
73
|
+
granted_at: now.iso8601,
|
|
74
|
+
reason: "scope=#{proposal.dig(:scope, :scope) || proposal.dig('scope', 'scope')} auto-approved")
|
|
75
|
+
p
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Non-blocking status check.
|
|
79
|
+
# @return [Symbol] :pending | :approved | :rejected | :expired | :not_found
|
|
80
|
+
def status_of(proposal_id)
|
|
81
|
+
p = read_proposal(proposal_id)
|
|
82
|
+
return :not_found unless p
|
|
83
|
+
return :expired if Time.parse(p['expires_at']) < @clock.call
|
|
84
|
+
d = read_decision(proposal_id)
|
|
85
|
+
return :pending unless d
|
|
86
|
+
d['decision'] == 'approve' ? :approved : :rejected
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Record a human decision (via AttachServer mailbox).
|
|
90
|
+
def record_decision(proposal_id, decision:, reviewer:, reason: nil)
|
|
91
|
+
raise ArgumentError, 'decision must be approve|reject' unless %w[approve reject].include?(decision)
|
|
92
|
+
p = read_proposal(proposal_id)
|
|
93
|
+
raise NotFoundError, "proposal not found: #{proposal_id}" unless p
|
|
94
|
+
raise ConflictError, "already decided: #{proposal_id}" if File.exist?(decision_file(proposal_id))
|
|
95
|
+
raise ExpiredError, "expired: #{proposal_id}" if Time.parse(p['expires_at']) < @clock.call
|
|
96
|
+
|
|
97
|
+
write_decision(proposal_id,
|
|
98
|
+
decision: decision,
|
|
99
|
+
reviewer: reviewer,
|
|
100
|
+
proposal_hash: p['proposal_hash'],
|
|
101
|
+
granted_at: @clock.call.iso8601,
|
|
102
|
+
reason: reason)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# For cycle re-entry: returns ApprovalGrant or nil. Never blocks.
|
|
106
|
+
def consume_grant(proposal_id)
|
|
107
|
+
case status_of(proposal_id)
|
|
108
|
+
when :approved
|
|
109
|
+
ApprovalGrant.new(
|
|
110
|
+
proposal_id: proposal_id,
|
|
111
|
+
decision: read_decision(proposal_id),
|
|
112
|
+
proposal: read_proposal(proposal_id)
|
|
113
|
+
)
|
|
114
|
+
else
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Verify proposal content integrity at apply time.
|
|
120
|
+
# @return [Boolean]
|
|
121
|
+
def verify_proposal_integrity(proposal_id)
|
|
122
|
+
p = read_proposal(proposal_id)
|
|
123
|
+
return false unless p
|
|
124
|
+
d = read_decision(proposal_id)
|
|
125
|
+
return false unless d
|
|
126
|
+
|
|
127
|
+
canonical = p.reject { |k, _| mutable_key?(k) }
|
|
128
|
+
recomputed = canonical_hash(canonical)
|
|
129
|
+
recomputed == p['proposal_hash'] && d['proposal_hash'] == p['proposal_hash']
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# List pending proposals.
|
|
133
|
+
def pending_proposals
|
|
134
|
+
Dir.glob(File.join(@dir, '*.json')).filter_map do |f|
|
|
135
|
+
next if f.end_with?('.decision.json') || f.end_with?('.applied.json')
|
|
136
|
+
p = safe_read_json(f)
|
|
137
|
+
next unless p
|
|
138
|
+
next if p['status'] == 'auto_approved' && File.exist?(decision_file(p['proposal_id']))
|
|
139
|
+
s = status_of(p['proposal_id'])
|
|
140
|
+
s == :pending ? p : nil
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Read a proposal record.
|
|
145
|
+
def read_proposal(proposal_id)
|
|
146
|
+
safe_read_json(file_for(proposal_id))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Read a decision record.
|
|
150
|
+
def read_decision(proposal_id)
|
|
151
|
+
safe_read_json(decision_file(proposal_id))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
ApprovalGrant = Struct.new(:proposal_id, :decision, :proposal, keyword_init: true)
|
|
155
|
+
class NotFoundError < StandardError; end
|
|
156
|
+
class ConflictError < StandardError; end
|
|
157
|
+
class ExpiredError < StandardError; end
|
|
158
|
+
class BackpressureError < StandardError; end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def file_for(id) ; File.join(@dir, "#{id}.json") end
|
|
163
|
+
def decision_file(id) ; File.join(@dir, "#{id}.decision.json") end
|
|
164
|
+
|
|
165
|
+
MUTABLE_KEYS = %w[status proposal_hash created_at expires_at ttl_seconds].freeze
|
|
166
|
+
|
|
167
|
+
def mutable_key?(k)
|
|
168
|
+
MUTABLE_KEYS.include?(k.to_s)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Sorted-key canonical JSON for deterministic hashing (R2 residual fix).
|
|
172
|
+
def canonical_hash(obj)
|
|
173
|
+
"sha256:#{Digest::SHA256.hexdigest(canonical_json(obj))}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def canonical_json(obj)
|
|
177
|
+
case obj
|
|
178
|
+
when Hash
|
|
179
|
+
'{' + obj.keys.map(&:to_s).sort.map { |k|
|
|
180
|
+
# Look up by both string and symbol to handle mixed-key hashes
|
|
181
|
+
val = obj.key?(k) ? obj[k] : obj[k.to_sym]
|
|
182
|
+
k.to_json + ':' + canonical_json(val)
|
|
183
|
+
}.join(',') + '}'
|
|
184
|
+
when Array
|
|
185
|
+
'[' + obj.map { |v| canonical_json(v) }.join(',') + ']'
|
|
186
|
+
when Symbol
|
|
187
|
+
obj.to_s.to_json
|
|
188
|
+
else
|
|
189
|
+
obj.to_json
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def stringify_keys(hash)
|
|
194
|
+
hash.transform_keys(&:to_s)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def write_decision(proposal_id, **fields)
|
|
198
|
+
write_atomic(decision_file(proposal_id), JSON.pretty_generate(fields))
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def write_atomic(path, data)
|
|
202
|
+
tmp = "#{path}.tmp.#{SecureRandom.hex(4)}"
|
|
203
|
+
File.open(tmp, 'wb', 0o600) do |f|
|
|
204
|
+
f.write(data)
|
|
205
|
+
f.flush
|
|
206
|
+
f.fsync rescue nil
|
|
207
|
+
end
|
|
208
|
+
File.rename(tmp, path)
|
|
209
|
+
ensure
|
|
210
|
+
File.unlink(tmp) if tmp && File.exist?(tmp)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def safe_read_json(path)
|
|
214
|
+
return nil unless File.file?(path)
|
|
215
|
+
JSON.parse(File.read(path))
|
|
216
|
+
rescue StandardError
|
|
217
|
+
nil
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def check_backpressure!
|
|
221
|
+
count = Dir.glob(File.join(@dir, '*.json')).count do |f|
|
|
222
|
+
next false if f.end_with?('.decision.json') || f.end_with?('.applied.json')
|
|
223
|
+
# Only count proposals that have no decision file (truly pending)
|
|
224
|
+
proposal_id = File.basename(f, '.json')
|
|
225
|
+
!File.exist?(decision_file(proposal_id))
|
|
226
|
+
end
|
|
227
|
+
raise BackpressureError, "max pending proposals (#{MAX_PENDING}) exceeded" if count >= MAX_PENDING
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|