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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 58adf58a025748f0645e1933fc19c3fab811c68eb459d2f5a8a152417146c386
4
- data.tar.gz: 1547a6b41b36061550ea60709eca097778500344b18f00849da26a47ed70397f
3
+ metadata.gz: 13fdfa15efd40f228b2fb789d51e1cbd722ce732bde40df4c8d25e5bf4108c56
4
+ data.tar.gz: 3aa43d1c26a56af6aa5c24e7ea9ca1d26a20f5c95984aec77f574874beeb810e
5
5
  SHA512:
6
- metadata.gz: dcb1eb4a44279b9d0c94a61b47093d95b2d5f997d9b6ec3850424a2c9d11e26aeb75a4d1a04cc67e5848ceee4638b350233489f7b1f76d4acf3f16852a4bcd68
7
- data.tar.gz: a10800df40fd5105ca2f84c21350a89d5ab4e2c3adf91cc41ac288dc2695a01141a4e96ae6df346b7613d9c03fdc5c0aa0afe1571ed269fc843d595f800ba3f2
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
@@ -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 for content)',
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
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.9.2"
2
+ VERSION = "3.10.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -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 (must exist as L1 knowledge or L2 context)'
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
- text_content(JSON.generate({
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
- text_content(JSON.generate({
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['status'] == 'ok' ? 'completed' : 'failed'
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
- result[:promotion_candidates] = detect_promotion_candidates(sessions_tags)
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
- # @return [Array<Hash>] promotion candidate hashes
136
- def detect_promotion_candidates(sessions_tags)
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
- lines << "- **#{c[:tag]}**: #{c[:session_count]} sessions (strength: #{c[:strength].round(2)})"
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 << ""
@@ -0,0 +1,4 @@
1
+ introspection:
2
+ health:
3
+ staleness_days: 180
4
+ report_format: "markdown"