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.
Files changed (28) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -0
  3. data/bin/kairos-chain +18 -0
  4. data/bin/kairos-plugin-project +57 -0
  5. data/lib/kairos_mcp/initializer.rb +57 -2
  6. data/lib/kairos_mcp/plugin_projector.rb +433 -0
  7. data/lib/kairos_mcp/protocol.rb +57 -0
  8. data/lib/kairos_mcp/skillset.rb +11 -0
  9. data/lib/kairos_mcp/tools/system_upgrade.rb +22 -0
  10. data/lib/kairos_mcp/version.rb +1 -1
  11. data/lib/kairos_mcp.rb +48 -0
  12. data/templates/skillsets/agent/plugin/SKILL.md +33 -0
  13. data/templates/skillsets/agent/plugin/agents/monitor.md +23 -0
  14. data/templates/skillsets/agent/skillset.json +5 -1
  15. data/templates/skillsets/plugin_projector/lib/plugin_projector.rb +18 -0
  16. data/templates/skillsets/plugin_projector/plugin/SKILL.md +34 -0
  17. data/templates/skillsets/plugin_projector/plugin/hooks.json +13 -0
  18. data/templates/skillsets/plugin_projector/skillset.json +19 -0
  19. data/templates/skillsets/plugin_projector/tools/plugin_project.rb +96 -0
  20. data/templates/skillsets/skillset_creator/lib/skillset_creator/scaffold_generator.rb +43 -7
  21. data/templates/skillsets/skillset_creator/plugin/SKILL.md +30 -0
  22. data/templates/skillsets/skillset_creator/skillset.json +4 -1
  23. data/templates/skillsets/skillset_creator/tools/sc_scaffold.rb +9 -2
  24. data/templates/skillsets/skillset_exchange/plugin/SKILL.md +40 -0
  25. data/templates/skillsets/skillset_exchange/plugin/agents/reviewer.md +24 -0
  26. data/templates/skillsets/skillset_exchange/plugin/hooks.json +13 -0
  27. data/templates/skillsets/skillset_exchange/skillset.json +6 -1
  28. metadata +15 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9677cd39be103647a6f733640b46d4785fb7898bff420596ac9bf34880b5d9f7
4
- data.tar.gz: daaff4ac3d451e272429787ccd1ffd0c22c3522c355a382f0129d89d806b39c3
3
+ metadata.gz: 59afbb969508bd1ec45bb4d35f28cd70cc84516d88ba5dc047c07f056bc70a0b
4
+ data.tar.gz: 8dff4c368684c5de32a6691fe2c7c2edf0adbed7a3114211ee4343583ae5b67a
5
5
  SHA512:
6
- metadata.gz: 73aa8e44ecb933877178888fe64c494a5a85544051aa68f58d7823e541322a53a9a39d73d907bd6c30974bb79a7b4ada52c31c77e0165d5a17ba71df7981e663
7
- data.tar.gz: de7dc8b133bdc276781da26aa6fea468b26e7190af199cfaa4c0de2e966622cd171c040431c4baa7c0712e4728b4ffbe4fe8d661e594cf07c54c26b0c28b0b1d
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