kairos-chain 2.0.5 → 2.1.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 +29 -3
- data/lib/kairos_mcp/dsl_ast/ast_engine.rb +366 -0
- data/lib/kairos_mcp/dsl_ast/decompiler.rb +95 -0
- data/lib/kairos_mcp/dsl_ast/drift_detector.rb +174 -0
- data/lib/kairos_mcp/kairos_chain/formalization_decision.rb +72 -0
- data/lib/kairos_mcp/protocol.rb +22 -0
- data/lib/kairos_mcp/skill_contexts.rb +77 -0
- data/lib/kairos_mcp/skills_dsl.rb +17 -1
- data/lib/kairos_mcp/tool_registry.rb +9 -0
- data/lib/kairos_mcp/tools/definition_decompile.rb +76 -0
- data/lib/kairos_mcp/tools/definition_drift.rb +96 -0
- data/lib/kairos_mcp/tools/definition_verify.rb +102 -0
- data/lib/kairos_mcp/tools/formalization_history.rb +139 -0
- data/lib/kairos_mcp/tools/formalization_record.rb +145 -0
- data/lib/kairos_mcp/tools/skills_dsl_get.rb +37 -0
- data/lib/kairos_mcp/version.rb +1 -1
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bb6f9b51791860a4898475d255ec105e4de84ac49cdbb8efb22297fff9db3337
|
|
4
|
+
data.tar.gz: 9c13b5e3743eeb39d84fa037f84027a0be9865d75ba5851123fe57fdd21133f6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 955cca06fa60a00c344412a98e73dd47c9164a122fcb185c72c27a9bcc948691477835b69ba51974a5e4702451b7264c61f6627d5791fb3657ecdb817a29af0d
|
|
7
|
+
data.tar.gz: 105ac2714d16387a47392a20e9fb707034702829abb071cd03f0be4bcf42896bec655ecede56236b4965ddc0ddb140227bcea137a3ca140bddcb2cee7ac0e582
|
data/CHANGELOG.md
CHANGED
|
@@ -4,12 +4,38 @@ 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
|
-
## [2.0
|
|
7
|
+
## [2.1.0] - 2026-02-25
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Phase 1: DSL/AST Partial Formalization Layer**
|
|
12
|
+
- `AstNode` Struct and `DefinitionContext` with 5 node types (Constraint, SemanticReasoning, Plan, ToolCall, Check)
|
|
13
|
+
- `Skill` Struct extended with `:definition` and `:formalization_notes` fields
|
|
14
|
+
- `FormalizationDecision` class for on-chain provenance records
|
|
15
|
+
- `formalization_record` MCP tool: record formalization decisions to blockchain
|
|
16
|
+
- `formalization_history` MCP tool: query accumulated formalization decisions
|
|
17
|
+
- `skills_dsl_get` enhanced with Definition (Structural Layer) and Formalization Notes (Provenance Layer) sections
|
|
18
|
+
- `core_safety` and `evolution_rules` L0 skills annotated with definition blocks
|
|
19
|
+
- 68 tests (test_dsl_ast_phase1.rb)
|
|
20
|
+
|
|
21
|
+
- **Phase 2: AST Verification Engine, Decompiler, and Drift Detection**
|
|
22
|
+
- `AstEngine`: Pattern-matched structural verification (no eval) with condition evaluation (==, <, >=, .method?(), not in)
|
|
23
|
+
- `Decompiler`: AST to human-readable Markdown reconstruction
|
|
24
|
+
- `DriftDetector`: Content/definition layer divergence detection with coverage_ratio and keyword matching (no LLM)
|
|
25
|
+
- `definition_verify` MCP tool: structural constraint verification report
|
|
26
|
+
- `definition_decompile` MCP tool: reverse AST to human-readable form
|
|
27
|
+
- `definition_drift` MCP tool: detect content/definition layer misalignment
|
|
28
|
+
- `skills_dsl_get` enhanced with Verification Status section (with fallback)
|
|
29
|
+
- Security: method call whitelist for condition evaluation, type-safe numeric comparisons
|
|
30
|
+
- 91 tests (test_dsl_ast_phase2.rb), full backward compatibility with Phase 1
|
|
31
|
+
|
|
32
|
+
- **DSL/AST Source of Truth Policy**: Ruby DSL (.rb) is authoritative; JSON representations are derived outputs
|
|
33
|
+
|
|
34
|
+
- **Upgrade notification at MCP session start**: `Protocol#handle_initialize` checks gem vs. data version via `UpgradeAnalyzer` and returns a `notifications` entry when an upgrade is available, so LLM clients are informed at session start without disrupting normal operation
|
|
8
35
|
|
|
9
36
|
### Fixed
|
|
10
37
|
|
|
11
|
-
- **Deadlock
|
|
12
|
-
- **Atomicity**: `check_trigger_conditions` now executes within a single lock, preventing race conditions between individual predicate checks.
|
|
38
|
+
- **Deadlock in PendingChanges#summary**: Recursive locking when summary calls constraint check methods within already-held @mutex synchronize block. Separated lock acquisition (public API) from logic (private methods). Made check_trigger_conditions atomic.
|
|
13
39
|
|
|
14
40
|
---
|
|
15
41
|
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../skill_contexts'
|
|
4
|
+
|
|
5
|
+
module KairosMcp
|
|
6
|
+
module DslAst
|
|
7
|
+
# Result of evaluating a single AST node
|
|
8
|
+
NodeResult = Struct.new(:node_name, :node_type, :satisfied, :detail, :evaluable, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
# Result of verifying all definition nodes for a skill
|
|
11
|
+
VerificationReport = Struct.new(:skill_id, :results, :timestamp, keyword_init: true) do
|
|
12
|
+
# All deterministic (evaluable) nodes passed?
|
|
13
|
+
def all_deterministic_passed?
|
|
14
|
+
results.select(&:evaluable).all? { |r| r.satisfied == true }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Nodes that require human judgment
|
|
18
|
+
def human_required
|
|
19
|
+
results.reject(&:evaluable)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Summary counts
|
|
23
|
+
def summary
|
|
24
|
+
total = results.size
|
|
25
|
+
passed = results.count { |r| r.evaluable && r.satisfied == true }
|
|
26
|
+
failed = results.count { |r| r.evaluable && r.satisfied == false }
|
|
27
|
+
unknown = results.count { |r| r.evaluable && r.satisfied == :unknown }
|
|
28
|
+
non_evaluable = results.count { |r| !r.evaluable }
|
|
29
|
+
{ total: total, passed: passed, failed: failed, unknown: unknown, human_required: non_evaluable }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# AST node evaluation engine
|
|
34
|
+
# Verifies definition nodes against a binding context without using eval.
|
|
35
|
+
# Design: verification-only — does not replace behavior block execution.
|
|
36
|
+
# NOTE: The engine itself is infrastructure in Phase 2.
|
|
37
|
+
# Policy aspects (drift thresholds, etc.) may become a SkillSet in Phase 3.
|
|
38
|
+
class AstEngine
|
|
39
|
+
# Allowed methods for condition evaluation via send().
|
|
40
|
+
# Only query methods (no side effects) are permitted.
|
|
41
|
+
ALLOWED_METHODS = %i[
|
|
42
|
+
can_evolve? has_tool? include? key? empty? nil?
|
|
43
|
+
is_a? respond_to? size length count
|
|
44
|
+
].freeze
|
|
45
|
+
|
|
46
|
+
# Verify all definition nodes for a skill
|
|
47
|
+
# @param skill [KairosMcp::SkillsDsl::Skill] the skill to verify
|
|
48
|
+
# @param binding_context [Hash] runtime values for condition evaluation
|
|
49
|
+
# @return [VerificationReport]
|
|
50
|
+
def self.verify(skill, binding_context: {})
|
|
51
|
+
return nil unless skill.definition
|
|
52
|
+
|
|
53
|
+
results = skill.definition.nodes.map do |node|
|
|
54
|
+
evaluate_node(node, binding_context: binding_context)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
VerificationReport.new(
|
|
58
|
+
skill_id: skill.id,
|
|
59
|
+
results: results,
|
|
60
|
+
timestamp: Time.now.iso8601
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Evaluate a single AST node
|
|
65
|
+
# @param node [KairosMcp::AstNode] the node to evaluate
|
|
66
|
+
# @param binding_context [Hash] runtime values for condition evaluation
|
|
67
|
+
# @return [NodeResult]
|
|
68
|
+
def self.evaluate_node(node, binding_context: {})
|
|
69
|
+
case node.type
|
|
70
|
+
when :Constraint
|
|
71
|
+
evaluate_constraint(node, binding_context)
|
|
72
|
+
when :Check
|
|
73
|
+
evaluate_check(node, binding_context)
|
|
74
|
+
when :Plan
|
|
75
|
+
evaluate_plan(node)
|
|
76
|
+
when :ToolCall
|
|
77
|
+
evaluate_tool_call(node)
|
|
78
|
+
when :SemanticReasoning
|
|
79
|
+
evaluate_semantic_reasoning(node)
|
|
80
|
+
else
|
|
81
|
+
NodeResult.new(
|
|
82
|
+
node_name: node.name,
|
|
83
|
+
node_type: node.type,
|
|
84
|
+
satisfied: :unknown,
|
|
85
|
+
detail: "Unknown node type: #{node.type}",
|
|
86
|
+
evaluable: false
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# Evaluate Constraint node via pattern matching (no eval)
|
|
94
|
+
def self.evaluate_constraint(node, binding_context)
|
|
95
|
+
opts = node.options || {}
|
|
96
|
+
condition = opts[:condition]
|
|
97
|
+
|
|
98
|
+
if condition.nil?
|
|
99
|
+
# No condition string — check if required key exists in context
|
|
100
|
+
if opts[:required] == true
|
|
101
|
+
# Constraint is a declaration; structurally valid
|
|
102
|
+
return NodeResult.new(
|
|
103
|
+
node_name: node.name,
|
|
104
|
+
node_type: :Constraint,
|
|
105
|
+
satisfied: true,
|
|
106
|
+
detail: "Required constraint declared (structural)",
|
|
107
|
+
evaluable: true
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
return NodeResult.new(
|
|
112
|
+
node_name: node.name,
|
|
113
|
+
node_type: :Constraint,
|
|
114
|
+
satisfied: true,
|
|
115
|
+
detail: "Constraint declared (no condition to evaluate)",
|
|
116
|
+
evaluable: true
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Pattern match the condition string
|
|
121
|
+
result = match_condition(condition, binding_context)
|
|
122
|
+
NodeResult.new(
|
|
123
|
+
node_name: node.name,
|
|
124
|
+
node_type: :Constraint,
|
|
125
|
+
satisfied: result[:satisfied],
|
|
126
|
+
detail: result[:detail],
|
|
127
|
+
evaluable: result[:evaluable]
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Evaluate Check node
|
|
132
|
+
def self.evaluate_check(node, binding_context)
|
|
133
|
+
opts = node.options || {}
|
|
134
|
+
condition = opts[:condition]
|
|
135
|
+
|
|
136
|
+
unless condition
|
|
137
|
+
return NodeResult.new(
|
|
138
|
+
node_name: node.name,
|
|
139
|
+
node_type: :Check,
|
|
140
|
+
satisfied: :unknown,
|
|
141
|
+
detail: "No condition specified",
|
|
142
|
+
evaluable: false
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
result = match_condition(condition, binding_context)
|
|
147
|
+
NodeResult.new(
|
|
148
|
+
node_name: node.name,
|
|
149
|
+
node_type: :Check,
|
|
150
|
+
satisfied: result[:satisfied],
|
|
151
|
+
detail: result[:detail],
|
|
152
|
+
evaluable: result[:evaluable]
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Evaluate Plan node — structural validity (steps exist and are named)
|
|
157
|
+
def self.evaluate_plan(node)
|
|
158
|
+
opts = node.options || {}
|
|
159
|
+
steps = opts[:steps]
|
|
160
|
+
|
|
161
|
+
unless steps.is_a?(Array) && !steps.empty?
|
|
162
|
+
return NodeResult.new(
|
|
163
|
+
node_name: node.name,
|
|
164
|
+
node_type: :Plan,
|
|
165
|
+
satisfied: false,
|
|
166
|
+
detail: "Plan has no steps defined",
|
|
167
|
+
evaluable: true
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
NodeResult.new(
|
|
172
|
+
node_name: node.name,
|
|
173
|
+
node_type: :Plan,
|
|
174
|
+
satisfied: true,
|
|
175
|
+
detail: "Plan has #{steps.size} steps: #{steps.map(&:to_s).join(' -> ')}",
|
|
176
|
+
evaluable: true
|
|
177
|
+
)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Evaluate ToolCall node — command recognition (does not execute)
|
|
181
|
+
def self.evaluate_tool_call(node)
|
|
182
|
+
opts = node.options || {}
|
|
183
|
+
command = opts[:command]
|
|
184
|
+
|
|
185
|
+
unless command && !command.to_s.strip.empty?
|
|
186
|
+
return NodeResult.new(
|
|
187
|
+
node_name: node.name,
|
|
188
|
+
node_type: :ToolCall,
|
|
189
|
+
satisfied: false,
|
|
190
|
+
detail: "No command specified",
|
|
191
|
+
evaluable: true
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
NodeResult.new(
|
|
196
|
+
node_name: node.name,
|
|
197
|
+
node_type: :ToolCall,
|
|
198
|
+
satisfied: true,
|
|
199
|
+
detail: "Command recognized: #{command}",
|
|
200
|
+
evaluable: true
|
|
201
|
+
)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# SemanticReasoning — explicitly non-evaluable (requires human judgment)
|
|
205
|
+
def self.evaluate_semantic_reasoning(node)
|
|
206
|
+
opts = node.options || {}
|
|
207
|
+
prompt = opts[:prompt] || "(no prompt)"
|
|
208
|
+
|
|
209
|
+
NodeResult.new(
|
|
210
|
+
node_name: node.name,
|
|
211
|
+
node_type: :SemanticReasoning,
|
|
212
|
+
satisfied: :unknown,
|
|
213
|
+
detail: "Requires human judgment: #{prompt}",
|
|
214
|
+
evaluable: false
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Pattern-match a condition string against binding_context
|
|
219
|
+
# Supported patterns:
|
|
220
|
+
# "X == true/false" — boolean comparison
|
|
221
|
+
# "X == VALUE" — equality comparison
|
|
222
|
+
# "X < Y" / "X > Y" / "X >= Y" / "X <= Y" — numeric comparison
|
|
223
|
+
# "X.method?(arg)" — method call on context object
|
|
224
|
+
# "X not in Y" — exclusion check
|
|
225
|
+
# Unsupported patterns return evaluable: false
|
|
226
|
+
def self.match_condition(condition, binding_context)
|
|
227
|
+
# Pattern: "X == true" or "X == false"
|
|
228
|
+
if condition =~ /\A(\w+)\s*==\s*(true|false)\z/
|
|
229
|
+
var_name = $1.to_sym
|
|
230
|
+
expected = $2 == 'true'
|
|
231
|
+
if binding_context.key?(var_name)
|
|
232
|
+
actual = binding_context[var_name]
|
|
233
|
+
return {
|
|
234
|
+
satisfied: actual == expected,
|
|
235
|
+
detail: "#{var_name}: expected #{expected}, got #{actual}",
|
|
236
|
+
evaluable: true
|
|
237
|
+
}
|
|
238
|
+
else
|
|
239
|
+
return {
|
|
240
|
+
satisfied: :unknown,
|
|
241
|
+
detail: "Variable '#{var_name}' not in binding context",
|
|
242
|
+
evaluable: false
|
|
243
|
+
}
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Pattern: "X < Y" or "X > Y" or "X >= Y" or "X <= Y"
|
|
248
|
+
if condition =~ /\A(\w+)\s*(<|>|<=|>=)\s*(\w+)\z/
|
|
249
|
+
left_name = $1.to_sym
|
|
250
|
+
op = $2
|
|
251
|
+
right_name = $3.to_sym
|
|
252
|
+
|
|
253
|
+
left_val = binding_context.key?(left_name) ? binding_context[left_name] : nil
|
|
254
|
+
right_val = binding_context.key?(right_name) ? binding_context[right_name] : nil
|
|
255
|
+
|
|
256
|
+
if left_val.nil? || right_val.nil?
|
|
257
|
+
missing = []
|
|
258
|
+
missing << left_name unless binding_context.key?(left_name)
|
|
259
|
+
missing << right_name unless binding_context.key?(right_name)
|
|
260
|
+
return {
|
|
261
|
+
satisfied: :unknown,
|
|
262
|
+
detail: "Missing variables: #{missing.join(', ')}",
|
|
263
|
+
evaluable: false
|
|
264
|
+
}
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
begin
|
|
268
|
+
result = case op
|
|
269
|
+
when '<' then left_val < right_val
|
|
270
|
+
when '>' then left_val > right_val
|
|
271
|
+
when '<=' then left_val <= right_val
|
|
272
|
+
when '>=' then left_val >= right_val
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
satisfied: result,
|
|
277
|
+
detail: "#{left_name}(#{left_val}) #{op} #{right_name}(#{right_val}) = #{result}",
|
|
278
|
+
evaluable: true
|
|
279
|
+
}
|
|
280
|
+
rescue TypeError, ArgumentError => e
|
|
281
|
+
return {
|
|
282
|
+
satisfied: :unknown,
|
|
283
|
+
detail: "Type error in comparison: #{e.message}",
|
|
284
|
+
evaluable: false
|
|
285
|
+
}
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Pattern: "X.method?(arg)"
|
|
290
|
+
if condition =~ /\A(\w+)\.(\w+\??)\(([^)]*)\)\z/
|
|
291
|
+
obj_name = $1.to_sym
|
|
292
|
+
method_name = $2.to_sym
|
|
293
|
+
arg_str = $3.strip
|
|
294
|
+
|
|
295
|
+
if binding_context.key?(obj_name)
|
|
296
|
+
obj = binding_context[obj_name]
|
|
297
|
+
if obj.respond_to?(method_name)
|
|
298
|
+
# Security: only allow whitelisted query methods (no side effects)
|
|
299
|
+
unless ALLOWED_METHODS.include?(method_name)
|
|
300
|
+
return {
|
|
301
|
+
satisfied: :unknown,
|
|
302
|
+
detail: "Method '#{method_name}' not in allowed list",
|
|
303
|
+
evaluable: false
|
|
304
|
+
}
|
|
305
|
+
end
|
|
306
|
+
# Parse argument: try symbol, then string
|
|
307
|
+
arg = arg_str.start_with?(':') ? arg_str[1..-1].to_sym : arg_str
|
|
308
|
+
begin
|
|
309
|
+
result = obj.send(method_name, arg)
|
|
310
|
+
return {
|
|
311
|
+
satisfied: !!result,
|
|
312
|
+
detail: "#{obj_name}.#{method_name}(#{arg_str}) = #{result}",
|
|
313
|
+
evaluable: true
|
|
314
|
+
}
|
|
315
|
+
rescue StandardError => e
|
|
316
|
+
return {
|
|
317
|
+
satisfied: :unknown,
|
|
318
|
+
detail: "Error calling #{method_name}: #{e.message}",
|
|
319
|
+
evaluable: false
|
|
320
|
+
}
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
satisfied: :unknown,
|
|
327
|
+
detail: "Cannot evaluate: #{condition}",
|
|
328
|
+
evaluable: false
|
|
329
|
+
}
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Pattern: "X not in Y"
|
|
333
|
+
if condition =~ /\A(\w+)\s+not\s+in\s+(\w+)\z/
|
|
334
|
+
item_name = $1.to_sym
|
|
335
|
+
collection_name = $2.to_sym
|
|
336
|
+
|
|
337
|
+
if binding_context.key?(item_name) && binding_context.key?(collection_name)
|
|
338
|
+
item = binding_context[item_name]
|
|
339
|
+
collection = binding_context[collection_name]
|
|
340
|
+
if collection.respond_to?(:include?)
|
|
341
|
+
result = !collection.include?(item)
|
|
342
|
+
return {
|
|
343
|
+
satisfied: result,
|
|
344
|
+
detail: "#{item_name} not in #{collection_name}: #{result}",
|
|
345
|
+
evaluable: true
|
|
346
|
+
}
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
satisfied: :unknown,
|
|
352
|
+
detail: "Cannot evaluate exclusion: #{condition}",
|
|
353
|
+
evaluable: false
|
|
354
|
+
}
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Unrecognized pattern
|
|
358
|
+
{
|
|
359
|
+
satisfied: :unknown,
|
|
360
|
+
detail: "Unrecognized condition pattern: #{condition}",
|
|
361
|
+
evaluable: false
|
|
362
|
+
}
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../skill_contexts'
|
|
4
|
+
|
|
5
|
+
module KairosMcp
|
|
6
|
+
module DslAst
|
|
7
|
+
# AST -> Natural language reverse conversion
|
|
8
|
+
# Template-based decompilation of definition nodes to human-readable Markdown.
|
|
9
|
+
# Used for: drift detection comparison, formalization_record decompile_text field.
|
|
10
|
+
class Decompiler
|
|
11
|
+
# Decompile a full definition to Markdown
|
|
12
|
+
# @param definition [KairosMcp::DefinitionContext] the definition to decompile
|
|
13
|
+
# @return [String] Markdown representation
|
|
14
|
+
def self.decompile(definition)
|
|
15
|
+
return "" unless definition && definition.nodes && !definition.nodes.empty?
|
|
16
|
+
|
|
17
|
+
lines = ["## Definition (Decompiled)\n"]
|
|
18
|
+
|
|
19
|
+
definition.nodes.each do |node|
|
|
20
|
+
lines << decompile_node(node)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
lines.join("\n")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Decompile a single node to a Markdown line
|
|
27
|
+
# @param node [KairosMcp::AstNode] the node to decompile
|
|
28
|
+
# @return [String] Markdown representation
|
|
29
|
+
def self.decompile_node(node)
|
|
30
|
+
case node.type
|
|
31
|
+
when :Constraint
|
|
32
|
+
decompile_constraint(node)
|
|
33
|
+
when :Check
|
|
34
|
+
decompile_check(node)
|
|
35
|
+
when :Plan
|
|
36
|
+
decompile_plan(node)
|
|
37
|
+
when :ToolCall
|
|
38
|
+
decompile_tool_call(node)
|
|
39
|
+
when :SemanticReasoning
|
|
40
|
+
decompile_semantic_reasoning(node)
|
|
41
|
+
else
|
|
42
|
+
"- **Unknown** (`#{node.name}`): type=#{node.type}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def self.decompile_constraint(node)
|
|
49
|
+
opts = node.options || {}
|
|
50
|
+
parts = ["- **Requirement** (`#{node.name}`)"]
|
|
51
|
+
|
|
52
|
+
if opts[:condition]
|
|
53
|
+
parts << ": #{opts[:condition]}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
qualifiers = []
|
|
57
|
+
qualifiers << "required" if opts[:required]
|
|
58
|
+
qualifiers << "timing: #{opts[:timing]}" if opts[:timing]
|
|
59
|
+
qualifiers << "scope: #{opts[:scope]}" if opts[:scope]
|
|
60
|
+
qualifiers << "target: #{opts[:target]}" if opts[:target]
|
|
61
|
+
|
|
62
|
+
parts << " [#{qualifiers.join(', ')}]" unless qualifiers.empty?
|
|
63
|
+
|
|
64
|
+
parts.join
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.decompile_check(node)
|
|
68
|
+
opts = node.options || {}
|
|
69
|
+
line = "- **Check** (`#{node.name}`)"
|
|
70
|
+
line += ": #{opts[:condition]}" if opts[:condition]
|
|
71
|
+
line
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.decompile_plan(node)
|
|
75
|
+
opts = node.options || {}
|
|
76
|
+
steps = opts[:steps] || []
|
|
77
|
+
step_str = steps.map(&:to_s).join(" -> ")
|
|
78
|
+
"- **Workflow** (`#{node.name}`): #{steps.size} steps — #{step_str}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.decompile_tool_call(node)
|
|
82
|
+
opts = node.options || {}
|
|
83
|
+
line = "- **Tool Call** (`#{node.name}`)"
|
|
84
|
+
line += ": `#{opts[:command]}`" if opts[:command]
|
|
85
|
+
line
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.decompile_semantic_reasoning(node)
|
|
89
|
+
opts = node.options || {}
|
|
90
|
+
prompt = opts[:prompt] || "(no prompt)"
|
|
91
|
+
"- **Human Judgment Required** (`#{node.name}`): #{prompt}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../skill_contexts'
|
|
4
|
+
|
|
5
|
+
module KairosMcp
|
|
6
|
+
module DslAst
|
|
7
|
+
# Drift item representing a single discrepancy between content and definition
|
|
8
|
+
DriftItem = Struct.new(:direction, :severity, :description, :node_name, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
# Report of all detected drifts for a skill
|
|
11
|
+
DriftReport = Struct.new(:skill_id, :items, :coverage_ratio, :timestamp, keyword_init: true) do
|
|
12
|
+
def drifted?
|
|
13
|
+
!items.empty?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def errors
|
|
17
|
+
items.select { |i| i.severity == :error }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def warnings
|
|
21
|
+
items.select { |i| i.severity == :warning }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def infos
|
|
25
|
+
items.select { |i| i.severity == :info }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def summary
|
|
29
|
+
{
|
|
30
|
+
total: items.size,
|
|
31
|
+
errors: errors.size,
|
|
32
|
+
warnings: warnings.size,
|
|
33
|
+
info: infos.size,
|
|
34
|
+
coverage_ratio: coverage_ratio
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Deterministic content <-> definition drift detection
|
|
40
|
+
# No LLM usage — keyword matching and structural analysis only.
|
|
41
|
+
# NOTE: Drift thresholds and policies may become a SkillSet in Phase 3.
|
|
42
|
+
class DriftDetector
|
|
43
|
+
# Keywords that indicate structural assertions in natural language content
|
|
44
|
+
ASSERTION_KEYWORDS = %w[must required always never shall mandatory].freeze
|
|
45
|
+
|
|
46
|
+
# Detect drift between content and definition layers
|
|
47
|
+
# @param skill [KairosMcp::SkillsDsl::Skill] the skill to analyze
|
|
48
|
+
# @return [DriftReport]
|
|
49
|
+
def self.detect(skill)
|
|
50
|
+
items = []
|
|
51
|
+
content = skill.content || ""
|
|
52
|
+
definition = skill.definition
|
|
53
|
+
content_lower = content.downcase
|
|
54
|
+
|
|
55
|
+
# No definition => no drift analysis possible
|
|
56
|
+
unless definition && definition.nodes && !definition.nodes.empty?
|
|
57
|
+
return DriftReport.new(
|
|
58
|
+
skill_id: skill.id,
|
|
59
|
+
items: [],
|
|
60
|
+
coverage_ratio: nil,
|
|
61
|
+
timestamp: Time.now.iso8601
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Check 1: definition nodes reflected in content (definition_orphaned)
|
|
66
|
+
covered_count = 0
|
|
67
|
+
definition.nodes.each do |node|
|
|
68
|
+
if node_reflected_in_content?(node, content_lower)
|
|
69
|
+
covered_count += 1
|
|
70
|
+
else
|
|
71
|
+
items << DriftItem.new(
|
|
72
|
+
direction: :definition_orphaned,
|
|
73
|
+
severity: :warning,
|
|
74
|
+
description: "Definition node '#{node.name}' (#{node.type}) has no corresponding mention in content",
|
|
75
|
+
node_name: node.name
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Check 2: content assertions not covered by definition (content_uncovered)
|
|
81
|
+
uncovered = content_assertions_not_covered(content, definition)
|
|
82
|
+
uncovered.each do |assertion|
|
|
83
|
+
items << DriftItem.new(
|
|
84
|
+
direction: :content_uncovered,
|
|
85
|
+
severity: :info,
|
|
86
|
+
description: "Content assertion \"#{assertion}\" not covered by any definition node",
|
|
87
|
+
node_name: nil
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Coverage ratio: proportion of definition nodes reflected in content
|
|
92
|
+
total_nodes = definition.nodes.size
|
|
93
|
+
ratio = total_nodes > 0 ? covered_count.to_f / total_nodes : 1.0
|
|
94
|
+
|
|
95
|
+
DriftReport.new(
|
|
96
|
+
skill_id: skill.id,
|
|
97
|
+
items: items,
|
|
98
|
+
coverage_ratio: ratio.round(2),
|
|
99
|
+
timestamp: Time.now.iso8601
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
# Check if a definition node's name or key options appear in the content
|
|
106
|
+
def self.node_reflected_in_content?(node, content_lower)
|
|
107
|
+
# Convert node name from snake_case to words for matching
|
|
108
|
+
name_words = node.name.to_s.split('_')
|
|
109
|
+
|
|
110
|
+
# Check if any name word appears in content
|
|
111
|
+
name_match = name_words.any? { |word| content_lower.include?(word.downcase) }
|
|
112
|
+
return true if name_match
|
|
113
|
+
|
|
114
|
+
# Also check key option values
|
|
115
|
+
opts = node.options || {}
|
|
116
|
+
opts.each_value do |v|
|
|
117
|
+
next unless v.is_a?(String)
|
|
118
|
+
return true if content_lower.include?(v.downcase)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
false
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Find content lines with assertion keywords not covered by any definition node
|
|
125
|
+
def self.content_assertions_not_covered(content, definition)
|
|
126
|
+
uncovered = []
|
|
127
|
+
node_keywords = collect_node_keywords(definition)
|
|
128
|
+
|
|
129
|
+
content.each_line do |line|
|
|
130
|
+
stripped = line.strip
|
|
131
|
+
next if stripped.empty?
|
|
132
|
+
next if stripped.start_with?('#', '|', '-') # Skip headers, tables, list markers that are structural
|
|
133
|
+
|
|
134
|
+
# Check if this line contains an assertion keyword
|
|
135
|
+
line_lower = stripped.downcase
|
|
136
|
+
has_assertion = ASSERTION_KEYWORDS.any? { |kw| line_lower.include?(kw) }
|
|
137
|
+
next unless has_assertion
|
|
138
|
+
|
|
139
|
+
# Check if any definition node keyword appears in this line
|
|
140
|
+
covered = node_keywords.any? { |kw| line_lower.include?(kw) }
|
|
141
|
+
unless covered
|
|
142
|
+
# Truncate long lines
|
|
143
|
+
display = stripped.length > 80 ? "#{stripped[0..77]}..." : stripped
|
|
144
|
+
uncovered << display
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
uncovered
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Collect searchable keywords from all definition nodes
|
|
152
|
+
def self.collect_node_keywords(definition)
|
|
153
|
+
keywords = []
|
|
154
|
+
|
|
155
|
+
definition.nodes.each do |node|
|
|
156
|
+
# Add name parts
|
|
157
|
+
node.name.to_s.split('_').each { |w| keywords << w.downcase }
|
|
158
|
+
|
|
159
|
+
# Add option values that are strings
|
|
160
|
+
(node.options || {}).each_value do |v|
|
|
161
|
+
case v
|
|
162
|
+
when String
|
|
163
|
+
v.split(/\s+/).each { |w| keywords << w.downcase if w.length > 3 }
|
|
164
|
+
when Symbol
|
|
165
|
+
keywords << v.to_s.downcase
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
keywords.uniq
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|