kward 0.80.0 → 0.81.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +1 -1
  5. data/doc/composer.md +5 -8
  6. data/doc/configuration.md +13 -3
  7. data/doc/permissions.md +1 -1
  8. data/doc/rpc.md +2 -1
  9. data/doc/sandboxing.md +4 -4
  10. data/doc/security.md +6 -9
  11. data/doc/shell.md +161 -196
  12. data/doc/skills.md +19 -5
  13. data/doc/tabs.md +1 -1
  14. data/doc/usage.md +6 -5
  15. data/lib/kward/agent.rb +11 -6
  16. data/lib/kward/ansi.rb +9 -1
  17. data/lib/kward/cli/commands.rb +7 -0
  18. data/lib/kward/cli/interactive_turn.rb +1 -1
  19. data/lib/kward/cli/memory_commands.rb +2 -2
  20. data/lib/kward/cli/plugins.rb +1 -1
  21. data/lib/kward/cli/project_skills.rb +99 -0
  22. data/lib/kward/cli/project_skills_commands.rb +87 -0
  23. data/lib/kward/cli/prompt_interface.rb +54 -4
  24. data/lib/kward/cli/rendering.rb +28 -2
  25. data/lib/kward/cli/runtime_helpers.rb +157 -19
  26. data/lib/kward/cli/sessions.rb +6 -4
  27. data/lib/kward/cli/settings.rb +1 -1
  28. data/lib/kward/cli/slash_commands.rb +7 -1
  29. data/lib/kward/cli/tabs.rb +4 -1
  30. data/lib/kward/cli.rb +21 -1
  31. data/lib/kward/compactor.rb +7 -2
  32. data/lib/kward/config_files.rb +30 -9
  33. data/lib/kward/conversation.rb +8 -5
  34. data/lib/kward/ekwsh.rb +37 -16
  35. data/lib/kward/hooks/audit_log.rb +5 -2
  36. data/lib/kward/interactive_pty_runner.rb +88 -22
  37. data/lib/kward/plugin_registry.rb +20 -13
  38. data/lib/kward/prompt_interface/composer_controller.rb +13 -1
  39. data/lib/kward/prompt_interface/editor/controller.rb +4 -0
  40. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +35 -10
  41. data/lib/kward/prompt_interface/key_handler.rb +78 -43
  42. data/lib/kward/prompt_interface/overlay_renderer.rb +12 -0
  43. data/lib/kward/prompt_interface/screen.rb +5 -0
  44. data/lib/kward/prompt_interface.rb +93 -21
  45. data/lib/kward/prompts/commands.rb +2 -0
  46. data/lib/kward/prompts/templates.rb +11 -6
  47. data/lib/kward/prompts.rb +6 -6
  48. data/lib/kward/rpc/server.rb +1 -0
  49. data/lib/kward/session_store.rb +3 -2
  50. data/lib/kward/skills/registry.rb +67 -12
  51. data/lib/kward/skills/trust_coordinator.rb +45 -0
  52. data/lib/kward/skills/trust_store.rb +107 -0
  53. data/lib/kward/telemetry/logger.rb +5 -2
  54. data/lib/kward/version.rb +1 -1
  55. metadata +5 -1
@@ -4,10 +4,21 @@ module Kward
4
4
  class CLI
5
5
  # Shared runtime construction helpers for CLI conversations, workspaces, plugins, and sessions.
6
6
  module RuntimeHelpers
7
+ MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES = 1_048_576
8
+ UNSAFE_TRANSCRIPT_CONTROL_PATTERN = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.freeze
9
+ FROZEN_COMPOSER_COMMANDS = /\Agit\s+(?:fetch|ls-remote|push|remote|status)(?:\s|\z)/.freeze
10
+
7
11
  private
8
12
 
9
13
  def new_conversation(workspace_root: current_workspace_root)
10
- Conversation.new(workspace_root: workspace_root, provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort, plugin_registry: plugin_registry)
14
+ Conversation.new(
15
+ workspace_root: workspace_root,
16
+ provider: current_model_provider,
17
+ model: current_model_id,
18
+ reasoning_effort: current_reasoning_effort,
19
+ plugin_registry: plugin_registry,
20
+ project_skill_paths: project_skill_paths_for(workspace_root)
21
+ )
11
22
  end
12
23
 
13
24
  def update_assistant_prompt(conversation)
@@ -50,6 +61,7 @@ module Kward
50
61
  tool_registry = ToolRegistry.new(
51
62
  workspace: workspace,
52
63
  prompt: @prompt,
64
+ skills: ConfigFiles.skills(workspace_root: conversation.workspace_root, project_skill_paths: project_skill_paths_for(conversation.workspace_root)),
53
65
  tool_approval: interactive_tool_approval_callback,
54
66
  hook_manager: hook_manager,
55
67
  hook_context: hook_context
@@ -60,6 +72,7 @@ module Kward
60
72
  client: @client,
61
73
  tool_registry: tool_registry,
62
74
  conversation: conversation,
75
+ warning_sink: ConfigFiles.warning_sink,
63
76
  hook_manager: hook_manager,
64
77
  hook_context: hook_context
65
78
  )
@@ -85,17 +98,90 @@ module Kward
85
98
  return true
86
99
  end
87
100
 
101
+ shell = bang_shell(agent)
102
+ editor_result = shell.editor_command_result(command)
103
+ if editor_result
104
+ @prompt.say(editor_result.output) unless editor_result.output.to_s.empty?
105
+ open_ekwsh_editor(editor_result.open_editor_path, shell) if editor_result.open_editor_path
106
+ return true
107
+ end
108
+
109
+ expanded_command = shell.expand_alias(command, interactive: true)
110
+ run_user_interactive_pty_command(
111
+ expanded_command,
112
+ shell: Ekwsh::DEFAULT_SHELL,
113
+ env: interactive_pty_environment({}, preserve_git_pager: true),
114
+ cwd: interactive_workspace_root(agent),
115
+ intro: "$ #{command}\n"
116
+ )
117
+ true
118
+ end
119
+
120
+ def run_captured_shell_command(command, agent)
121
+ command = command.to_s.strip
122
+ if command.empty?
123
+ runtime_output("Usage: /capture <command>")
124
+ return
125
+ end
126
+
88
127
  run_busy_local_command_and_requeue(activity: "running") do
89
- result = configured_workspace(root: interactive_workspace_root(agent)).run_shell_command(command)
128
+ result = Workspace.new(root: interactive_workspace_root(agent)).run_shell_command(command)
90
129
  @prompt.say("\n#{colored("Shell>", :cyan, :bold)} #{command}\n#{result}\n")
91
130
  end
92
- true
93
131
  end
94
132
 
95
133
  def shell_command_input?(input)
96
134
  input.to_s.start_with?("!")
97
135
  end
98
136
 
137
+ def complete_bang_command(input, cursor, agent)
138
+ value = input.to_s
139
+ return false unless value.start_with?("!")
140
+ return nil if cursor.to_i <= 1
141
+
142
+ completion = bang_shell(agent).complete(value[1..], cursor.to_i - 1)
143
+ return nil unless completion
144
+
145
+ Ekwsh::Completion.new(
146
+ range: (completion.range.begin + 1)...(completion.range.end + 1),
147
+ replacement: completion.replacement,
148
+ candidates: completion.candidates
149
+ )
150
+ end
151
+
152
+ def bang_shell(agent)
153
+ root = File.expand_path(interactive_workspace_root(agent).to_s)
154
+ aliases = ConfigFiles.read_ekwsh_config[:aliases]
155
+ cache_key = [root, ENV.fetch("PATH", ""), aliases.sort]
156
+ return @bang_shell if @bang_shell_key == cache_key
157
+
158
+ @bang_shell_key = cache_key
159
+ @bang_shell = Ekwsh.new(cwd: root, env: ENV.to_h, aliases: aliases)
160
+ end
161
+
162
+ def install_bang_completion_provider(agent)
163
+ return unless @prompt.respond_to?(:update_completion_provider)
164
+
165
+ @bang_completion_provider_installed = true
166
+ provider = lambda do |input, cursor|
167
+ current_agent = if respond_to?(:active_tab, true) && active_tab&.agent
168
+ active_tab.agent
169
+ else
170
+ agent
171
+ end
172
+ complete_bang_command(input, cursor, current_agent)
173
+ end
174
+ @prompt.update_completion_provider(provider)
175
+ end
176
+
177
+ def clear_bang_completion_provider
178
+ return unless @bang_completion_provider_installed
179
+ return unless @prompt.respond_to?(:update_completion_provider)
180
+
181
+ @prompt.update_completion_provider(nil)
182
+ @bang_completion_provider_installed = false
183
+ end
184
+
99
185
  def run_ekwsh(agent)
100
186
  unless @prompt.respond_to?(:ask)
101
187
  runtime_output("The embedded shell is only available in interactive mode.")
@@ -139,29 +225,82 @@ module Kward
139
225
  end
140
226
 
141
227
  config = ConfigFiles.read_ekwsh_config
142
- env = interactive_pty_environment(config[:env])
143
- cwd = interactive_workspace_root(agent)
144
- @prompt.say("$ #{command}\n[interactive PTY session started]\n") if @prompt.respond_to?(:say)
145
- result = run_interactive_pty_with_terminal_handoff(config[:shell], command, env: env, cwd: cwd)
146
- @prompt.say("[interactive PTY session exited with status #{result.exit_status}]\n") if @prompt.respond_to?(:say)
228
+ run_user_interactive_pty_command(
229
+ command,
230
+ shell: config[:shell],
231
+ env: interactive_pty_environment(config[:env]),
232
+ cwd: interactive_workspace_root(agent)
233
+ )
234
+ end
235
+
236
+ def run_user_interactive_pty_command(command, shell:, env:, cwd:, intro: nil)
237
+ intro_message = intro || "$ #{command}\n"
238
+ if @prompt.respond_to?(:write_transcript)
239
+ @prompt.write_transcript(intro_message)
240
+ elsif @prompt.respond_to?(:say)
241
+ @prompt.say(intro_message)
242
+ end
243
+ output = +"".b
244
+ output_truncated = false
245
+ handoff_options = {}
246
+ handoff_options[:preserve_composer] = true if frozen_composer_command?(command)
247
+ result = run_interactive_pty_with_terminal_handoff(shell, command, env: env, cwd: cwd, **handoff_options) do |chunk|
248
+ remaining = MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES - output.bytesize
249
+ if chunk.bytesize > remaining
250
+ output << chunk.byteslice(0, remaining) if remaining.positive?
251
+ output_truncated = true
252
+ else
253
+ output << chunk
254
+ end
255
+ end
256
+ @prompt.refresh_composer_status if @prompt.respond_to?(:refresh_composer_status)
257
+ transcript_output = terminal_transcript_output(output, result, truncated: output_truncated)
258
+ if transcript_output && @prompt.respond_to?(:record_transient_terminal_output)
259
+ @prompt.record_transient_terminal_output(transcript_output)
260
+ end
261
+ result
147
262
  rescue Errno::ENOENT => e
148
263
  runtime_output("Error: #{e.message}")
264
+ nil
149
265
  end
150
266
 
151
- def run_interactive_pty_with_terminal_handoff(shell, command, env:, cwd:)
267
+ def run_interactive_pty_with_terminal_handoff(shell, command, env:, cwd:, preserve_composer: false, &block)
152
268
  runner = InteractivePtyRunner.new
153
269
  if @prompt.respond_to?(:with_terminal_handoff)
154
- @prompt.with_terminal_handoff do |input, output|
155
- runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, output: output)
270
+ if preserve_composer
271
+ @prompt.with_terminal_handoff(preserve_composer: true) do |input, output|
272
+ runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, output: output, &block)
273
+ end
274
+ else
275
+ @prompt.with_terminal_handoff do |input, output|
276
+ runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, output: output, &block)
277
+ end
156
278
  end
157
279
  else
158
- runner.run(shell, "-c", command, env: env, cwd: cwd)
280
+ runner.run(shell, "-c", command, env: env, cwd: cwd, &block)
159
281
  end
160
282
  end
161
283
 
162
- def interactive_pty_environment(configured_env)
284
+ def frozen_composer_command?(command)
285
+ FROZEN_COMPOSER_COMMANDS.match?(command.to_s.strip)
286
+ end
287
+
288
+ def terminal_transcript_output(output, result, truncated:)
289
+ return if truncated || result.input_forwarded
290
+
291
+ text = ANSI.normalize_transcript_encoding(output).gsub("\r\n", "\n")
292
+ return if text.empty? || text.include?("\r")
293
+
294
+ sanitized = ANSI.sanitize_transcript(text)
295
+ return unless sanitized == text
296
+ return if ANSI.strip_control_sequences(text).match?(UNSAFE_TRANSCRIPT_CONTROL_PATTERN)
297
+
298
+ sanitized
299
+ end
300
+
301
+ def interactive_pty_environment(configured_env, preserve_git_pager: false)
163
302
  ENV.to_h.merge(configured_env.to_h.transform_keys(&:to_s).transform_values(&:to_s)).tap do |env|
164
- env.delete("GIT_PAGER") if env["GIT_PAGER"] == "cat"
303
+ env.delete("GIT_PAGER") if !preserve_git_pager && env["GIT_PAGER"] == "cat"
165
304
  env["TERM"] = "xterm-256color" if env["TERM"].to_s.empty? || env["TERM"] == "dumb"
166
305
  end
167
306
  end
@@ -248,14 +387,13 @@ module Kward
248
387
  end
249
388
 
250
389
  def run_ekwsh_interactive_pty_command(shell, result)
251
- @prompt.say(result.output) unless result.output.to_s.empty?
252
- pty_result = run_interactive_pty_with_terminal_handoff(
253
- shell.command_shell,
390
+ run_user_interactive_pty_command(
254
391
  result.interactive_command,
392
+ shell: shell.command_shell,
255
393
  env: shell.child_env(interactive: true),
256
- cwd: shell.cwd
394
+ cwd: shell.cwd,
395
+ intro: result.output
257
396
  )
258
- @prompt.say("[interactive PTY session exited with status #{pty_result.exit_status}]\n") if @prompt.respond_to?(:say)
259
397
  end
260
398
 
261
399
  def run_ekwsh_command(shell, input)
@@ -21,7 +21,7 @@ module Kward
21
21
  path = session_store.remembered_last_session_path if session_store.respond_to?(:remembered_last_session_path)
22
22
  return nil if path.to_s.empty?
23
23
 
24
- @active_session, conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
24
+ @active_session, conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort, project_skill_paths: project_skill_paths_for(session_store.cwd) || [])
25
25
  reset_session_diff(@active_session.path)
26
26
  track_session(@active_session)
27
27
  @resumed_last_session = true
@@ -99,6 +99,7 @@ module Kward
99
99
  def start_new_session(session_store)
100
100
  return say_sessions_unavailable unless session_store
101
101
 
102
+ prepare_interactive_project_skills if respond_to?(:prepare_interactive_project_skills, true)
102
103
  previous_session = @active_session
103
104
  @active_session = track_session(session_store.create)
104
105
  reset_session_diff
@@ -127,7 +128,7 @@ module Kward
127
128
 
128
129
  def load_session(session_store, path, message: nil)
129
130
  previous_session = @active_session
130
- @active_session, conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
131
+ @active_session, conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort, project_skill_paths: project_skill_paths_for(session_store.cwd) || [])
131
132
  reset_session_diff(@active_session.path)
132
133
  track_session(@active_session)
133
134
  cleanup_replaced_session(previous_session)
@@ -396,7 +397,8 @@ module Kward
396
397
  workspace: configured_workspace(root: session_store.cwd),
397
398
  provider: current_model_provider,
398
399
  model: current_model_id,
399
- reasoning_effort: current_reasoning_effort
400
+ reasoning_effort: current_reasoning_effort,
401
+ project_skill_paths: project_skill_paths_for(session_store.cwd) || []
400
402
  )
401
403
  reset_session_diff(@active_session.path)
402
404
  track_session(@active_session)
@@ -579,7 +581,7 @@ module Kward
579
581
  end
580
582
 
581
583
  def clone_session_file_from_path(session_store, path)
582
- source_session, source_conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
584
+ source_session, source_conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort, project_skill_paths: project_skill_paths_for(session_store.cwd) || [])
583
585
  clone, = session_store.create_independent_from_conversation(source_conversation, parent_session: source_session)
584
586
  clone.path
585
587
  end
@@ -956,7 +956,7 @@ module Kward
956
956
  refresh_reasoning_status
957
957
  else
958
958
  refresh_conversation_runtime(conversation, reasoning_effort: effort)
959
- @prompt.redraw if @prompt.respond_to?(:redraw)
959
+ refresh_reasoning_status
960
960
  end
961
961
  end
962
962
 
@@ -32,6 +32,9 @@ module Kward
32
32
  run_busy_local_command_and_requeue { activate_skill_command(argument, agent) }
33
33
  end
34
34
  [true, nil]
35
+ when "skills"
36
+ run_busy_local_command_and_requeue { handle_project_skills_command(argument) }
37
+ [true, nil]
35
38
  when "redraw"
36
39
  run_busy_local_command_and_requeue { @prompt.redraw if @prompt.respond_to?(:redraw) }
37
40
  [true, nil]
@@ -47,6 +50,9 @@ module Kward
47
50
  when "shell"
48
51
  run_ekwsh(agent)
49
52
  [true, nil]
53
+ when "capture"
54
+ run_captured_shell_command(argument, agent)
55
+ [true, nil]
50
56
  when "scratchpad"
51
57
  open_scratchpad_command(argument)
52
58
  [true, nil]
@@ -345,7 +351,7 @@ module Kward
345
351
  percent = ((reserve_tokens.to_f / context_window.to_i) * 100).round(1)
346
352
  "Auto-compaction reserve: #{reserve_tokens} tokens (#{percent}% of #{context_window})"
347
353
  rescue StandardError => e
348
- warn "Auto-compaction status unavailable: #{e.message}"
354
+ emit_warning "Auto-compaction status unavailable: #{e.message}"
349
355
  nil
350
356
  end
351
357
 
@@ -194,7 +194,7 @@ module Kward
194
194
 
195
195
  def restore_tab_session(session_store, path, workspace_root: session_store.cwd, strict: false)
196
196
  if File.file?(path)
197
- session, conversation = session_store.load(path, workspace: configured_workspace(root: workspace_root, strict: strict), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
197
+ session, conversation = session_store.load(path, workspace: configured_workspace(root: workspace_root, strict: strict), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort, project_skill_paths: project_skill_paths_for(workspace_root) || [])
198
198
  return [track_session(session), conversation]
199
199
  end
200
200
 
@@ -215,9 +215,11 @@ module Kward
215
215
  git_committer = if strict
216
216
  ->(message:, paths:) { git_commit_for_agent(workspace.root.to_s, message: message, paths: paths) }
217
217
  end
218
+ project_skill_paths = project_skill_paths_for(conversation.workspace_root) || []
218
219
  tool_registry = ToolRegistry.new(
219
220
  workspace: workspace,
220
221
  prompt: prompt,
222
+ skills: ConfigFiles.skills(workspace_root: conversation.workspace_root, project_skill_paths: project_skill_paths),
221
223
  tool_approval: tab_tool_approval_callback(prompt),
222
224
  hook_manager: hook_manager,
223
225
  hook_context: hook_context,
@@ -230,6 +232,7 @@ module Kward
230
232
  client: @client,
231
233
  tool_registry: tool_registry,
232
234
  conversation: conversation,
235
+ warning_sink: ConfigFiles.warning_sink,
233
236
  hook_manager: hook_manager,
234
237
  hook_context: hook_context
235
238
  )
data/lib/kward/cli.rb CHANGED
@@ -59,6 +59,8 @@ require_relative "cli/sysprompt"
59
59
  require_relative "cli/stats"
60
60
  require_relative "cli/openrouter_commands"
61
61
  require_relative "cli/runtime_helpers"
62
+ require_relative "cli/project_skills"
63
+ require_relative "cli/project_skills_commands"
62
64
  require_relative "cli/slash_commands"
63
65
  require_relative "cli/memory_commands"
64
66
  require_relative "cli/hook_commands"
@@ -94,6 +96,8 @@ module Kward
94
96
  include CLI::Stats
95
97
  include CLI::OpenRouterCommands
96
98
  include CLI::RuntimeHelpers
99
+ include CLI::ProjectSkills
100
+ include CLI::ProjectSkillsCommands
97
101
  include CLI::SlashCommands
98
102
  include CLI::MemoryCommands
99
103
  include CLI::HookCommands
@@ -232,6 +236,16 @@ module Kward
232
236
  return
233
237
  end
234
238
 
239
+ if @argv.first == "skills"
240
+ if help_option_arguments?(@argv[1..] || [])
241
+ print_command_help("skills")
242
+ return
243
+ end
244
+
245
+ handle_project_skills_cli_command(@argv[1..] || [])
246
+ return
247
+ end
248
+
235
249
  if @argv.first == "edit"
236
250
  if help_option_arguments?(@argv[1..] || [])
237
251
  print_command_help("edit")
@@ -333,6 +347,7 @@ module Kward
333
347
  @prompt.edit_file(path, base_dir: Dir.pwd, allow_new: true)
334
348
  ensure
335
349
  @prompt.close if @prompt.respond_to?(:close) && prompt_interface?
350
+ clear_interactive_warning_sink if respond_to?(:clear_interactive_warning_sink, true)
336
351
  end
337
352
 
338
353
  def run_prompt_or_interactive
@@ -429,7 +444,8 @@ module Kward
429
444
  end
430
445
 
431
446
  def interactive_loop(agent: nil)
432
- setup_interactive_prompt
447
+ setup_interactive_prompt(defer_warnings: true)
448
+ prepare_interactive_project_skills
433
449
  session_store = interactive_session_store(agent)
434
450
  @resumed_last_session = false
435
451
  if session_store && @prompt.respond_to?(:update_tabs)
@@ -449,8 +465,10 @@ module Kward
449
465
 
450
466
  print_visual_banner unless @resumed_last_session || @restored_tabs
451
467
  render_resumed_last_session_transcript(agent.conversation) if @resumed_last_session
468
+ enable_interactive_warnings if respond_to?(:enable_interactive_warnings, true)
452
469
 
453
470
  @pending_inputs = []
471
+ install_bang_completion_provider(agent)
454
472
 
455
473
  loop do
456
474
  if @pending_inputs.empty? && active_tab&.shell
@@ -542,10 +560,12 @@ module Kward
542
560
  runtime_output("Goodbye.")
543
561
  active_tab&.agent&.conversation || agent&.conversation
544
562
  ensure
563
+ clear_bang_completion_provider
545
564
  begin
546
565
  stop_tabs if respond_to?(:stop_tabs, true)
547
566
  @prompt.close if prompt_interface?
548
567
  ensure
568
+ clear_interactive_warning_sink if respond_to?(:clear_interactive_warning_sink, true)
549
569
  cleanup_unused_sessions
550
570
  remember_active_session(session_store)
551
571
  end
@@ -734,9 +734,10 @@ module Kward
734
734
  AUTO_COMPACTION_EXTRA_GUARD_FLOOR = 12_000
735
735
 
736
736
  # Creates an object for conversation compaction.
737
- def initialize(conversation:, client:, tool_result_summarizer: nil, settings: nil, summarizer: nil)
737
+ def initialize(conversation:, client:, tool_result_summarizer: nil, settings: nil, summarizer: nil, warning_sink: nil)
738
738
  @conversation = conversation
739
739
  @client = client
740
+ @warning_sink = warning_sink
740
741
  @settings = settings || Compaction::Settings.from_config
741
742
  @prompt_builder = Compaction::PromptBuilder.new(
742
743
  serializer: Compaction::ConversationSerializer.new(tool_result_summarizer: tool_result_summarizer)
@@ -791,7 +792,7 @@ module Kward
791
792
  rescue Compaction::NothingToCompact, Compaction::AlreadyCompacted
792
793
  nil
793
794
  rescue StandardError => e
794
- warn "Auto-compaction failed: #{e.message}"
795
+ emit_warning "Auto-compaction failed: #{e.message}"
795
796
  nil
796
797
  end
797
798
 
@@ -814,6 +815,10 @@ module Kward
814
815
 
815
816
  private
816
817
 
818
+ def emit_warning(message)
819
+ @warning_sink ? @warning_sink.call(message) : warn(message)
820
+ end
821
+
817
822
  def prepare
818
823
  @conversation.refresh_system_message_if_workspace_agents_changed!
819
824
  Compaction::Preparation.new(conversation: @conversation, settings: @settings).call
@@ -66,6 +66,7 @@ module Kward
66
66
  end
67
67
 
68
68
  @skip_config = false
69
+ @warning_sink = nil
69
70
 
70
71
  module_function
71
72
 
@@ -73,6 +74,14 @@ module Kward
73
74
  @skip_config = value
74
75
  end
75
76
 
77
+ def warning_sink=(sink)
78
+ @warning_sink = sink
79
+ end
80
+
81
+ def warning_sink
82
+ @warning_sink
83
+ end
84
+
76
85
  def skip_config?
77
86
  @skip_config == true
78
87
  end
@@ -791,13 +800,13 @@ module Kward
791
800
 
792
801
  size = File.size(path)
793
802
  if size > MAX_PROMPT_FILE_BYTES
794
- warn "Warning: skipping #{label} #{path}: file too large (#{size} bytes; limit is #{MAX_PROMPT_FILE_BYTES} bytes)"
803
+ emit_warning "Warning: skipping #{label} #{path}: file too large (#{size} bytes; limit is #{MAX_PROMPT_FILE_BYTES} bytes)"
795
804
  return nil
796
805
  end
797
806
 
798
807
  File.read(path)
799
808
  rescue StandardError => e
800
- warn "Warning: skipping #{label} #{path}: #{e.message}"
809
+ emit_warning "Warning: skipping #{label} #{path}: #{e.message}"
801
810
  nil
802
811
  end
803
812
 
@@ -908,8 +917,12 @@ module Kward
908
917
  # Lists configured skills discovered under the config directory.
909
918
  #
910
919
  # @return [Array<Skill>] skill metadata available to the model
911
- def skills(workspace_root: Dir.pwd)
912
- skills_registry(workspace_root: workspace_root).skills
920
+ def skills(workspace_root: Dir.pwd, warning_sink: nil, project_skill_paths: nil)
921
+ skills_registry(workspace_root: workspace_root, warning_sink: warning_sink, project_skill_paths: project_skill_paths).skills
922
+ end
923
+
924
+ def project_skill_candidates(workspace_root: Dir.pwd, warning_sink: nil)
925
+ skills_registry(workspace_root: workspace_root, warning_sink: warning_sink).project_skill_candidates
913
926
  end
914
927
 
915
928
  # @return [String] trusted user plugin directory
@@ -923,7 +936,7 @@ module Kward
923
936
  # workspace or custom `KWARD_CONFIG_PATH` directory.
924
937
  #
925
938
  # @return [Array<String>] sorted plugin file paths
926
- def plugin_paths
939
+ def plugin_paths(warning_sink: nil)
927
940
  plugins_root = plugin_dir
928
941
  return [] unless Dir.exist?(plugins_root)
929
942
 
@@ -931,7 +944,7 @@ module Kward
931
944
  paths.concat(Dir.glob(File.join(plugins_root, "*", "plugin.rb")))
932
945
  paths.sort
933
946
  rescue StandardError => e
934
- warn "Warning: skipping Kward plugins in #{plugins_root}: #{e.message}"
947
+ emit_warning("Warning: skipping Kward plugins in #{plugins_root}: #{e.message}", warning_sink: warning_sink)
935
948
  []
936
949
  end
937
950
 
@@ -952,7 +965,7 @@ module Kward
952
965
  skills_registry(workspace_root: workspace_root).read_skill_file(name, relative_path)
953
966
  end
954
967
 
955
- def skills_registry(workspace_root: Dir.pwd)
968
+ def skills_registry(workspace_root: Dir.pwd, warning_sink: nil, project_skill_paths: nil)
956
969
  Skills::Registry.new(
957
970
  config_dir: config_dir,
958
971
  workspace_root: workspace_root,
@@ -960,7 +973,9 @@ module Kward
960
973
  skill_class: Skill,
961
974
  max_file_bytes: MAX_SKILL_FILE_BYTES,
962
975
  markdown_parser: ->(path) { Frontmatter.markdown_parts(path, lenient: true) },
963
- inside_directory: method(:inside_directory?)
976
+ inside_directory: method(:inside_directory?),
977
+ warning_sink: warning_sink || @warning_sink,
978
+ project_skill_paths: project_skill_paths
964
979
  )
965
980
  end
966
981
 
@@ -968,10 +983,16 @@ module Kward
968
983
  Prompts::Templates.new(
969
984
  config_dir: config_dir,
970
985
  template_class: PromptTemplate,
971
- markdown_parser: ->(path) { Frontmatter.markdown_parts(path) }
986
+ markdown_parser: ->(path) { Frontmatter.markdown_parts(path) },
987
+ warning_sink: @warning_sink
972
988
  )
973
989
  end
974
990
 
991
+ def emit_warning(message, warning_sink: nil)
992
+ sink = warning_sink || @warning_sink
993
+ sink ? sink.call(message) : warn(message)
994
+ end
995
+
975
996
  def inside_directory?(path, base)
976
997
  PathGuard.inside?(path, base)
977
998
  end
@@ -63,13 +63,16 @@ module Kward
63
63
  attr_reader :tool_output_artifacts
64
64
  # @return [ContextBudgetMeter] runtime context savings for this conversation
65
65
  attr_reader :context_budget_meter
66
+ # @return [Array<String>, nil] explicitly permitted project skill paths
67
+ attr_reader :project_skill_paths
66
68
 
67
- def initialize(system_message: DEFAULT_SYSTEM_MESSAGE, messages: [], read_paths: [], on_append: nil, on_compact: nil, on_tool_execution: nil, on_runtime_update: nil, workspace_root: Dir.pwd, compaction_system_message: DEFAULT_SYSTEM_MESSAGE, provider: nil, model: nil, reasoning_effort: nil, memory_context: nil, session_memories: [], last_memory_retrieval: nil, plugin_registry: nil, execution_profile_context: nil)
69
+ def initialize(system_message: DEFAULT_SYSTEM_MESSAGE, messages: [], read_paths: [], on_append: nil, on_compact: nil, on_tool_execution: nil, on_runtime_update: nil, workspace_root: Dir.pwd, compaction_system_message: DEFAULT_SYSTEM_MESSAGE, provider: nil, model: nil, reasoning_effort: nil, memory_context: nil, session_memories: [], last_memory_retrieval: nil, plugin_registry: nil, execution_profile_context: nil, project_skill_paths: nil)
68
70
  @workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
69
71
  @provider = provider
70
72
  @model = model
71
73
  @reasoning_effort = reasoning_effort
72
74
  @plugin_registry = plugin_registry
75
+ @project_skill_paths = project_skill_paths
73
76
  @execution_profile_context = execution_profile_context.to_s.empty? ? nil : execution_profile_context.to_s.freeze
74
77
  @messages = []
75
78
  restored_system_message, transcript_messages = split_system_message(messages)
@@ -78,13 +81,13 @@ module Kward
78
81
  system_message = restored_system_message
79
82
  else
80
83
  @last_plugin_prompt_context = plugin_prompt_context
81
- system_message = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: memory_context, plugin_context: @last_plugin_prompt_context, execution_profile_context: @execution_profile_context)
84
+ system_message = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: memory_context, plugin_context: @last_plugin_prompt_context, execution_profile_context: @execution_profile_context, project_skill_paths: @project_skill_paths)
82
85
  end
83
86
  end
84
87
  @system_message = system_message
85
88
  @system_message_enabled = !@system_message.nil?
86
89
  if compaction_system_message.equal?(DEFAULT_SYSTEM_MESSAGE)
87
- compaction_system_message = @system_message_enabled ? Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, execution_profile_context: @execution_profile_context) : nil
90
+ compaction_system_message = @system_message_enabled ? Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, execution_profile_context: @execution_profile_context, project_skill_paths: @project_skill_paths) : nil
88
91
  end
89
92
  @compaction_system_message = compaction_system_message
90
93
  @workspace_agents_mtime = workspace_agents_mtime
@@ -188,10 +191,10 @@ module Kward
188
191
  return nil unless @system_message_enabled
189
192
 
190
193
  @last_plugin_prompt_context = plugin_prompt_context
191
- replacement = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: @memory_context, plugin_context: @last_plugin_prompt_context, execution_profile_context: @execution_profile_context)
194
+ replacement = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: @memory_context, plugin_context: @last_plugin_prompt_context, execution_profile_context: @execution_profile_context, project_skill_paths: @project_skill_paths)
192
195
  @system_message = replacement
193
196
  @on_system_message_change&.call(replacement)
194
- @compaction_system_message = Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, execution_profile_context: @execution_profile_context)
197
+ @compaction_system_message = Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, execution_profile_context: @execution_profile_context, project_skill_paths: @project_skill_paths)
195
198
  @workspace_agents_mtime = workspace_agents_mtime
196
199
  @system_prompt_sources_fingerprint = ConfigFiles.system_prompt_sources_fingerprint
197
200
  replacement