kairos-chain 3.16.0 → 3.17.0
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/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/logger.rb +164 -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 +50 -16
- data/templates/knowledge/multi_llm_reviewer_evaluation/multi_llm_reviewer_evaluation.md +29 -8
- 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/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 +3 -3
- 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 +157 -0
- data/templates/skillsets/llm_client/lib/llm_client/cursor_adapter.rb +149 -0
- data/templates/skillsets/llm_client/lib/llm_client/error_taxonomy.rb +124 -0
- data/templates/skillsets/llm_client/lib/llm_client/safe_subprocess.rb +206 -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/config/multi_llm_review.yml +69 -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 +189 -0
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/prompt_builder.rb +126 -0
- data/templates/skillsets/multi_llm_review/skillset.json +18 -0
- data/templates/skillsets/multi_llm_review/test/test_multi_llm_review.rb +335 -0
- data/templates/skillsets/multi_llm_review/tools/multi_llm_review.rb +275 -0
- metadata +90 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9d2b6b702508caf49a43e1fdc09beb4b6d5a7dd478a89492d07503643744855c
|
|
4
|
+
data.tar.gz: faaceed727c7de49424391d32768bff4790411a414cd4b2a671fcd31501aadf5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 727829e5b3672c2b694336baabb14af1a0906ee3b7c196bb320ea0060db98bc163ab36a7971f405eb88e0c1d0393dcb370fed337bb6220e0e8dd1716885e7166
|
|
7
|
+
data.tar.gz: 12be3d20add101e4ac13871b4c36dce043e104fcbe67ab1af95ad55793b018b2cc0e785324d34a4f8887a2969c7d6b5db9da61a9f777496d560296a8d94f14cc
|
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,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
|