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.
Files changed (136) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/README.md +1 -0
  4. data/bin/kairos-chain-daemon +111 -0
  5. data/lib/kairos_mcp/daemon/active_observe.rb +180 -0
  6. data/lib/kairos_mcp/daemon/approval_gate.rb +231 -0
  7. data/lib/kairos_mcp/daemon/attach_server.rb +415 -0
  8. data/lib/kairos_mcp/daemon/budget.rb +172 -0
  9. data/lib/kairos_mcp/daemon/canonical.rb +83 -0
  10. data/lib/kairos_mcp/daemon/chronos.rb +641 -0
  11. data/lib/kairos_mcp/daemon/code_gen_act.rb +247 -0
  12. data/lib/kairos_mcp/daemon/code_gen_phase_handler.rb +87 -0
  13. data/lib/kairos_mcp/daemon/command_mailbox.rb +91 -0
  14. data/lib/kairos_mcp/daemon/credentials.rb +182 -0
  15. data/lib/kairos_mcp/daemon/daemon_llm_caller.rb +253 -0
  16. data/lib/kairos_mcp/daemon/daemon_policy.rb +112 -0
  17. data/lib/kairos_mcp/daemon/edit_kernel.rb +54 -0
  18. data/lib/kairos_mcp/daemon/elevation_token.rb +36 -0
  19. data/lib/kairos_mcp/daemon/execution_context.rb +21 -0
  20. data/lib/kairos_mcp/daemon/heartbeat.rb +138 -0
  21. data/lib/kairos_mcp/daemon/idempotency_check.rb +132 -0
  22. data/lib/kairos_mcp/daemon/idempotent_chain_recorder.rb +140 -0
  23. data/lib/kairos_mcp/daemon/integration.rb +293 -0
  24. data/lib/kairos_mcp/daemon/llm_phase_functions.rb +184 -0
  25. data/lib/kairos_mcp/daemon/mandate_factory.rb +123 -0
  26. data/lib/kairos_mcp/daemon/ooda_cycle_runner.rb +275 -0
  27. data/lib/kairos_mcp/daemon/pdf_build.rb +96 -0
  28. data/lib/kairos_mcp/daemon/pid_lock.rb +103 -0
  29. data/lib/kairos_mcp/daemon/planner.rb +100 -0
  30. data/lib/kairos_mcp/daemon/policy_elevation.rb +74 -0
  31. data/lib/kairos_mcp/daemon/proposal_routes.rb +135 -0
  32. data/lib/kairos_mcp/daemon/restricted_shell/argv_validators.rb +114 -0
  33. data/lib/kairos_mcp/daemon/restricted_shell/binary_resolver.rb +70 -0
  34. data/lib/kairos_mcp/daemon/restricted_shell/errors.rb +30 -0
  35. data/lib/kairos_mcp/daemon/restricted_shell/runner.rb +153 -0
  36. data/lib/kairos_mcp/daemon/restricted_shell/sandbox_context.rb +26 -0
  37. data/lib/kairos_mcp/daemon/restricted_shell/sandbox_factory.rb +89 -0
  38. data/lib/kairos_mcp/daemon/restricted_shell.rb +129 -0
  39. data/lib/kairos_mcp/daemon/scope_classifier.rb +44 -0
  40. data/lib/kairos_mcp/daemon/signal_handler.rb +80 -0
  41. data/lib/kairos_mcp/daemon/task_dag.rb +322 -0
  42. data/lib/kairos_mcp/daemon/wal.rb +391 -0
  43. data/lib/kairos_mcp/daemon/wal_phase_recorder.rb +90 -0
  44. data/lib/kairos_mcp/daemon/wal_recovery.rb +86 -0
  45. data/lib/kairos_mcp/daemon.rb +363 -0
  46. data/lib/kairos_mcp/invocation_context.rb +44 -6
  47. data/lib/kairos_mcp/lifecycle_hook.rb +61 -0
  48. data/lib/kairos_mcp/logger.rb +164 -0
  49. data/lib/kairos_mcp/signal_handle.rb +75 -0
  50. data/lib/kairos_mcp/skillset.rb +13 -0
  51. data/lib/kairos_mcp/tool_registry.rb +133 -0
  52. data/lib/kairos_mcp/version.rb +1 -1
  53. data/templates/knowledge/llm_cross_evaluation/assets/prompts/cross_evaluation.md.erb +35 -0
  54. data/templates/knowledge/llm_cross_evaluation/assets/prompts/cross_evaluation_philosophy.md.erb +65 -0
  55. data/templates/knowledge/llm_cross_evaluation/assets/prompts/incompleteness_report.md.erb +36 -0
  56. data/templates/knowledge/llm_cross_evaluation/assets/prompts/meta_evaluation.md.erb +37 -0
  57. data/templates/knowledge/llm_cross_evaluation/assets/prompts/meta_evaluation_philosophy.md.erb +45 -0
  58. data/templates/knowledge/llm_cross_evaluation/assets/prompts/nomic_postgame.md.erb +36 -0
  59. data/templates/knowledge/llm_cross_evaluation/assets/prompts/nomic_proposal.md.erb +34 -0
  60. data/templates/knowledge/llm_cross_evaluation/assets/prompts/nomic_vote.md.erb +32 -0
  61. data/templates/knowledge/llm_cross_evaluation/assets/prompts/self_calibration.md.erb +42 -0
  62. data/templates/knowledge/llm_cross_evaluation/assets/prompts/self_calibration_philosophy.md.erb +43 -0
  63. data/templates/knowledge/llm_cross_evaluation/assets/tasks/code_generation.yaml +20 -0
  64. data/templates/knowledge/llm_cross_evaluation/assets/tasks/kairoschain_philosophy.yaml +55 -0
  65. data/templates/knowledge/llm_cross_evaluation/assets/tasks/logic_reasoning.yaml +19 -0
  66. data/templates/knowledge/llm_cross_evaluation/assets/tasks/scientific_reasoning.yaml +27 -0
  67. data/templates/knowledge/llm_cross_evaluation/llm_cross_evaluation.md +409 -0
  68. data/templates/knowledge/llm_cross_evaluation/scripts/run_cross_eval.rb +1752 -0
  69. data/templates/knowledge/multi_llm_review_workflow/multi_llm_review_workflow.md +141 -16
  70. data/templates/knowledge/multi_llm_reviewer_evaluation/multi_llm_reviewer_evaluation.md +29 -8
  71. data/templates/skills/kairos_tutorial.md +70 -21
  72. data/templates/skillsets/agent/config/agent.yml +39 -0
  73. data/templates/skillsets/agent/lib/agent/cognitive_loop.rb +189 -20
  74. data/templates/skillsets/agent/tools/agent_step.rb +183 -5
  75. data/templates/skillsets/daemon_runtime/lib/daemon_runtime/attach_auth.rb +196 -0
  76. data/templates/skillsets/daemon_runtime/lib/daemon_runtime/main_loop.rb +87 -0
  77. data/templates/skillsets/daemon_runtime/lib/daemon_runtime/main_loop_supervisor.rb +84 -0
  78. data/templates/skillsets/daemon_runtime/lib/daemon_runtime/signal_coordinator.rb +176 -0
  79. data/templates/skillsets/daemon_runtime/lib/daemon_runtime.rb +16 -0
  80. data/templates/skillsets/daemon_runtime/skillset.json +16 -0
  81. data/templates/skillsets/daemon_runtime/test/test_attach_auth.rb +254 -0
  82. data/templates/skillsets/daemon_runtime/test/test_daemon_runtime.rb +250 -0
  83. data/templates/skillsets/external_tools/lib/external_tools/tool_support.rb +42 -0
  84. data/templates/skillsets/external_tools/lib/external_tools/workspace_confinement.rb +111 -0
  85. data/templates/skillsets/external_tools/lib/external_tools.rb +6 -0
  86. data/templates/skillsets/external_tools/skillset.json +24 -0
  87. data/templates/skillsets/external_tools/test/test_external_tools.rb +521 -0
  88. data/templates/skillsets/external_tools/test/test_http_tools.rb +451 -0
  89. data/templates/skillsets/external_tools/tools/safe_file_copy.rb +90 -0
  90. data/templates/skillsets/external_tools/tools/safe_file_delete.rb +75 -0
  91. data/templates/skillsets/external_tools/tools/safe_file_edit.rb +106 -0
  92. data/templates/skillsets/external_tools/tools/safe_file_list.rb +95 -0
  93. data/templates/skillsets/external_tools/tools/safe_file_read.rb +85 -0
  94. data/templates/skillsets/external_tools/tools/safe_file_write.rb +110 -0
  95. data/templates/skillsets/external_tools/tools/safe_git_branch.rb +140 -0
  96. data/templates/skillsets/external_tools/tools/safe_git_commit.rb +125 -0
  97. data/templates/skillsets/external_tools/tools/safe_git_push.rb +111 -0
  98. data/templates/skillsets/external_tools/tools/safe_git_status.rb +81 -0
  99. data/templates/skillsets/external_tools/tools/safe_http_get.rb +203 -0
  100. data/templates/skillsets/external_tools/tools/safe_http_post.rb +209 -0
  101. data/templates/skillsets/llm_client/config/llm_client.yml +4 -4
  102. data/templates/skillsets/llm_client/lib/llm_client/call_router.rb +187 -0
  103. data/templates/skillsets/llm_client/lib/llm_client/claude_code_adapter.rb +59 -13
  104. data/templates/skillsets/llm_client/lib/llm_client/codex_adapter.rb +166 -0
  105. data/templates/skillsets/llm_client/lib/llm_client/cursor_adapter.rb +158 -0
  106. data/templates/skillsets/llm_client/lib/llm_client/error_taxonomy.rb +124 -0
  107. data/templates/skillsets/llm_client/lib/llm_client/headless.rb +85 -0
  108. data/templates/skillsets/llm_client/lib/llm_client/safe_subprocess.rb +206 -0
  109. data/templates/skillsets/llm_client/test/test_call_router.rb +316 -0
  110. data/templates/skillsets/llm_client/test/test_cursor_adapter_retryable.rb +70 -0
  111. data/templates/skillsets/llm_client/test/test_error_taxonomy.rb +195 -0
  112. data/templates/skillsets/llm_client/tools/llm_call.rb +94 -17
  113. data/templates/skillsets/llm_client/tools/llm_configure.rb +11 -3
  114. data/templates/skillsets/llm_client/tools/llm_status.rb +45 -0
  115. data/templates/skillsets/mmp/lib/mmp/identity.rb +2 -1
  116. data/templates/skillsets/multi_llm_review/bin/dispatch_worker.rb +309 -0
  117. data/templates/skillsets/multi_llm_review/config/multi_llm_review.yml +129 -0
  118. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/consensus.rb +145 -0
  119. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/dispatcher.rb +216 -0
  120. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/main_state.rb +64 -0
  121. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/pending_state.rb +429 -0
  122. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/persona_assembly.rb +184 -0
  123. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/pin_resolver.rb +146 -0
  124. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/prompt_builder.rb +126 -0
  125. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/wait_for_worker.rb +143 -0
  126. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/worker_reaper.rb +96 -0
  127. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/worker_spawner.rb +67 -0
  128. data/templates/skillsets/multi_llm_review/skillset.json +19 -0
  129. data/templates/skillsets/multi_llm_review/test/test_dispatcher_usage.rb +36 -0
  130. data/templates/skillsets/multi_llm_review/test/test_multi_llm_review.rb +1220 -0
  131. data/templates/skillsets/multi_llm_review/test/test_pending_state_v3.rb +409 -0
  132. data/templates/skillsets/multi_llm_review/test/test_pin_resolver.rb +173 -0
  133. data/templates/skillsets/multi_llm_review/test/test_worker_spawner.rb +145 -0
  134. data/templates/skillsets/multi_llm_review/tools/multi_llm_review.rb +606 -0
  135. data/templates/skillsets/multi_llm_review/tools/multi_llm_review_collect.rb +364 -0
  136. metadata +118 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 57b5814a96eadd45ccf9056493e217017d0ff5d83f676ee5cccde7c8ae8b3fb7
4
- data.tar.gz: 12c91db84b545e576e821fe1e1a82c7fa0f30089850fff92adcab7f85e244cc0
3
+ metadata.gz: ef45a8e58aedc056cd5303abb6463a66503da146fc2bb7abe8ee018569fde8e0
4
+ data.tar.gz: c62bc1328bc6a22e577cb1a8dad2de501bf184dd9022acb207257a4cfaa49955
5
5
  SHA512:
6
- metadata.gz: 528258d448d7079162acb42e7bb6d4ad62402b5d3c6a3802882137175462f24468e3355cc2d90a0efdb778a0949c523209d5e060a637fb5500f893155f9b2ed5
7
- data.tar.gz: 74e8d097b208b131bab97a3b2c81208644279c08a48f7f4cb54ddedd9a8fe31c15eb7b22ebfddd0d07eec2bd93672990168e246e9350af202ff517b232c0f992
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