kairos-chain 3.9.2 → 3.10.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 +44 -0
- data/lib/kairos_mcp/safety.rb +6 -0
- data/lib/kairos_mcp/tools/skills_promote.rb +30 -4
- data/lib/kairos_mcp/tools/system_upgrade.rb +32 -0
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/agent/lib/agent/cognitive_loop.rb +68 -0
- data/templates/skillsets/agent/lib/agent/session.rb +1 -0
- data/templates/skillsets/agent/tools/agent_start.rb +3 -1
- data/templates/skillsets/agent/tools/agent_step.rb +82 -5
- data/templates/skillsets/dream/lib/dream/scanner.rb +102 -7
- data/templates/skillsets/dream/tools/dream_scan.rb +9 -2
- data/templates/skillsets/introspection/config/introspection.yml +4 -0
- data/templates/skillsets/introspection/knowledge/introspection_guide/introspection_guide.md +90 -0
- data/templates/skillsets/introspection/lib/introspection/health_scorer.rb +136 -0
- data/templates/skillsets/introspection/lib/introspection/safety_inspector.rb +85 -0
- data/templates/skillsets/introspection/lib/introspection.rb +7 -0
- data/templates/skillsets/introspection/skillset.json +17 -0
- data/templates/skillsets/introspection/tools/introspection_check.rb +207 -0
- data/templates/skillsets/introspection/tools/introspection_health.rb +80 -0
- data/templates/skillsets/introspection/tools/introspection_safety.rb +46 -0
- metadata +12 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 13fdfa15efd40f228b2fb789d51e1cbd722ce732bde40df4c8d25e5bf4108c56
|
|
4
|
+
data.tar.gz: 3aa43d1c26a56af6aa5c24e7ea9ca1d26a20f5c95984aec77f574874beeb810e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 80630f2c2488d3ab1b3864abc33f3f3711507c7ad1d6d166c21b06f33d8c044f51ec518ef22ae1bacc98e6c1a8d06dcff80afccb0d9b6b2b432dd38d7ad220ee
|
|
7
|
+
data.tar.gz: 27d701df365ec0c71b3b16b9190c78d0d590964192316d0d4a08218074ff289b5ca7edbd6fcca41311f9f0daeaed4b0a5ad46091bb36767afb1b2839e5dfab99
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,50 @@ 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.10.0] - 2026-03-31
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **introspection SkillSet** (v0.1.0) — New self-inspection SkillSet with 3 tools:
|
|
12
|
+
- `introspection_check`: Full inspection (L1 health + blockchain integrity + safety mechanisms + recommendations)
|
|
13
|
+
- `introspection_health`: L1 knowledge health scores using Synoptis TrustScorer (optional, falls back to staleness-only)
|
|
14
|
+
- `introspection_safety`: 4-layer safety mechanism visibility (L0 approval workflow, RBAC policies, agent safety gates, blockchain recording)
|
|
15
|
+
- **Dream SkillSet L1 dedup + confidence scoring** (v0.2.1) — `dream_scan` now checks promotion candidates against existing L1 knowledge (name similarity + tag Jaccard overlap) and scores candidates with 3-dimension confidence (recurrence, tag consistency, session diversity). New `include_l1_dedup` parameter.
|
|
16
|
+
- **`skills_promote` attestation integration** — Successful L2→L1 promotions now automatically issue Synoptis attestations (`claim: "promoted_from_l2"`, `actor_role: "automated"`). Graceful degradation when Synoptis is not loaded.
|
|
17
|
+
- **`Safety.registered_policy_names`** — New thread-safe public API for introspecting registered RBAC policies. Replaces `instance_variable_get(:@policies)` pattern.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **`system_upgrade` SkillSet-only install** — When specific SkillSet names are provided via `names` parameter but L0 templates are already up-to-date, `system_upgrade apply` now correctly installs/upgrades the requested SkillSets instead of returning "No upgrade needed". Previously, the L0 upgrade check (`UpgradeAnalyzer.upgrade_needed?`) gated all operations including SkillSet installs.
|
|
22
|
+
|
|
23
|
+
### Design Process
|
|
24
|
+
|
|
25
|
+
- Design reviewed: 2 rounds x 3 LLMs (Claude Opus 4.6, Codex GPT-5.4, Cursor Composer-2)
|
|
26
|
+
- Implementation reviewed: 1 round x 3 LLMs per phase
|
|
27
|
+
- 8 P0/P1 bugs found and fixed during design review (before any code was written)
|
|
28
|
+
- Inspired by oh-my-claudecode analysis; independently designed with KairosChain philosophy
|
|
29
|
+
|
|
30
|
+
## [3.9.4] - 2026-03-30
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- **Permission advisory on claude_code fallback** — When the agent falls back
|
|
35
|
+
to the `claude_code` LLM provider (API key missing), it now checks if a
|
|
36
|
+
`PreToolUse` hook is configured in `.claude/settings.json` for the MCP server.
|
|
37
|
+
If not, a one-time `permission_advisory` is included in the `agent_step`
|
|
38
|
+
response with the exact hook configuration needed for uninterrupted
|
|
39
|
+
autonomous operation. The advisory is suggest-only and never modifies
|
|
40
|
+
user settings.
|
|
41
|
+
|
|
42
|
+
## [3.9.3] - 2026-03-30
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- **act_summary always 'failed'** — `autoexec_run` `internal_execute` mode
|
|
47
|
+
returns `outcome: "internal_execute_complete"` but `agent_step` checked
|
|
48
|
+
`run_parsed['status'] == 'ok'` (always nil). Changed to check
|
|
49
|
+
`outcome.end_with?('_complete')`.
|
|
50
|
+
|
|
7
51
|
## [3.9.2] - 2026-03-30
|
|
8
52
|
|
|
9
53
|
### Fixed
|
data/lib/kairos_mcp/safety.rb
CHANGED
|
@@ -25,6 +25,12 @@ module KairosMcp
|
|
|
25
25
|
@policy_mutex.synchronize { @policies[name.to_sym] }
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# Thread-safe list of registered policy names.
|
|
29
|
+
# Used by introspection SkillSet for safety visibility.
|
|
30
|
+
def self.registered_policy_names
|
|
31
|
+
@policy_mutex.synchronize { @policies.keys.map(&:to_s) }
|
|
32
|
+
end
|
|
33
|
+
|
|
28
34
|
# For testing only
|
|
29
35
|
def self.clear_policies!
|
|
30
36
|
@policy_mutex.synchronize { @policies = {} }
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
3
4
|
require_relative 'base_tool'
|
|
4
5
|
require_relative '../knowledge_provider'
|
|
5
6
|
require_relative '../context_manager'
|
|
@@ -30,7 +31,8 @@ module KairosMcp
|
|
|
30
31
|
|
|
31
32
|
def description
|
|
32
33
|
'Promote knowledge between layers (L2→L1, L1→L0) with optional Persona Assembly for decision support. ' \
|
|
33
|
-
'Assembly generates a structured discussion from multiple perspectives before human decision.'
|
|
34
|
+
'Assembly generates a structured discussion from multiple perspectives before human decision. ' \
|
|
35
|
+
'For pattern detection and auto-scan, use dream_scan instead.'
|
|
34
36
|
end
|
|
35
37
|
|
|
36
38
|
def category
|
|
@@ -63,7 +65,7 @@ module KairosMcp
|
|
|
63
65
|
end
|
|
64
66
|
|
|
65
67
|
def related_tools
|
|
66
|
-
%w[context_save knowledge_update skills_evolve skills_audit]
|
|
68
|
+
%w[context_save knowledge_update skills_evolve skills_audit attestation_issue]
|
|
67
69
|
end
|
|
68
70
|
|
|
69
71
|
def input_schema
|
|
@@ -72,7 +74,7 @@ module KairosMcp
|
|
|
72
74
|
properties: {
|
|
73
75
|
command: {
|
|
74
76
|
type: 'string',
|
|
75
|
-
description: 'Command: "analyze" (with assembly), "promote" (direct promotion), "status" (check requirements), or "suggest" (LLM suggests optimal personas
|
|
77
|
+
description: 'Command: "analyze" (with assembly), "promote" (direct promotion), "status" (check requirements), or "suggest" (LLM suggests optimal personas)',
|
|
76
78
|
enum: %w[analyze promote status suggest]
|
|
77
79
|
},
|
|
78
80
|
source_name: {
|
|
@@ -122,7 +124,7 @@ module KairosMcp
|
|
|
122
124
|
consensus_threshold: {
|
|
123
125
|
type: 'number',
|
|
124
126
|
description: 'Consensus threshold for early termination in discussion mode (default: 0.6 = 60%)'
|
|
125
|
-
}
|
|
127
|
+
},
|
|
126
128
|
},
|
|
127
129
|
required: %w[command]
|
|
128
130
|
}
|
|
@@ -576,6 +578,9 @@ module KairosMcp
|
|
|
576
578
|
# Track promotion event for state commit (this triggers auto-commit on promotion)
|
|
577
579
|
track_promotion_change(from_layer: 'L2', to_layer: 'L1', skill_id: target_name, reason: reason)
|
|
578
580
|
|
|
581
|
+
# Issue attestation AFTER L1 exists
|
|
582
|
+
issue_promotion_attestation(target_name, reason)
|
|
583
|
+
|
|
579
584
|
action = existing ? 'updated' : 'created'
|
|
580
585
|
output = "## Promotion Successful\n\n"
|
|
581
586
|
output += "**Target**: #{target_name} (L1)\n"
|
|
@@ -681,6 +686,27 @@ module KairosMcp
|
|
|
681
686
|
end
|
|
682
687
|
end
|
|
683
688
|
|
|
689
|
+
# Issue attestation after successful promotion
|
|
690
|
+
def issue_promotion_attestation(target_name, reason)
|
|
691
|
+
return unless synoptis_available?
|
|
692
|
+
|
|
693
|
+
invoke_tool('attestation_issue', {
|
|
694
|
+
'subject_ref' => "knowledge://#{target_name}",
|
|
695
|
+
'claim' => 'promoted_from_l2',
|
|
696
|
+
'evidence' => reason.to_s,
|
|
697
|
+
'actor_role' => 'automated'
|
|
698
|
+
})
|
|
699
|
+
rescue StandardError => e
|
|
700
|
+
# Synoptis may not be loaded — silently skip
|
|
701
|
+
warn "[SkillsPromote] Attestation skipped: #{e.message}"
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
def synoptis_available?
|
|
705
|
+
@registry && true
|
|
706
|
+
rescue StandardError
|
|
707
|
+
false
|
|
708
|
+
end
|
|
709
|
+
|
|
684
710
|
# Track promotion event for state commit auto-commit
|
|
685
711
|
def track_promotion_change(from_layer:, to_layer:, skill_id:, reason: nil)
|
|
686
712
|
return unless SkillsConfig.state_commit_enabled?
|
|
@@ -240,6 +240,12 @@ module KairosMcp
|
|
|
240
240
|
analyzer = UpgradeAnalyzer.new
|
|
241
241
|
analyzer.analyze
|
|
242
242
|
|
|
243
|
+
# When specific SkillSet names are requested, skip L0 upgrade check
|
|
244
|
+
# and go directly to SkillSet install/upgrade
|
|
245
|
+
if names && !names.empty? && !analyzer.upgrade_needed?
|
|
246
|
+
return handle_skillset_only_install(names)
|
|
247
|
+
end
|
|
248
|
+
|
|
243
249
|
unless analyzer.upgrade_needed?
|
|
244
250
|
return text_content("No upgrade needed. Data directory is already up to date.")
|
|
245
251
|
end
|
|
@@ -437,6 +443,32 @@ module KairosMcp
|
|
|
437
443
|
# =====================================================================
|
|
438
444
|
# skillsets — List all available SkillSets with install status
|
|
439
445
|
# =====================================================================
|
|
446
|
+
# Install/upgrade specific SkillSets without requiring a full L0 upgrade.
|
|
447
|
+
# Called when names are specified but L0 templates are already up to date.
|
|
448
|
+
def handle_skillset_only_install(names)
|
|
449
|
+
ss_mgr = KairosMcp::SkillSetManager.new
|
|
450
|
+
ss_results = ss_mgr.upgrade_apply(names: names)
|
|
451
|
+
|
|
452
|
+
if ss_results.empty?
|
|
453
|
+
return text_content("No SkillSet changes needed for: #{names.join(', ')}")
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
output = "# SkillSet Install/Upgrade\n\n"
|
|
457
|
+
ss_results.each do |r|
|
|
458
|
+
if r[:action] == 'installed'
|
|
459
|
+
output += " [INSTALLED] #{r[:name]} v#{r[:to]}\n"
|
|
460
|
+
else
|
|
461
|
+
output += " [UPGRADED] #{r[:name]} v#{r[:from]} → v#{r[:to]} (#{r[:files_updated]} files)\n"
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
output += "\nNo L0 template changes needed.\n"
|
|
465
|
+
output += "Restart the MCP server to load new SkillSet tools.\n"
|
|
466
|
+
|
|
467
|
+
text_content(output)
|
|
468
|
+
rescue StandardError => e
|
|
469
|
+
text_content("SkillSet install failed: #{e.message}")
|
|
470
|
+
end
|
|
471
|
+
|
|
440
472
|
def handle_skillsets
|
|
441
473
|
ss_mgr = KairosMcp::SkillSetManager.new
|
|
442
474
|
available = ss_mgr.available_skillsets
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -17,6 +17,7 @@ module KairosMcp
|
|
|
17
17
|
@caller = caller_tool
|
|
18
18
|
@session = session
|
|
19
19
|
@fallback_attempted = false
|
|
20
|
+
@fallback_advisory_shown = false
|
|
20
21
|
@total_calls = 0
|
|
21
22
|
end
|
|
22
23
|
|
|
@@ -167,6 +168,7 @@ module KairosMcp
|
|
|
167
168
|
next
|
|
168
169
|
end
|
|
169
170
|
|
|
171
|
+
check_permission_advisory(fallback)
|
|
170
172
|
return retry_parsed
|
|
171
173
|
end
|
|
172
174
|
|
|
@@ -187,6 +189,72 @@ module KairosMcp
|
|
|
187
189
|
false
|
|
188
190
|
end
|
|
189
191
|
|
|
192
|
+
# Check if Claude Code PreToolUse hook is configured for the MCP server.
|
|
193
|
+
# If not, record a one-time advisory on the session so the user knows
|
|
194
|
+
# how to avoid permission prompts during autonomous operation.
|
|
195
|
+
def check_permission_advisory(provider)
|
|
196
|
+
return unless provider == 'claude_code'
|
|
197
|
+
return if @fallback_advisory_shown
|
|
198
|
+
return if claude_hook_configured?
|
|
199
|
+
|
|
200
|
+
@fallback_advisory_shown = true
|
|
201
|
+
mcp_name = detect_mcp_server_name
|
|
202
|
+
return unless mcp_name # Can't advise without knowing the server name
|
|
203
|
+
|
|
204
|
+
matcher = "mcp__#{mcp_name}__*"
|
|
205
|
+
|
|
206
|
+
advisory = <<~MSG.strip
|
|
207
|
+
Claude Code fallback activated — using your Claude Code subscription instead of API.
|
|
208
|
+
For uninterrupted autonomous operation, a PreToolUse hook for "#{matcher}" is needed.
|
|
209
|
+
|
|
210
|
+
To auto-apply this setting, re-run agent_step with apply_permission_hook: true:
|
|
211
|
+
agent_step(session_id: "#{@session.session_id}", action: "approve", apply_permission_hook: true)
|
|
212
|
+
|
|
213
|
+
This auto-approves only #{mcp_name} MCP tools. Bash, file edits, and other tools still require confirmation.
|
|
214
|
+
MSG
|
|
215
|
+
|
|
216
|
+
@session.permission_advisory = advisory
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def claude_hook_configured?
|
|
220
|
+
mcp_name = detect_mcp_server_name
|
|
221
|
+
return false unless mcp_name
|
|
222
|
+
|
|
223
|
+
matcher = "mcp__#{mcp_name}__*"
|
|
224
|
+
settings_candidates.each do |path|
|
|
225
|
+
next unless File.exist?(path)
|
|
226
|
+
settings = JSON.parse(File.read(path))
|
|
227
|
+
hooks = settings.dig('hooks', 'PreToolUse') || []
|
|
228
|
+
return true if hooks.any? { |h| h['matcher'] == matcher }
|
|
229
|
+
end
|
|
230
|
+
false
|
|
231
|
+
rescue StandardError
|
|
232
|
+
false
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Detect MCP server name from Claude Code settings.
|
|
236
|
+
# Scans project-level and global settings for mcpServers entries
|
|
237
|
+
# whose command/args include 'kairos-chain'.
|
|
238
|
+
def detect_mcp_server_name
|
|
239
|
+
settings_candidates.each do |path|
|
|
240
|
+
next unless File.exist?(path)
|
|
241
|
+
settings = JSON.parse(File.read(path))
|
|
242
|
+
(settings['mcpServers'] || {}).each do |name, config|
|
|
243
|
+
cmd_parts = Array(config['command']) + Array(config['args'])
|
|
244
|
+
return name if cmd_parts.any? { |part| part.to_s.include?('kairos-chain') }
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
nil
|
|
248
|
+
rescue StandardError
|
|
249
|
+
nil
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def settings_candidates
|
|
253
|
+
project = File.join(Dir.pwd, '.claude', 'settings.json')
|
|
254
|
+
global = File.join(Dir.home, '.claude', 'settings.json')
|
|
255
|
+
[project, global]
|
|
256
|
+
end
|
|
257
|
+
|
|
190
258
|
def extract_json(content)
|
|
191
259
|
JSON.parse(content)
|
|
192
260
|
content
|
|
@@ -9,6 +9,7 @@ module KairosMcp
|
|
|
9
9
|
class Session
|
|
10
10
|
attr_reader :session_id, :mandate_id, :goal_name, :invocation_context,
|
|
11
11
|
:state, :cycle_number, :config, :autonomous
|
|
12
|
+
attr_accessor :permission_advisory
|
|
12
13
|
|
|
13
14
|
def initialize(session_id:, mandate_id:, goal_name:, invocation_context:, config:,
|
|
14
15
|
autonomous: false)
|
|
@@ -39,7 +39,9 @@ module KairosMcp
|
|
|
39
39
|
properties: {
|
|
40
40
|
goal_name: {
|
|
41
41
|
type: 'string',
|
|
42
|
-
description: 'Name of the goal
|
|
42
|
+
description: 'Name of the goal. Create it with context_save (L2) first. ' \
|
|
43
|
+
'L1 knowledge is only for reusable goal templates. ' \
|
|
44
|
+
'The agent searches L2 contexts first, then falls back to L1.'
|
|
43
45
|
},
|
|
44
46
|
max_cycles: {
|
|
45
47
|
type: 'integer',
|
|
@@ -52,6 +52,10 @@ module KairosMcp
|
|
|
52
52
|
feedback: {
|
|
53
53
|
type: 'string',
|
|
54
54
|
description: 'Feedback for "revise" action (optional)'
|
|
55
|
+
},
|
|
56
|
+
apply_permission_hook: {
|
|
57
|
+
type: 'boolean',
|
|
58
|
+
description: 'Apply the suggested PreToolUse hook to .claude/settings.json (requires prior permission_advisory)'
|
|
55
59
|
}
|
|
56
60
|
},
|
|
57
61
|
required: %w[session_id action]
|
|
@@ -66,6 +70,11 @@ module KairosMcp
|
|
|
66
70
|
session = Session.load(session_id)
|
|
67
71
|
return error_result("Session not found: #{session_id}") unless session
|
|
68
72
|
|
|
73
|
+
if arguments['apply_permission_hook']
|
|
74
|
+
result = apply_permission_hook
|
|
75
|
+
return text_content(JSON.generate(result)) unless result['status'] == 'ok'
|
|
76
|
+
end
|
|
77
|
+
|
|
69
78
|
case action
|
|
70
79
|
when 'stop'
|
|
71
80
|
handle_stop(session)
|
|
@@ -269,12 +278,14 @@ module KairosMcp
|
|
|
269
278
|
result = run_act_reflect_internal(session)
|
|
270
279
|
session.update_state('checkpoint')
|
|
271
280
|
session.save
|
|
272
|
-
|
|
281
|
+
response = {
|
|
273
282
|
'status' => 'ok', 'session_id' => session.session_id,
|
|
274
283
|
'state' => 'checkpoint',
|
|
275
284
|
'act_summary' => result.dig(:act, 'summary') || 'completed',
|
|
276
285
|
'reflect' => result[:reflect]
|
|
277
|
-
}
|
|
286
|
+
}
|
|
287
|
+
response['permission_advisory'] = session.permission_advisory if session.permission_advisory
|
|
288
|
+
text_content(JSON.generate(response))
|
|
278
289
|
end
|
|
279
290
|
|
|
280
291
|
# ---- AUTONOMOUS LOOP ----
|
|
@@ -423,7 +434,7 @@ module KairosMcp
|
|
|
423
434
|
else 'completed'
|
|
424
435
|
end
|
|
425
436
|
|
|
426
|
-
|
|
437
|
+
response = {
|
|
427
438
|
'status' => status,
|
|
428
439
|
'session_id' => session.session_id,
|
|
429
440
|
'state' => session.state,
|
|
@@ -438,7 +449,9 @@ module KairosMcp
|
|
|
438
449
|
'confidence' => clamp_confidence(r.dig(:reflect, 'confidence')),
|
|
439
450
|
'remaining_count' => Array(r.dig(:reflect, 'remaining')).size }
|
|
440
451
|
}
|
|
441
|
-
}
|
|
452
|
+
}
|
|
453
|
+
response['permission_advisory'] = session.permission_advisory if session.permission_advisory
|
|
454
|
+
text_content(JSON.generate(response))
|
|
442
455
|
end
|
|
443
456
|
|
|
444
457
|
def clamp_confidence(raw)
|
|
@@ -599,7 +612,7 @@ module KairosMcp
|
|
|
599
612
|
'task_id' => task_id,
|
|
600
613
|
'plan_hash' => plan_hash,
|
|
601
614
|
'execution' => run_parsed,
|
|
602
|
-
'summary' => run_parsed['
|
|
615
|
+
'summary' => run_parsed['outcome']&.end_with?('_complete') ? 'completed' : 'failed'
|
|
603
616
|
}
|
|
604
617
|
end
|
|
605
618
|
|
|
@@ -937,6 +950,70 @@ module KairosMcp
|
|
|
937
950
|
'state' => revert_state, 'error' => result['error']
|
|
938
951
|
}))
|
|
939
952
|
end
|
|
953
|
+
|
|
954
|
+
# ---- Permission Hook Management ----
|
|
955
|
+
|
|
956
|
+
def apply_permission_hook
|
|
957
|
+
mcp_name = detect_mcp_server_name
|
|
958
|
+
return { 'status' => 'error', 'error' => 'Could not detect MCP server name' } unless mcp_name
|
|
959
|
+
|
|
960
|
+
settings_path = find_settings_path
|
|
961
|
+
return { 'status' => 'error', 'error' => 'No .claude/settings.json found' } unless settings_path
|
|
962
|
+
|
|
963
|
+
settings = File.exist?(settings_path) ? JSON.parse(File.read(settings_path)) : {}
|
|
964
|
+
settings['hooks'] ||= {}
|
|
965
|
+
settings['hooks']['PreToolUse'] ||= []
|
|
966
|
+
|
|
967
|
+
matcher = "mcp__#{mcp_name}__*"
|
|
968
|
+
|
|
969
|
+
# Check if already configured
|
|
970
|
+
if settings['hooks']['PreToolUse'].any? { |h| h['matcher'] == matcher }
|
|
971
|
+
return { 'status' => 'ok', 'message' => "PreToolUse hook for #{matcher} already configured" }
|
|
972
|
+
end
|
|
973
|
+
|
|
974
|
+
hook_entry = {
|
|
975
|
+
'matcher' => matcher,
|
|
976
|
+
'hooks' => [{
|
|
977
|
+
'type' => 'command',
|
|
978
|
+
'command' => "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\"," \
|
|
979
|
+
"\"permissionDecision\":\"allow\"," \
|
|
980
|
+
"\"permissionDecisionReason\":\"Auto-allowed for #{mcp_name} agent autonomous mode\"}}'",
|
|
981
|
+
'statusMessage' => "Auto-allowing #{mcp_name} tool..."
|
|
982
|
+
}]
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
settings['hooks']['PreToolUse'] << hook_entry
|
|
986
|
+
File.write(settings_path, JSON.pretty_generate(settings) + "\n")
|
|
987
|
+
|
|
988
|
+
{ 'status' => 'ok', 'message' => "PreToolUse hook added for #{matcher} in #{settings_path}" }
|
|
989
|
+
rescue StandardError => e
|
|
990
|
+
{ 'status' => 'error', 'error' => "Failed to apply hook: #{e.message}" }
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
def detect_mcp_server_name
|
|
994
|
+
settings_candidates.each do |path|
|
|
995
|
+
next unless File.exist?(path)
|
|
996
|
+
settings = JSON.parse(File.read(path))
|
|
997
|
+
(settings['mcpServers'] || {}).each do |name, config|
|
|
998
|
+
cmd_parts = Array(config['command']) + Array(config['args'])
|
|
999
|
+
return name if cmd_parts.any? { |part| part.to_s.include?('kairos-chain') }
|
|
1000
|
+
end
|
|
1001
|
+
end
|
|
1002
|
+
nil
|
|
1003
|
+
rescue StandardError
|
|
1004
|
+
nil
|
|
1005
|
+
end
|
|
1006
|
+
|
|
1007
|
+
def find_settings_path
|
|
1008
|
+
# Prefer project-level settings, fall back to global
|
|
1009
|
+
settings_candidates.find { |p| File.exist?(p) }
|
|
1010
|
+
end
|
|
1011
|
+
|
|
1012
|
+
def settings_candidates
|
|
1013
|
+
project_settings = File.join(Dir.pwd, '.claude', 'settings.json')
|
|
1014
|
+
global_settings = File.join(Dir.home, '.claude', 'settings.json')
|
|
1015
|
+
[project_settings, global_settings]
|
|
1016
|
+
end
|
|
940
1017
|
end
|
|
941
1018
|
end
|
|
942
1019
|
end
|
|
@@ -25,8 +25,9 @@ module KairosMcp
|
|
|
25
25
|
# @param scope [String] 'l2', 'l1', or 'all'
|
|
26
26
|
# @param since_session [String, nil] Only scan sessions after this ID
|
|
27
27
|
# @param include_archive_candidates [Boolean] Whether to detect stale L2
|
|
28
|
+
# @param include_l1_dedup [Boolean] Check promotion candidates against existing L1
|
|
28
29
|
# @return [Hash] Structured scan result
|
|
29
|
-
def scan(scope: 'l2', since_session: nil, include_archive_candidates: true)
|
|
30
|
+
def scan(scope: 'l2', since_session: nil, include_archive_candidates: true, include_l1_dedup: true)
|
|
30
31
|
result = {
|
|
31
32
|
scope: scope,
|
|
32
33
|
scanned_at: Time.now.iso8601,
|
|
@@ -38,7 +39,8 @@ module KairosMcp
|
|
|
38
39
|
|
|
39
40
|
if %w[l2 all].include?(scope)
|
|
40
41
|
scan_l2(result, since_session: since_session,
|
|
41
|
-
include_archive_candidates: include_archive_candidates
|
|
42
|
+
include_archive_candidates: include_archive_candidates,
|
|
43
|
+
include_l1_dedup: include_l1_dedup)
|
|
42
44
|
end
|
|
43
45
|
|
|
44
46
|
if %w[l1 all].include?(scope)
|
|
@@ -54,7 +56,7 @@ module KairosMcp
|
|
|
54
56
|
# L2 scanning
|
|
55
57
|
# ---------------------------------------------------------------
|
|
56
58
|
|
|
57
|
-
def scan_l2(result, since_session: nil, include_archive_candidates: true)
|
|
59
|
+
def scan_l2(result, since_session: nil, include_archive_candidates: true, include_l1_dedup: true)
|
|
58
60
|
all_contexts = load_all_l2_contexts(since_session: since_session)
|
|
59
61
|
|
|
60
62
|
# Partition: live contexts vs archived stubs
|
|
@@ -62,7 +64,20 @@ module KairosMcp
|
|
|
62
64
|
|
|
63
65
|
# Promotion candidates: tag co-occurrence across sessions
|
|
64
66
|
sessions_tags = build_sessions_tags(live)
|
|
65
|
-
|
|
67
|
+
candidates = detect_promotion_candidates(sessions_tags, all_sessions_tags: sessions_tags)
|
|
68
|
+
|
|
69
|
+
# L1 dedup: mark candidates that already exist as L1 knowledge
|
|
70
|
+
if include_l1_dedup
|
|
71
|
+
kp = knowledge_provider
|
|
72
|
+
existing_l1 = kp ? kp.list : []
|
|
73
|
+
candidates.each do |candidate|
|
|
74
|
+
match = find_l1_match(candidate[:tag], Array(candidate[:tag]), existing_l1)
|
|
75
|
+
candidate[:already_in_l1] = !match.nil?
|
|
76
|
+
candidate[:l1_match] = match if match
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
result[:promotion_candidates] = candidates
|
|
66
81
|
|
|
67
82
|
# Consolidation candidates: name token overlap
|
|
68
83
|
result[:consolidation_candidates] = detect_consolidation_candidates(live)
|
|
@@ -132,8 +147,11 @@ module KairosMcp
|
|
|
132
147
|
# Detect tags that recur across min_recurrence+ distinct sessions.
|
|
133
148
|
#
|
|
134
149
|
# @param sessions_tags [Hash] { session_id => { context_name => [tags] } }
|
|
135
|
-
# @
|
|
136
|
-
|
|
150
|
+
# @param all_sessions_tags [Hash] same as sessions_tags (for scoring)
|
|
151
|
+
# @return [Array<Hash>] promotion candidate hashes with confidence scoring
|
|
152
|
+
def detect_promotion_candidates(sessions_tags, all_sessions_tags: nil)
|
|
153
|
+
all_sessions_tags ||= sessions_tags
|
|
154
|
+
|
|
137
155
|
tag_sessions = Hash.new { |h, k| h[k] = Set.new }
|
|
138
156
|
|
|
139
157
|
sessions_tags.each do |session_id, contexts|
|
|
@@ -148,12 +166,15 @@ module KairosMcp
|
|
|
148
166
|
.sort_by { |_tag, sids| -sids.size }
|
|
149
167
|
.first(@max_candidates)
|
|
150
168
|
.map do |tag, sids|
|
|
169
|
+
confidence = score_promotion_candidate(tag, sids, all_sessions_tags)
|
|
151
170
|
{
|
|
152
171
|
tag: tag,
|
|
153
172
|
session_count: sids.size,
|
|
154
173
|
sessions: sids.to_a,
|
|
155
174
|
signal: 'tag_recurrence',
|
|
156
|
-
strength: sids.size.to_f / @min_recurrence
|
|
175
|
+
strength: sids.size.to_f / @min_recurrence,
|
|
176
|
+
confidence: confidence,
|
|
177
|
+
already_in_l1: false
|
|
157
178
|
}
|
|
158
179
|
end
|
|
159
180
|
|
|
@@ -274,6 +295,80 @@ module KairosMcp
|
|
|
274
295
|
tags
|
|
275
296
|
end
|
|
276
297
|
|
|
298
|
+
# ---------------------------------------------------------------
|
|
299
|
+
# L1 dedup and confidence scoring (migrated from skills_promote auto_scan)
|
|
300
|
+
# ---------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
# Find an existing L1 entry that matches a candidate by name or tag overlap.
|
|
303
|
+
#
|
|
304
|
+
# @param candidate_name [String] suggested/tag name of the candidate
|
|
305
|
+
# @param candidate_tags [Array<String>] tags for the candidate
|
|
306
|
+
# @param existing_l1 [Array<Hash>] list from KnowledgeProvider#list
|
|
307
|
+
# @return [String, nil] matching L1 entry name or nil
|
|
308
|
+
def find_l1_match(candidate_name, candidate_tags, existing_l1)
|
|
309
|
+
existing_l1.each do |entry|
|
|
310
|
+
# Name match (normalized substring ratio)
|
|
311
|
+
name_sim = name_similarity(candidate_name, entry[:name])
|
|
312
|
+
return entry[:name] if name_sim > 0.8
|
|
313
|
+
|
|
314
|
+
# Tag overlap (Jaccard on name tokens vs entry tags)
|
|
315
|
+
entry_tags = Array(entry[:tags]).to_set
|
|
316
|
+
candidate_set = candidate_tags.to_set
|
|
317
|
+
sim = candidate_set.empty? && entry_tags.empty? ? 0.0 : (candidate_set & entry_tags).size.to_f / (candidate_set | entry_tags).size.to_f
|
|
318
|
+
return entry[:name] if sim > 0.8
|
|
319
|
+
end
|
|
320
|
+
nil
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Compute name similarity using token-level Jaccard.
|
|
324
|
+
#
|
|
325
|
+
# @param a [String]
|
|
326
|
+
# @param b [String]
|
|
327
|
+
# @return [Float] 0.0..1.0
|
|
328
|
+
def name_similarity(a, b)
|
|
329
|
+
a_parts = a.to_s.split('_').to_set
|
|
330
|
+
b_parts = b.to_s.split('_').to_set
|
|
331
|
+
return 0.0 if a_parts.empty? && b_parts.empty?
|
|
332
|
+
(a_parts & b_parts).size.to_f / [a_parts.size, b_parts.size].max
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Score a promotion candidate across 3 dimensions:
|
|
336
|
+
# - recurrence: how many sessions the tag appears in (max 40)
|
|
337
|
+
# - tag_consistency: co-occurrence consistency across sessions (max 30)
|
|
338
|
+
# - session_diversity: distinct sessions (max 30)
|
|
339
|
+
#
|
|
340
|
+
# @param tag [String] the recurring tag
|
|
341
|
+
# @param sessions [Set<String>] session IDs where this tag appears
|
|
342
|
+
# @param all_sessions_tags [Hash] { session_id => { context_name => [tags] } }
|
|
343
|
+
# @return [Integer] 0..100
|
|
344
|
+
def score_promotion_candidate(tag, sessions, all_sessions_tags)
|
|
345
|
+
# Recurrence: 3=10, 4=20, 5=30, 6+=40 (max 40 points)
|
|
346
|
+
recurrence_score = [[sessions.size - 2, 4].min * 10, 40].min
|
|
347
|
+
recurrence_score = [recurrence_score, 0].max
|
|
348
|
+
|
|
349
|
+
# Tag consistency: what fraction of sessions containing this tag
|
|
350
|
+
# also share the same co-occurring tags?
|
|
351
|
+
co_tags_per_session = sessions.map do |sid|
|
|
352
|
+
contexts = all_sessions_tags[sid] || {}
|
|
353
|
+
contexts.values.flatten.uniq.reject { |t| t == tag }
|
|
354
|
+
end.reject(&:empty?)
|
|
355
|
+
|
|
356
|
+
if co_tags_per_session.size >= 2
|
|
357
|
+
co_sets = co_tags_per_session.map(&:to_set)
|
|
358
|
+
intersection = co_sets.reduce(:&)
|
|
359
|
+
union = co_sets.reduce(:|)
|
|
360
|
+
tag_consistency = union.empty? ? 0.0 : intersection.size.to_f / union.size
|
|
361
|
+
else
|
|
362
|
+
tag_consistency = 0.0
|
|
363
|
+
end
|
|
364
|
+
tag_score = (tag_consistency * 30).round
|
|
365
|
+
|
|
366
|
+
# Session diversity: distinct sessions (max 30 points)
|
|
367
|
+
session_score = [[sessions.size - 1, 3].min * 10, 30].min
|
|
368
|
+
|
|
369
|
+
[recurrence_score + tag_score + session_score, 100].min
|
|
370
|
+
end
|
|
371
|
+
|
|
277
372
|
# ---------------------------------------------------------------
|
|
278
373
|
# Helpers
|
|
279
374
|
# ---------------------------------------------------------------
|
|
@@ -42,6 +42,10 @@ module KairosMcp
|
|
|
42
42
|
include_archive_candidates: {
|
|
43
43
|
type: 'boolean',
|
|
44
44
|
description: 'Whether to detect stale L2 contexts for archival. Default: true'
|
|
45
|
+
},
|
|
46
|
+
include_l1_dedup: {
|
|
47
|
+
type: 'boolean',
|
|
48
|
+
description: 'Check promotion candidates against existing L1 knowledge to mark duplicates. Default: true'
|
|
45
49
|
}
|
|
46
50
|
},
|
|
47
51
|
required: []
|
|
@@ -55,7 +59,8 @@ module KairosMcp
|
|
|
55
59
|
scan_result = scanner.scan(
|
|
56
60
|
scope: arguments['scope'] || config.dig('scan', 'default_scope') || 'l2',
|
|
57
61
|
since_session: arguments['since_session'],
|
|
58
|
-
include_archive_candidates: arguments.fetch('include_archive_candidates', true)
|
|
62
|
+
include_archive_candidates: arguments.fetch('include_archive_candidates', true),
|
|
63
|
+
include_l1_dedup: arguments.fetch('include_l1_dedup', true)
|
|
59
64
|
)
|
|
60
65
|
|
|
61
66
|
# Record findings on blockchain if non-empty
|
|
@@ -131,7 +136,9 @@ module KairosMcp
|
|
|
131
136
|
lines << "_No recurring patterns detected._"
|
|
132
137
|
else
|
|
133
138
|
promo.each do |c|
|
|
134
|
-
|
|
139
|
+
dedup_marker = c[:already_in_l1] ? " [already in L1: #{c[:l1_match]}]" : ''
|
|
140
|
+
confidence_str = c[:confidence] ? " (confidence: #{c[:confidence]})" : ''
|
|
141
|
+
lines << "- **#{c[:tag]}**: #{c[:session_count]} sessions (strength: #{c[:strength].round(2)})#{confidence_str}#{dedup_marker}"
|
|
135
142
|
end
|
|
136
143
|
end
|
|
137
144
|
lines << ""
|