kairos-chain 3.13.0 → 3.14.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 +41 -0
- data/bin/kairos-chain +18 -0
- data/bin/kairos-plugin-project +57 -0
- data/lib/kairos_mcp/initializer.rb +57 -2
- data/lib/kairos_mcp/plugin_projector.rb +433 -0
- data/lib/kairos_mcp/protocol.rb +57 -0
- data/lib/kairos_mcp/skillset.rb +11 -0
- data/lib/kairos_mcp/tools/system_upgrade.rb +22 -0
- data/lib/kairos_mcp/version.rb +1 -1
- data/lib/kairos_mcp.rb +48 -0
- data/templates/skillsets/agent/plugin/SKILL.md +33 -0
- data/templates/skillsets/agent/plugin/agents/monitor.md +23 -0
- data/templates/skillsets/agent/skillset.json +5 -1
- data/templates/skillsets/plugin_projector/lib/plugin_projector.rb +18 -0
- data/templates/skillsets/plugin_projector/plugin/SKILL.md +34 -0
- data/templates/skillsets/plugin_projector/plugin/hooks.json +13 -0
- data/templates/skillsets/plugin_projector/skillset.json +19 -0
- data/templates/skillsets/plugin_projector/tools/plugin_project.rb +96 -0
- data/templates/skillsets/skillset_creator/lib/skillset_creator/scaffold_generator.rb +43 -7
- data/templates/skillsets/skillset_creator/plugin/SKILL.md +30 -0
- data/templates/skillsets/skillset_creator/skillset.json +4 -1
- data/templates/skillsets/skillset_creator/tools/sc_scaffold.rb +9 -2
- data/templates/skillsets/skillset_exchange/plugin/SKILL.md +40 -0
- data/templates/skillsets/skillset_exchange/plugin/agents/reviewer.md +24 -0
- data/templates/skillsets/skillset_exchange/plugin/hooks.json +13 -0
- data/templates/skillsets/skillset_exchange/skillset.json +6 -1
- metadata +15 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 59afbb969508bd1ec45bb4d35f28cd70cc84516d88ba5dc047c07f056bc70a0b
|
|
4
|
+
data.tar.gz: 8dff4c368684c5de32a6691fe2c7c2edf0adbed7a3114211ee4343583ae5b67a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 93131bc0d3ced8749a875ed0a316ec1107ef6a9ecfed61cb5a6f111c2bad6b5331884bc55d2669b2223730c8153c4754bc2209c5d39adf374dc4fba2272eb515
|
|
7
|
+
data.tar.gz: a2358fedff75942a10b73223c8b2e43f6d8f68b3d976be256c580682363e4cf3eae7052ce68f2707f04454cbf2317e46e8f28319d28b82aa789ed7849d353d32
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,47 @@ 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.14.0] - 2026-04-12
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **SkillSet Plugin Projection** — Self-referential dual-mode Claude Code integration.
|
|
12
|
+
Projects SkillSet artifacts (skills, agents, hooks) to `.claude/` structure.
|
|
13
|
+
- **PluginProjector** (`plugin_projector.rb`, ~430 lines): dual-mode (Project + Plugin),
|
|
14
|
+
atomic writes, digest-based no-op, manifest tracking, stale cleanup
|
|
15
|
+
- **Ruby introspection**: `<!-- AUTO_TOOLS -->` marker dynamically generates tool docs
|
|
16
|
+
- **L1 knowledge meta skill**: `<!-- AUTO_KNOWLEDGE_LIST -->` projects knowledge catalog
|
|
17
|
+
- **Self-referential SkillSet**: `plugin_projector` projects its own SKILL.md and hooks
|
|
18
|
+
- **Auto-init + auto-install**: first MCP connection initializes `.kairos/` and installs
|
|
19
|
+
9 core SkillSets (no external dependencies)
|
|
20
|
+
- **Auto-generate `.mcp.json`**: `kairos-chain init` creates MCP config with absolute paths
|
|
21
|
+
- **Plugin artifacts for 3 SkillSets**: agent (SKILL.md + monitor agent),
|
|
22
|
+
skillset_exchange (SKILL.md + reviewer agent + hooks), skillset_creator (SKILL.md)
|
|
23
|
+
- **Scaffold `has_plugin` option**: `sc_scaffold` generates `plugin/` directory
|
|
24
|
+
- **Auto-projection on upgrade**: `system_upgrade apply` and `skillset upgrade --apply`
|
|
25
|
+
trigger re-projection after SkillSet changes
|
|
26
|
+
- **Security**: SAFE_NAME_PATTERN, safe_path boundary checks, ALLOWED_HOOK_COMMANDS warning,
|
|
27
|
+
atomic writes for settings.json auto-reload safety
|
|
28
|
+
- **30 tests**, 3-LLM reviewed (Claude Opus 4.6 + Codex GPT-5.4 + Cursor Composer-2)
|
|
29
|
+
|
|
30
|
+
- **2-step setup**: `kairos-chain init` + `claude` (no manual system_upgrade needed)
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- **Seed `skills/kairos-chain/SKILL.md`**: revised to delegate per-SkillSet workflow details
|
|
35
|
+
to individual SKILL.md files (agent, skillset_exchange, etc.)
|
|
36
|
+
- **`collect_knowledge_entries`**: extracted to `KairosMcp` module (shared by protocol.rb,
|
|
37
|
+
plugin_project tool, and CLI)
|
|
38
|
+
- **Core SkillSets**: auto-install limited to 9 SkillSets without external dependencies
|
|
39
|
+
(excludes multiuser/PostgreSQL, hestia/networking, etc.)
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- **`.mcp.json` absolute paths**: relative `--data-dir` paths caused MCP connection failures
|
|
44
|
+
when Claude Code resolved from different working directory
|
|
45
|
+
- **Non-Claude clients**: projection skipped when `.claude/` directory doesn't exist,
|
|
46
|
+
preventing unintended artifact creation for Cursor/Codex
|
|
47
|
+
|
|
7
48
|
## [3.13.0] - 2026-04-02
|
|
8
49
|
|
|
9
50
|
### Added
|
data/bin/kairos-chain
CHANGED
|
@@ -143,6 +143,24 @@ when 'skillset'
|
|
|
143
143
|
puts "Updated #{r[:name]}: v#{r[:from]} -> v#{r[:to]} (#{r[:files_updated]} files)"
|
|
144
144
|
end
|
|
145
145
|
puts "Done. #{results.size} SkillSet(s) upgraded."
|
|
146
|
+
|
|
147
|
+
# Re-project plugin artifacts if any SkillSets changed
|
|
148
|
+
if results.any?
|
|
149
|
+
begin
|
|
150
|
+
require 'kairos_mcp/plugin_projector'
|
|
151
|
+
project_root = KairosMcp.project_root
|
|
152
|
+
mode = KairosMcp.projection_mode
|
|
153
|
+
projector = KairosMcp::PluginProjector.new(project_root, mode: mode)
|
|
154
|
+
enabled = manager.enabled_skillsets
|
|
155
|
+
knowledge = KairosMcp.collect_knowledge_entries
|
|
156
|
+
if projector.project_if_changed!(enabled, knowledge_entries: knowledge)
|
|
157
|
+
puts "Plugin artifacts projected. Run /reload-plugins in Claude Code."
|
|
158
|
+
end
|
|
159
|
+
rescue => e
|
|
160
|
+
# Non-fatal: projection is optional
|
|
161
|
+
$stderr.puts "Plugin projection skipped: #{e.message}"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
146
164
|
else
|
|
147
165
|
puts "SkillSet upgrades available:"
|
|
148
166
|
puts ""
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# CLI script for plugin projection.
|
|
5
|
+
# Used by Claude Code hooks and manual invocation.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# kairos-plugin-project # Full projection
|
|
9
|
+
# kairos-plugin-project --if-changed # Skip if digest unchanged
|
|
10
|
+
# kairos-plugin-project --json # Output additionalContext JSON
|
|
11
|
+
# kairos-plugin-project --status # Show projection status
|
|
12
|
+
# kairos-plugin-project --verify # Verify projected files
|
|
13
|
+
|
|
14
|
+
require_relative '../lib/kairos_mcp'
|
|
15
|
+
require_relative '../lib/kairos_mcp/plugin_projector'
|
|
16
|
+
require_relative '../lib/kairos_mcp/skillset_manager'
|
|
17
|
+
|
|
18
|
+
project_root = KairosMcp.project_root
|
|
19
|
+
mode = KairosMcp.projection_mode
|
|
20
|
+
projector = KairosMcp::PluginProjector.new(project_root, mode: mode)
|
|
21
|
+
|
|
22
|
+
if ARGV.include?('--status')
|
|
23
|
+
require 'json'
|
|
24
|
+
puts JSON.pretty_generate(projector.status)
|
|
25
|
+
exit 0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
if ARGV.include?('--verify')
|
|
29
|
+
require 'json'
|
|
30
|
+
result = projector.verify
|
|
31
|
+
puts JSON.pretty_generate(result)
|
|
32
|
+
exit(result[:valid] ? 0 : 1)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
manager = KairosMcp::SkillSetManager.new
|
|
36
|
+
enabled = manager.enabled_skillsets
|
|
37
|
+
knowledge_entries = KairosMcp.collect_knowledge_entries
|
|
38
|
+
|
|
39
|
+
json_mode = ARGV.include?('--json')
|
|
40
|
+
|
|
41
|
+
if ARGV.include?('--if-changed')
|
|
42
|
+
changed = projector.project_if_changed!(enabled, knowledge_entries: knowledge_entries)
|
|
43
|
+
if changed
|
|
44
|
+
if json_mode
|
|
45
|
+
puts '{"additionalContext": "Plugin projection updated. Run /reload-plugins to activate new skills."}'
|
|
46
|
+
else
|
|
47
|
+
$stderr.puts "Plugin projection updated. Run /reload-plugins to activate new skills."
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
else
|
|
51
|
+
projector.project!(enabled, knowledge_entries: knowledge_entries)
|
|
52
|
+
if json_mode
|
|
53
|
+
puts '{"additionalContext": "Plugin projection complete. Run /reload-plugins to activate."}'
|
|
54
|
+
else
|
|
55
|
+
$stderr.puts "Plugin projection complete."
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
require 'digest'
|
|
5
|
+
require 'json'
|
|
5
6
|
require 'yaml'
|
|
6
7
|
require 'time'
|
|
7
8
|
require_relative '../kairos_mcp'
|
|
@@ -51,7 +52,8 @@ module KairosMcp
|
|
|
51
52
|
copy_templates
|
|
52
53
|
copy_knowledge_templates
|
|
53
54
|
write_meta
|
|
54
|
-
|
|
55
|
+
generate_mcp_json unless @quiet
|
|
56
|
+
|
|
55
57
|
log ""
|
|
56
58
|
log "KairosChain data directory initialized successfully!"
|
|
57
59
|
log ""
|
|
@@ -61,7 +63,7 @@ module KairosMcp
|
|
|
61
63
|
log " kairos-chain # stdio mode (for Claude Code / Cursor)"
|
|
62
64
|
log " kairos-chain --http # HTTP mode (for remote)"
|
|
63
65
|
log ""
|
|
64
|
-
print_mcp_config
|
|
66
|
+
print_mcp_config unless @generated_mcp_json
|
|
65
67
|
end
|
|
66
68
|
|
|
67
69
|
private
|
|
@@ -190,6 +192,59 @@ module KairosMcp
|
|
|
190
192
|
log " }"
|
|
191
193
|
end
|
|
192
194
|
|
|
195
|
+
# Generate .mcp.json in the project root (parent of .kairos/)
|
|
196
|
+
def generate_mcp_json
|
|
197
|
+
@generated_mcp_json = false
|
|
198
|
+
project_root = File.dirname(@data_dir)
|
|
199
|
+
mcp_json_path = File.join(project_root, '.mcp.json')
|
|
200
|
+
data_dir_absolute = File.expand_path(@data_dir)
|
|
201
|
+
|
|
202
|
+
mcp_content = JSON.pretty_generate({
|
|
203
|
+
"mcpServers" => {
|
|
204
|
+
"kairos-chain" => {
|
|
205
|
+
"command" => "kairos-chain",
|
|
206
|
+
"args" => ["--data-dir", data_dir_absolute]
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
if File.exist?(mcp_json_path)
|
|
212
|
+
existing = File.read(mcp_json_path)
|
|
213
|
+
if existing.include?('kairos-chain')
|
|
214
|
+
log " .mcp.json: kairos-chain already configured (skipped)"
|
|
215
|
+
@generated_mcp_json = true
|
|
216
|
+
return
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# .mcp.json exists but doesn't have kairos-chain
|
|
220
|
+
$stderr.puts ""
|
|
221
|
+
$stderr.puts " .mcp.json already exists at: #{mcp_json_path}"
|
|
222
|
+
$stderr.puts " Add kairos-chain MCP server to it? (y/N): "
|
|
223
|
+
answer = $stdin.gets&.strip
|
|
224
|
+
if answer&.downcase == 'y'
|
|
225
|
+
begin
|
|
226
|
+
existing_json = JSON.parse(existing)
|
|
227
|
+
existing_json['mcpServers'] ||= {}
|
|
228
|
+
existing_json['mcpServers']['kairos-chain'] = {
|
|
229
|
+
'command' => 'kairos-chain',
|
|
230
|
+
'args' => ['--data-dir', data_dir_absolute]
|
|
231
|
+
}
|
|
232
|
+
File.write(mcp_json_path, JSON.pretty_generate(existing_json) + "\n")
|
|
233
|
+
log " .mcp.json: kairos-chain added to existing configuration"
|
|
234
|
+
@generated_mcp_json = true
|
|
235
|
+
rescue JSON::ParserError
|
|
236
|
+
log " .mcp.json: invalid JSON, skipping. Please add kairos-chain manually."
|
|
237
|
+
end
|
|
238
|
+
else
|
|
239
|
+
log " .mcp.json: skipped. Add kairos-chain manually (see below)."
|
|
240
|
+
end
|
|
241
|
+
else
|
|
242
|
+
File.write(mcp_json_path, mcp_content + "\n")
|
|
243
|
+
log " .mcp.json: created with kairos-chain MCP server configuration"
|
|
244
|
+
@generated_mcp_json = true
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
193
248
|
def relative_path(path)
|
|
194
249
|
path.sub("#{@data_dir}/", '')
|
|
195
250
|
end
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
require 'tempfile'
|
|
7
|
+
require 'time'
|
|
8
|
+
|
|
9
|
+
module KairosMcp
|
|
10
|
+
# Projects SkillSet plugin artifacts to Claude Code plugin/project structure.
|
|
11
|
+
#
|
|
12
|
+
# Dual-mode:
|
|
13
|
+
# :project (default) — writes to .claude/skills/, .claude/agents/, .claude/settings.json
|
|
14
|
+
# :plugin — writes to plugin root skills/, agents/, hooks/hooks.json
|
|
15
|
+
#
|
|
16
|
+
# Design: log/skillset_plugin_projection_design_v2.2_20260404.md
|
|
17
|
+
class PluginProjector
|
|
18
|
+
SEED_SKILLS = %w[kairos-chain].freeze
|
|
19
|
+
PROJECTED_BY = 'kairos-chain'
|
|
20
|
+
SAFE_NAME_PATTERN = /\A[a-zA-Z0-9][a-zA-Z0-9_-]*\z/
|
|
21
|
+
ALLOWED_HOOK_COMMANDS = /\Akairos-/
|
|
22
|
+
|
|
23
|
+
attr_reader :mode, :project_root, :output_root
|
|
24
|
+
|
|
25
|
+
def initialize(project_root, mode: :auto)
|
|
26
|
+
@project_root = project_root
|
|
27
|
+
@mode = resolve_mode(mode)
|
|
28
|
+
@output_root = @mode == :plugin ? project_root : File.join(project_root, '.claude')
|
|
29
|
+
@manifest_path = File.join(project_root, '.kairos', 'projection_manifest.json')
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Main entry: project all SkillSet plugin artifacts + L1 knowledge meta skill
|
|
33
|
+
def project!(enabled_skillsets, knowledge_entries: [])
|
|
34
|
+
previous_manifest = load_manifest
|
|
35
|
+
current_outputs = {}
|
|
36
|
+
merged_hooks = @mode == :plugin ? load_seed_hooks : { 'hooks' => {} }
|
|
37
|
+
|
|
38
|
+
enabled_skillsets.each do |ss|
|
|
39
|
+
next unless ss.has_plugin?
|
|
40
|
+
|
|
41
|
+
plugin_dir = File.join(ss.path, 'plugin')
|
|
42
|
+
project_skill!(ss, plugin_dir, current_outputs)
|
|
43
|
+
project_agents!(ss, plugin_dir, current_outputs)
|
|
44
|
+
collect_hooks!(ss, plugin_dir, merged_hooks)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
project_knowledge_meta_skill!(knowledge_entries, current_outputs)
|
|
48
|
+
write_merged_hooks!(merged_hooks, current_outputs)
|
|
49
|
+
cleanup_stale!(previous_manifest, current_outputs)
|
|
50
|
+
save_manifest(current_outputs, enabled_skillsets, knowledge_entries)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Digest-based no-op: skip projection if nothing changed
|
|
54
|
+
def project_if_changed!(enabled_skillsets, knowledge_entries: [])
|
|
55
|
+
digest = compute_source_digest(enabled_skillsets, knowledge_entries)
|
|
56
|
+
return false if digest == load_manifest.dig('source_digest')
|
|
57
|
+
project!(enabled_skillsets, knowledge_entries: knowledge_entries)
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Status summary for MCP tool
|
|
62
|
+
def status
|
|
63
|
+
manifest = load_manifest
|
|
64
|
+
{
|
|
65
|
+
mode: @mode,
|
|
66
|
+
output_root: @output_root,
|
|
67
|
+
projected_at: manifest['projected_at'],
|
|
68
|
+
source_digest: manifest['source_digest'],
|
|
69
|
+
output_count: manifest.fetch('outputs', {}).size
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Verify projected files match manifest
|
|
74
|
+
def verify
|
|
75
|
+
manifest = load_manifest
|
|
76
|
+
outputs = manifest.fetch('outputs', {})
|
|
77
|
+
missing = outputs.keys.reject { |f| File.exist?(f) }
|
|
78
|
+
orphaned = find_orphaned_files(outputs)
|
|
79
|
+
{ valid: missing.empty? && orphaned.empty?, missing: missing, orphaned: orphaned }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def resolve_mode(mode)
|
|
85
|
+
return mode unless mode == :auto
|
|
86
|
+
return :plugin if ENV['KAIROS_PROJECTION_MODE'] == 'plugin'
|
|
87
|
+
:project
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# =========================================================================
|
|
91
|
+
# Skill projection
|
|
92
|
+
# =========================================================================
|
|
93
|
+
|
|
94
|
+
def project_skill!(ss, plugin_dir, outputs)
|
|
95
|
+
src = File.join(plugin_dir, 'SKILL.md')
|
|
96
|
+
return unless File.exist?(src)
|
|
97
|
+
return if SEED_SKILLS.include?(ss.name)
|
|
98
|
+
return unless safe_name?(ss.name)
|
|
99
|
+
|
|
100
|
+
template = File.read(src)
|
|
101
|
+
tools_section = generate_tools_section(ss)
|
|
102
|
+
content = inject_section(template, '<!-- AUTO_TOOLS -->', tools_section)
|
|
103
|
+
|
|
104
|
+
target = File.join(@output_root, 'skills', ss.name, 'SKILL.md')
|
|
105
|
+
return unless safe_path?(target)
|
|
106
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
107
|
+
atomic_write(target, content)
|
|
108
|
+
outputs[target] = { 'source' => src, 'type' => 'skill', 'skillset' => ss.name }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Ruby introspection: generate Available Tools section from tool classes
|
|
112
|
+
# Returns nil if all tools fail introspection (preserves static template)
|
|
113
|
+
def generate_tools_section(ss)
|
|
114
|
+
return nil if ss.tool_class_names.empty?
|
|
115
|
+
|
|
116
|
+
results = ss.tool_class_names.map do |class_name|
|
|
117
|
+
begin
|
|
118
|
+
klass = Object.const_get(class_name)
|
|
119
|
+
instance = klass.new
|
|
120
|
+
schema = instance.input_schema
|
|
121
|
+
params = format_params(schema)
|
|
122
|
+
{ success: true, md: "### `#{instance.name}`\n#{instance.description}\n#{params}" }
|
|
123
|
+
rescue => e
|
|
124
|
+
{ success: false, md: "### `#{class_name}` (introspection failed: #{e.message})" }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
return nil if results.none? { |r| r[:success] }
|
|
129
|
+
results.map { |r| r[:md] }.join("\n\n")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def format_params(schema)
|
|
133
|
+
props = schema.is_a?(Hash) ? (schema[:properties] || schema['properties'] || {}) : {}
|
|
134
|
+
required = schema.is_a?(Hash) ? (schema[:required] || schema['required'] || []) : []
|
|
135
|
+
props.map do |name, spec|
|
|
136
|
+
type = spec[:type] || spec['type'] || 'string'
|
|
137
|
+
desc = spec[:description] || spec['description'] || ''
|
|
138
|
+
req = required.include?(name.to_s) ? ', required' : ''
|
|
139
|
+
"- `#{name}` (#{type}#{req}): #{desc}"
|
|
140
|
+
end.join("\n")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# =========================================================================
|
|
144
|
+
# Agent projection
|
|
145
|
+
# =========================================================================
|
|
146
|
+
|
|
147
|
+
def project_agents!(ss, plugin_dir, outputs)
|
|
148
|
+
agents_src = File.join(plugin_dir, 'agents')
|
|
149
|
+
return unless Dir.exist?(agents_src)
|
|
150
|
+
return unless safe_name?(ss.name)
|
|
151
|
+
Dir.glob(File.join(agents_src, '*.md')).each do |f|
|
|
152
|
+
target = File.join(@output_root, 'agents', "#{ss.name}-#{File.basename(f)}")
|
|
153
|
+
next unless safe_path?(target)
|
|
154
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
155
|
+
atomic_write(target, File.read(f))
|
|
156
|
+
outputs[target] = { 'source' => f, 'type' => 'agent', 'skillset' => ss.name }
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# =========================================================================
|
|
161
|
+
# L1 Knowledge meta skill
|
|
162
|
+
# =========================================================================
|
|
163
|
+
|
|
164
|
+
def project_knowledge_meta_skill!(knowledge_entries, outputs)
|
|
165
|
+
return if knowledge_entries.empty?
|
|
166
|
+
|
|
167
|
+
list_md = knowledge_entries.map do |entry|
|
|
168
|
+
tags = (entry[:tags] || []).first(3).join(', ')
|
|
169
|
+
"| `#{entry[:name]}` | #{entry[:description]} | #{tags} |"
|
|
170
|
+
end.join("\n")
|
|
171
|
+
|
|
172
|
+
table = "| Name | Description | Tags |\n|------|-------------|------|\n#{list_md}"
|
|
173
|
+
|
|
174
|
+
content = knowledge_meta_skill_template(knowledge_entries.size)
|
|
175
|
+
content = inject_section(content, '<!-- AUTO_KNOWLEDGE_LIST -->', table)
|
|
176
|
+
|
|
177
|
+
target = File.join(@output_root, 'skills', 'kairos-knowledge', 'SKILL.md')
|
|
178
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
179
|
+
atomic_write(target, content)
|
|
180
|
+
outputs[target] = { 'type' => 'knowledge_meta_skill', 'knowledge_count' => knowledge_entries.size }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def knowledge_meta_skill_template(count)
|
|
184
|
+
<<~SKILL
|
|
185
|
+
---
|
|
186
|
+
name: kairos-knowledge
|
|
187
|
+
description: >
|
|
188
|
+
Access KairosChain L1 knowledge base. Use when the user needs domain knowledge,
|
|
189
|
+
project conventions, workflow patterns, or accumulated insights from previous sessions.
|
|
190
|
+
_projected_by: #{PROJECTED_BY}
|
|
191
|
+
_knowledge_count: #{count}
|
|
192
|
+
_last_projected: "#{Time.now.utc.iso8601}"
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
# KairosChain Knowledge Base
|
|
196
|
+
|
|
197
|
+
L1 knowledge is dynamically managed through the KairosChain layer system.
|
|
198
|
+
|
|
199
|
+
## How to Access Knowledge
|
|
200
|
+
|
|
201
|
+
1. **Browse**: `knowledge_list` — see all available L1 knowledge
|
|
202
|
+
2. **Read**: `knowledge_get name="xxx"` — read specific knowledge content
|
|
203
|
+
3. **Search**: `knowledge_list query="keyword"` — filter by keyword
|
|
204
|
+
4. **Promote**: `skills_promote` — promote L2 session context to L1 knowledge
|
|
205
|
+
|
|
206
|
+
## Currently Available Knowledge
|
|
207
|
+
|
|
208
|
+
<!-- AUTO_KNOWLEDGE_LIST -->
|
|
209
|
+
SKILL
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# =========================================================================
|
|
213
|
+
# Hooks projection (dual-mode)
|
|
214
|
+
# =========================================================================
|
|
215
|
+
|
|
216
|
+
def collect_hooks!(ss, plugin_dir, merged_hooks)
|
|
217
|
+
hooks_file = File.join(plugin_dir, 'hooks.json')
|
|
218
|
+
return unless File.exist?(hooks_file)
|
|
219
|
+
ss_hooks = JSON.parse(File.read(hooks_file))
|
|
220
|
+
ss_hooks.fetch('hooks', {}).each do |event, handlers|
|
|
221
|
+
merged_hooks['hooks'][event] ||= []
|
|
222
|
+
handlers.each do |h|
|
|
223
|
+
existing = merged_hooks['hooks'][event].find { |e| e['matcher'] == h['matcher'] }
|
|
224
|
+
if existing
|
|
225
|
+
warn "[PluginProjector] WARNING: duplicate matcher '#{h['matcher']}' for #{event} from #{ss.name}"
|
|
226
|
+
end
|
|
227
|
+
# Warn about non-standard hook commands
|
|
228
|
+
cmd = h.dig('hooks', 0, 'command') || h['command']
|
|
229
|
+
if cmd && !cmd.match?(ALLOWED_HOOK_COMMANDS)
|
|
230
|
+
warn "[PluginProjector] WARNING: non-standard hook command '#{cmd}' from #{ss.name}. Review for safety."
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
merged_hooks['hooks'][event].concat(handlers)
|
|
234
|
+
end
|
|
235
|
+
rescue JSON::ParserError => e
|
|
236
|
+
warn "[PluginProjector] ERROR: #{hooks_file} has invalid JSON: #{e.message}"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def write_merged_hooks!(merged_hooks, outputs)
|
|
240
|
+
if @mode == :plugin
|
|
241
|
+
write_hooks_file!(merged_hooks, outputs)
|
|
242
|
+
else
|
|
243
|
+
write_hooks_to_settings!(merged_hooks, outputs)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Plugin mode: write hooks/hooks.json
|
|
248
|
+
def write_hooks_file!(merged_hooks, outputs)
|
|
249
|
+
hooks_dir = File.join(@output_root, 'hooks')
|
|
250
|
+
hooks_file = File.join(hooks_dir, 'hooks.json')
|
|
251
|
+
if merged_hooks['hooks'].empty?
|
|
252
|
+
FileUtils.rm_f(hooks_file)
|
|
253
|
+
else
|
|
254
|
+
FileUtils.mkdir_p(hooks_dir)
|
|
255
|
+
atomic_write(hooks_file, JSON.pretty_generate(merged_hooks))
|
|
256
|
+
outputs[hooks_file] = { 'type' => 'hooks_merged' }
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Project mode: merge hooks into .claude/settings.json
|
|
261
|
+
def write_hooks_to_settings!(merged_hooks, outputs)
|
|
262
|
+
settings_path = File.join(@output_root, 'settings.json')
|
|
263
|
+
settings = load_settings(settings_path)
|
|
264
|
+
return if settings.nil? # JSON parse failed, abort
|
|
265
|
+
|
|
266
|
+
remove_projected_hooks!(settings)
|
|
267
|
+
|
|
268
|
+
unless merged_hooks['hooks'].empty?
|
|
269
|
+
settings['hooks'] ||= {}
|
|
270
|
+
merged_hooks['hooks'].each do |event, handlers|
|
|
271
|
+
settings['hooks'][event] ||= []
|
|
272
|
+
tagged = handlers.map { |h| h.merge('_projected_by' => PROJECTED_BY) }
|
|
273
|
+
settings['hooks'][event].concat(tagged)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Clean up empty hooks
|
|
278
|
+
settings['hooks']&.delete_if { |_, v| v.is_a?(Array) && v.empty? }
|
|
279
|
+
settings.delete('hooks') if settings['hooks']&.empty?
|
|
280
|
+
|
|
281
|
+
atomic_write(settings_path, JSON.pretty_generate(settings))
|
|
282
|
+
outputs[settings_path] = { 'type' => 'hooks_settings_merge' }
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Remove only hooks projected by KairosChain, preserve user hooks
|
|
286
|
+
def remove_projected_hooks!(settings)
|
|
287
|
+
return unless settings['hooks']
|
|
288
|
+
settings['hooks'].each do |_event, handlers|
|
|
289
|
+
next unless handlers.is_a?(Array)
|
|
290
|
+
handlers.reject! { |h| h['_projected_by'] == PROJECTED_BY }
|
|
291
|
+
end
|
|
292
|
+
settings['hooks'].delete_if { |_, v| v.is_a?(Array) && v.empty? }
|
|
293
|
+
settings.delete('hooks') if settings['hooks']&.empty?
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Load settings.json with error handling (P1-2)
|
|
297
|
+
def load_settings(path)
|
|
298
|
+
return {} unless File.exist?(path)
|
|
299
|
+
JSON.parse(File.read(path))
|
|
300
|
+
rescue JSON::ParserError => e
|
|
301
|
+
warn "[PluginProjector] ERROR: #{path} has invalid JSON (#{e.message}). Skipping hooks merge."
|
|
302
|
+
nil
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Plugin mode: load seed hooks from .kairos/seed_hooks.json
|
|
306
|
+
def load_seed_hooks
|
|
307
|
+
seed_path = File.join(@project_root, '.kairos', 'seed_hooks.json')
|
|
308
|
+
if File.exist?(seed_path)
|
|
309
|
+
JSON.parse(File.read(seed_path))
|
|
310
|
+
else
|
|
311
|
+
{ 'hooks' => {} }
|
|
312
|
+
end
|
|
313
|
+
rescue JSON::ParserError
|
|
314
|
+
{ 'hooks' => {} }
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# =========================================================================
|
|
318
|
+
# Cleanup & Manifest
|
|
319
|
+
# =========================================================================
|
|
320
|
+
|
|
321
|
+
def cleanup_stale!(previous_manifest, current_outputs)
|
|
322
|
+
previous_files = previous_manifest.fetch('outputs', {}).keys
|
|
323
|
+
current_files = current_outputs.keys
|
|
324
|
+
stale = previous_files - current_files
|
|
325
|
+
stale.each do |f|
|
|
326
|
+
# Path safety: only delete files under output_root
|
|
327
|
+
canonical = File.expand_path(f)
|
|
328
|
+
unless canonical.start_with?(File.expand_path(@output_root))
|
|
329
|
+
warn "[PluginProjector] WARNING: skipping stale cleanup of '#{f}' (outside output_root)"
|
|
330
|
+
next
|
|
331
|
+
end
|
|
332
|
+
FileUtils.rm_f(canonical)
|
|
333
|
+
dir = File.dirname(canonical)
|
|
334
|
+
FileUtils.rmdir(dir) if Dir.exist?(dir) && Dir.empty?(dir)
|
|
335
|
+
rescue Errno::ENOTEMPTY, Errno::ENOENT
|
|
336
|
+
# Directory not empty or already removed
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def compute_source_digest(enabled_skillsets, knowledge_entries = [])
|
|
341
|
+
ss_content = enabled_skillsets.select(&:has_plugin?).map do |ss|
|
|
342
|
+
"#{ss.name}:#{ss.version}:#{ss.content_hash}"
|
|
343
|
+
end.join('|')
|
|
344
|
+
|
|
345
|
+
# Include description and tags in digest (Codex P1-5: detect metadata changes)
|
|
346
|
+
k_content = knowledge_entries.map do |e|
|
|
347
|
+
tags = (e[:tags] || []).join(',')
|
|
348
|
+
"#{e[:name]}:#{e[:version] || '0'}:#{e[:description]}:#{tags}"
|
|
349
|
+
end.join('|')
|
|
350
|
+
|
|
351
|
+
Digest::SHA256.hexdigest("#{ss_content}||#{k_content}")
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def load_manifest
|
|
355
|
+
return {} unless File.exist?(@manifest_path)
|
|
356
|
+
JSON.parse(File.read(@manifest_path))
|
|
357
|
+
rescue JSON::ParserError
|
|
358
|
+
{}
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def save_manifest(outputs, enabled_skillsets = [], knowledge_entries = [])
|
|
362
|
+
manifest = {
|
|
363
|
+
'projected_at' => Time.now.utc.iso8601,
|
|
364
|
+
'source_digest' => compute_source_digest(enabled_skillsets, knowledge_entries),
|
|
365
|
+
'mode' => @mode.to_s,
|
|
366
|
+
'output_root' => @output_root,
|
|
367
|
+
'outputs' => outputs
|
|
368
|
+
}
|
|
369
|
+
FileUtils.mkdir_p(File.dirname(@manifest_path))
|
|
370
|
+
atomic_write(@manifest_path, JSON.pretty_generate(manifest))
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def find_orphaned_files(manifest_outputs)
|
|
374
|
+
projected_dirs = [
|
|
375
|
+
File.join(@output_root, 'skills'),
|
|
376
|
+
File.join(@output_root, 'agents')
|
|
377
|
+
]
|
|
378
|
+
actual_files = projected_dirs.flat_map do |dir|
|
|
379
|
+
next [] unless Dir.exist?(dir)
|
|
380
|
+
Dir.glob(File.join(dir, '**', '*')).select { |f| File.file?(f) }
|
|
381
|
+
end
|
|
382
|
+
# Files in projected dirs not in manifest (excluding seed skills)
|
|
383
|
+
actual_files.reject do |f|
|
|
384
|
+
manifest_outputs.key?(f) || SEED_SKILLS.any? { |s| f.include?("/skills/#{s}/") }
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# =========================================================================
|
|
389
|
+
# Utilities
|
|
390
|
+
# =========================================================================
|
|
391
|
+
|
|
392
|
+
# Validate SkillSet name against traversal patterns
|
|
393
|
+
def safe_name?(name)
|
|
394
|
+
unless name.match?(SAFE_NAME_PATTERN)
|
|
395
|
+
warn "[PluginProjector] WARNING: unsafe SkillSet name '#{name}', skipping projection"
|
|
396
|
+
return false
|
|
397
|
+
end
|
|
398
|
+
true
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Validate target path is under output_root
|
|
402
|
+
def safe_path?(target)
|
|
403
|
+
canonical = File.expand_path(target)
|
|
404
|
+
unless canonical.start_with?(File.expand_path(@output_root))
|
|
405
|
+
warn "[PluginProjector] WARNING: path '#{target}' is outside output_root, skipping"
|
|
406
|
+
return false
|
|
407
|
+
end
|
|
408
|
+
true
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Atomic write: tmpfile + rename to prevent partial reads (P1-1)
|
|
412
|
+
def atomic_write(path, content)
|
|
413
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
414
|
+
tmp = Tempfile.new([File.basename(path), File.extname(path)], File.dirname(path))
|
|
415
|
+
tmp.write(content)
|
|
416
|
+
tmp.close
|
|
417
|
+
File.rename(tmp.path, path)
|
|
418
|
+
rescue => e
|
|
419
|
+
tmp&.close
|
|
420
|
+
tmp&.unlink
|
|
421
|
+
raise e
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def inject_section(template, marker, content)
|
|
425
|
+
return template if content.nil?
|
|
426
|
+
if template.include?(marker)
|
|
427
|
+
template.gsub(marker, content)
|
|
428
|
+
else
|
|
429
|
+
template
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
end
|