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
@@ -36,6 +36,9 @@ module Kward
36
36
  return true if handle_mouse_reporting_key(key)
37
37
  return if handle_bracketed_paste_key(key)
38
38
 
39
+ completion_overlay_result = handle_completion_overlay_key(key)
40
+ return completion_overlay_result unless completion_overlay_result == false
41
+
39
42
  csi_result = handle_csi_u_key(key)
40
43
  return csi_result unless csi_result == false
41
44
  return if handle_shift_enter_key(key)
@@ -44,9 +47,6 @@ module Kward
44
47
  completion_result = handle_completion_provider_key(key)
45
48
  return completion_result unless completion_result == false
46
49
 
47
- reasoning_result = handle_reasoning_key_binding(key)
48
- return reasoning_result unless reasoning_result == false
49
-
50
50
  tab_result = handle_tab_key_binding(key)
51
51
  return tab_result unless tab_result == false
52
52
 
@@ -81,7 +81,7 @@ module Kward
81
81
  when :ctrl_y
82
82
  yank_kill_buffer
83
83
  when :ctrl_l
84
- redraw_screen_locked
84
+ request_transcript_redraw
85
85
  when :ctrl_r
86
86
  start_history_search
87
87
  when :left
@@ -212,9 +212,6 @@ module Kward
212
212
  return true if sequence == "\e" && (dismiss_file_overlay || dismiss_slash_overlay)
213
213
  return true if handle_shift_enter_key(sequence)
214
214
 
215
- reasoning_result = handle_reasoning_key_binding(sequence)
216
- return reasoning_result unless reasoning_result == false
217
-
218
215
  tab_result = handle_tab_key_binding(sequence)
219
216
  return tab_result unless tab_result == false
220
217
 
@@ -323,10 +320,10 @@ module Kward
323
320
  if ctrl_modifier?(modifier)
324
321
  shift_modifier?(modifier) ? { tab_action: :previous } : { tab_action: :next }
325
322
  elsif shift_modifier?(modifier)
326
- handle_reasoning_key_binding(key) || handle_tab_completion_key
323
+ handle_tab_completion_key
327
324
  else
328
325
  completion_result = handle_completion_provider_key("\t")
329
- completion_result == false ? handle_reasoning_key_binding("\t") || handle_tab_completion_key : completion_result
326
+ completion_result == false ? handle_tab_completion_key : completion_result
330
327
  end
331
328
  when 13
332
329
  if modifier == 2
@@ -447,7 +444,7 @@ module Kward
447
444
  when 107
448
445
  kill_line_after_cursor
449
446
  when 108
450
- redraw_screen_locked
447
+ request_transcript_redraw
451
448
  when 114
452
449
  start_history_search
453
450
  when 117
@@ -580,61 +577,99 @@ module Kward
580
577
 
581
578
  CTRL_TAB_SEQUENCES = TerminalKeys::CTRL_TAB
582
579
  CTRL_SHIFT_TAB_SEQUENCES = TerminalKeys::CTRL_SHIFT_TAB
583
- SHIFT_TAB_SEQUENCES = TerminalKeys::SHIFT_TAB
580
+
581
+ def handle_completion_overlay_key(key)
582
+ return false unless @completion_overlay
583
+
584
+ return handle_completion_provider_key("\t") if completion_tab_key?(key)
585
+
586
+ dismiss_completion_overlay
587
+ false
588
+ end
589
+
590
+ def completion_tab_key?(key)
591
+ return true if key == "\t"
592
+
593
+ sequence = parse_csi_u_key(key)
594
+ return false unless sequence && sequence[:code] == 9
595
+
596
+ completion = !ctrl_modifier?(sequence[:modifier]) && !shift_modifier?(sequence[:modifier])
597
+ queue_pending_keys(sequence[:remaining]) if completion && sequence[:remaining] && !sequence[:remaining].empty?
598
+ completion
599
+ end
600
+
601
+ def dismiss_completion_overlay
602
+ @completion_overlay = nil
603
+ @completion_cycle = nil
604
+ end
584
605
 
585
606
  def handle_completion_provider_key(key)
586
607
  return false unless key == "\t" && @completion_provider
587
608
 
609
+ if (cycle = matching_completion_cycle)
610
+ cycle_completion(cycle)
611
+ return true
612
+ end
613
+
588
614
  result = @completion_provider.call(composer_input, composer_cursor)
615
+ return false if result == false
589
616
  return true unless result
590
617
 
591
618
  apply_completion_result(result)
592
619
  true
593
620
  end
594
621
 
622
+ def matching_completion_cycle
623
+ cycle = @completion_cycle
624
+ return nil unless cycle
625
+ return cycle if cycle[:input] == composer_input && cycle[:cursor] == composer_cursor
626
+
627
+ @completion_cycle = nil
628
+ @completion_overlay = nil
629
+ nil
630
+ end
631
+
632
+ def cycle_completion(cycle)
633
+ cycle[:index] = (cycle[:index] + 1) % cycle[:candidates].length
634
+ apply_completion_cycle_candidate(cycle)
635
+ end
636
+
595
637
  def apply_completion_result(result)
596
638
  range = result[:range] || result["range"] || result.range
597
639
  replacement = result[:replacement] || result["replacement"] || result.replacement
598
- candidates = result[:candidates] || result["candidates"] || result.candidates
640
+ candidates = (result[:candidates] || result["candidates"] || result.candidates).to_a
599
641
  original = composer_input
600
642
  before = original[0...range.begin].to_s
601
643
  after = original[range.end..].to_s
644
+
645
+ if candidates.length > 1
646
+ @completion_cycle = {
647
+ candidates: candidates.map(&:to_s),
648
+ index: 0,
649
+ before: before,
650
+ after: after
651
+ }
652
+ apply_completion_cycle_candidate(@completion_cycle)
653
+ show_completion_overlay(candidates)
654
+ return
655
+ end
656
+
657
+ @completion_cycle = nil
602
658
  self.composer_input = "#{before}#{replacement}#{after}"
603
659
  self.composer_cursor = before.length + replacement.to_s.length
604
- show_completion_candidates(candidates, replacement) if candidates.to_a.length > 1 && replacement.to_s == original[range]
605
660
  end
606
661
 
607
- def show_completion_candidates(candidates, replacement)
608
- lines = candidates.to_a.first(40)
609
- text = ["completions:", *lines.map { |candidate| " #{candidate}" }].join("\n")
610
- write_completion_transcript_locked(text)
662
+ def show_completion_overlay(candidates)
663
+ @completion_overlay = { candidates: candidates.map(&:to_s), index: 0 }
611
664
  end
612
665
 
613
- def write_completion_transcript_locked(text)
614
- with_synchronized_output_locked do
615
- clear_prompt_for_output_locked
616
- write_transcript_text_locked("\n#{text}\n")
617
- render_prompt_after_output_locked
618
- end
619
- end
620
-
621
- def handle_reasoning_key_binding(key)
622
- return false if @busy || @select_state || @question_state
623
- return false if file_overlay_visible? || slash_overlay_visible?
624
- return false if @slash_overlay_dismissed_input && @slash_overlay_dismissed_input == composer_input
625
- mention_token = active_file_mention_token
626
- open_token = active_file_open_token
627
- return false if mention_token && @file_overlay_dismissed_token == mention_token
628
- return false if open_token && @file_open_dismissed_token == open_token
629
-
630
- case key
631
- when "\t"
632
- { reasoning_action: :next }
633
- when *SHIFT_TAB_SEQUENCES
634
- { reasoning_action: :previous }
635
- else
636
- false
637
- end
666
+ def apply_completion_cycle_candidate(cycle)
667
+ replacement = cycle[:candidates][cycle[:index]]
668
+ self.composer_input = "#{cycle[:before]}#{replacement}#{cycle[:after]}"
669
+ self.composer_cursor = cycle[:before].length + replacement.length
670
+ @completion_overlay[:index] = cycle[:index] if @completion_overlay
671
+ cycle[:input] = composer_input
672
+ cycle[:cursor] = composer_cursor
638
673
  end
639
674
 
640
675
  def handle_tab_key_binding(key)
@@ -710,7 +745,7 @@ module Kward
710
745
  when TerminalKeys::CTRL_K
711
746
  kill_line_after_cursor
712
747
  when TerminalKeys::CTRL_L
713
- redraw_screen_locked
748
+ request_transcript_redraw
714
749
  when TerminalKeys::CTRL_U
715
750
  kill_line_before_cursor
716
751
  when TerminalKeys::CTRL_W
@@ -11,12 +11,24 @@ module Kward
11
11
  return selection_overlay_rows(width, height: height) if @select_state
12
12
  return git_overlay_rows(width, height: height) if @git_state
13
13
  return project_browser_rows(width, height: height) if project_browser_visible?
14
+ return completion_overlay_rows(width, height: height) if @completion_overlay
14
15
  return history_search_overlay_rows(width, height: height) if history_search_active?
15
16
  return file_overlay_rows(width, height: height) if file_overlay_visible?
16
17
 
17
18
  slash_overlay_rows(width, height: height)
18
19
  end
19
20
 
21
+ def completion_overlay_rows(width, height: screen_height)
22
+ candidates = @completion_overlay[:candidates]
23
+ max_rows = max_overlay_list_rows(height)
24
+ selected = @completion_overlay[:index].to_i
25
+ start = centered_list_window_start(selected, candidates.length, max_rows)
26
+ rows = (candidates[start, max_rows] || []).each_with_index.map do |candidate, offset|
27
+ overlay_choice_line(candidate, selected: start + offset == selected)
28
+ end
29
+ overlay_card_rows("Completions", rows, width)
30
+ end
31
+
20
32
  def history_search_overlay_rows(width, height: screen_height)
21
33
  matches = history_search_matches
22
34
  if matches.empty?
@@ -87,6 +87,11 @@ module Kward
87
87
  ensure_scroll_region_locked(rows.length, width: width, height: height)
88
88
  end
89
89
 
90
+ def make_room_for_composer_after_handoff_locked
91
+ rows, = composer_layout(screen_width, screen_height)
92
+ @output_io.print("\r\n" * rows.length)
93
+ end
94
+
90
95
  def ensure_scroll_region_locked(row_count, redraw_transcript: true, width: screen_width, height: screen_height)
91
96
  new_reserved_rows = [[row_count, 1].max, [height - 1, 1].max].min
92
97
  return if @reserved_rows == new_reserved_rows && @last_height == height
@@ -128,7 +128,7 @@ module Kward
128
128
  end
129
129
  end
130
130
 
131
- def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, project_browser_icon_theme: "off", footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil, banner_message: nil, tab_keybindings: nil, prompt_history: nil, workspace_root: Dir.pwd, editor_mode: nil, editor_mode_source: nil, editor_auto_indent: true, editor_auto_indent_source: nil, editor_auto_close_pairs: true, editor_auto_close_pairs_source: nil, editor_soft_wrap: true, editor_soft_wrap_source: nil, editor_bar_cursor: true, editor_bar_cursor_source: nil, editor_line_numbers: "absolute", editor_line_numbers_source: nil, diff_view: "auto", diff_view_source: nil)
131
+ def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, project_browser_icon_theme: "off", footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil, banner_message: nil, tab_keybindings: nil, prompt_history: nil, workspace_root: Dir.pwd, editor_mode: nil, editor_mode_source: nil, editor_auto_indent: true, editor_auto_indent_source: nil, editor_auto_close_pairs: true, editor_auto_close_pairs_source: nil, editor_soft_wrap: true, editor_soft_wrap_source: nil, editor_bar_cursor: true, editor_bar_cursor_source: nil, editor_line_numbers: "absolute", editor_line_numbers_source: nil, diff_view: "auto", diff_view_source: nil, redraw_handler: nil)
132
132
  @input_io = input
133
133
  @output_io = output
134
134
  @reader = TTY::Reader.new(input: input, output: output, interrupt: :error)
@@ -162,6 +162,10 @@ module Kward
162
162
  @restoring_transcript = false
163
163
  @pending_keys = []
164
164
  @completion_provider = nil
165
+ @completion_cycle = nil
166
+ @completion_overlay = nil
167
+ @redraw_handler = redraw_handler
168
+ @transcript_redraw_requested = false
165
169
  @original_console_mode = nil
166
170
  @raw_mode_active = false
167
171
  @slash_commands = normalize_slash_commands(slash_commands)
@@ -258,24 +262,11 @@ module Kward
258
262
  end
259
263
 
260
264
  def say(message)
261
- @mutex.synchronize do
262
- text = message.to_s
263
- if @restoring_transcript
264
- write_transcript_text_locked(text)
265
- write_transcript_text_locked("\n") unless text.end_with?("\n")
266
- @stream_state.finish_block
267
- next
268
- end
265
+ write_transcript_message(message, preserve_composer: false)
266
+ end
269
267
 
270
- with_synchronized_output_locked do
271
- clear_prompt_for_output_locked
272
- write_transcript_text_locked(text)
273
- write_transcript_text_locked("\n") unless text.end_with?("\n")
274
- @stream_state.finish_block
275
- render_prompt_after_output_locked
276
- end
277
- @output_io.flush
278
- end
268
+ def write_transcript(message)
269
+ write_transcript_message(message, preserve_composer: true)
279
270
  end
280
271
 
281
272
  def say_visual(message)
@@ -328,6 +319,16 @@ module Kward
328
319
  @slash_overlay_disabled = previous_slash_overlay_disabled
329
320
  end
330
321
 
322
+ def update_completion_provider(provider, slash_overlay: true)
323
+ @mutex.synchronize do
324
+ @completion_provider = provider
325
+ @completion_cycle = nil
326
+ @completion_overlay = nil
327
+ @slash_overlay_disabled = !slash_overlay
328
+ render_prompt_locked if @started
329
+ end
330
+ end
331
+
331
332
  def with_prompt_history(history)
332
333
  previous_history = @prompt_history
333
334
  @prompt_history = history
@@ -435,6 +436,8 @@ module Kward
435
436
  reset_history_navigation
436
437
  end
437
438
  @pending_keys.clear
439
+ @completion_cycle = nil
440
+ @completion_overlay = nil
438
441
  @asking = true
439
442
  @busy = false
440
443
  @queued_count = 0
@@ -454,11 +457,14 @@ module Kward
454
457
  render_prompt_locked unless result.is_a?(String) || result == EXIT_INPUT || prompt_action_result?(result)
455
458
  end
456
459
  end
460
+ perform_pending_transcript_redraw
457
461
  return result if result.is_a?(String) || prompt_action_result?(result)
458
462
  return nil if result == EXIT_INPUT
459
463
 
460
464
  sleep 0.02 if key.nil?
461
465
  end
466
+ ensure
467
+ perform_pending_transcript_redraw
462
468
  end
463
469
 
464
470
  def yes?(message, default: false)
@@ -573,13 +579,19 @@ module Kward
573
579
  end
574
580
  end
575
581
 
576
- def with_terminal_handoff
582
+ def with_terminal_handoff(preserve_composer: false)
577
583
  start
578
584
  input = nil
579
585
  output = nil
580
586
  @mutex.synchronize do
581
- clear_prompt_for_output_locked
582
- restore_scroll_region_locked
587
+ if preserve_composer
588
+ handle_resize_locked
589
+ reserve_composer_region_locked
590
+ move_to_transcript_cursor_locked
591
+ else
592
+ clear_prompt_for_output_locked
593
+ restore_scroll_region_locked
594
+ end
583
595
  disable_editor_mouse_reporting(force: true)
584
596
  @output_io.print(BRACKETED_PASTE_RESTORE)
585
597
  @output_io.print(KEYBOARD_PROTOCOL_RESTORE)
@@ -594,6 +606,8 @@ module Kward
594
606
  yield(input, output)
595
607
  ensure
596
608
  @mutex.synchronize do
609
+ restore_scroll_region_locked if preserve_composer
610
+ make_room_for_composer_after_handoff_locked if !preserve_composer && @started && @asking
597
611
  enter_raw_mode_locked
598
612
  @output_io.print(KEYBOARD_PROTOCOL_ENABLE)
599
613
  @output_io.print(BRACKETED_PASTE_ENABLE)
@@ -683,6 +697,8 @@ module Kward
683
697
 
684
698
  def restore_composer_snapshot_locked(snapshot)
685
699
  @composer = snapshot[:composer] || new_composer_state_with_history
700
+ @completion_cycle = nil
701
+ @completion_overlay = nil
686
702
  @prompt_label = snapshot[:prompt_label].to_s.empty? ? "You>" : snapshot[:prompt_label].to_s
687
703
  self.composer_input = @composer.input
688
704
  self.composer_cursor = @composer.cursor
@@ -811,6 +827,8 @@ module Kward
811
827
  render_prompt_locked unless [EXIT_INPUT, CANCEL_INPUT].include?(result) || prompt_action_result?(result)
812
828
  [EXIT_INPUT, CANCEL_INPUT].include?(result) ? result : result
813
829
  end
830
+ ensure
831
+ perform_pending_transcript_redraw
814
832
  end
815
833
 
816
834
  def update_assistant_label(label)
@@ -855,6 +873,21 @@ module Kward
855
873
  end
856
874
  end
857
875
 
876
+ def record_transient_terminal_output(text)
877
+ value = text.to_s
878
+ return if value.empty?
879
+
880
+ @mutex.synchronize do
881
+ append_transcript_buffer(value)
882
+ append_transcript_buffer("\n") unless value.end_with?("\n")
883
+ @stream_state.finish_block
884
+ width, height = screen_size
885
+ remember_transcript_viewport_locked(height)
886
+ with_synchronized_output_locked { redraw_screen_locked(width: width, height: height) }
887
+ @output_io.flush
888
+ end
889
+ end
890
+
858
891
  def write_transcript_delta(delta)
859
892
  @mutex.synchronize do
860
893
  with_synchronized_output_locked do
@@ -904,6 +937,45 @@ module Kward
904
937
 
905
938
  private
906
939
 
940
+ def write_transcript_message(message, preserve_composer:)
941
+ @mutex.synchronize do
942
+ text = message.to_s
943
+ if @restoring_transcript
944
+ write_transcript_text_locked(text)
945
+ write_transcript_text_locked("\n") unless text.end_with?("\n")
946
+ @stream_state.finish_block
947
+ next
948
+ end
949
+
950
+ with_synchronized_output_locked do
951
+ if preserve_composer
952
+ prepare_transcript_output_locked
953
+ else
954
+ clear_prompt_for_output_locked
955
+ end
956
+ write_transcript_text_locked(text)
957
+ write_transcript_text_locked("\n") unless text.end_with?("\n")
958
+ @stream_state.finish_block
959
+ preserve_composer ? restore_composer_cursor_locked : render_prompt_after_output_locked
960
+ end
961
+ @output_io.flush
962
+ end
963
+ end
964
+
965
+ def request_transcript_redraw
966
+ redraw_screen_locked
967
+ @transcript_redraw_requested = true
968
+ end
969
+
970
+ def perform_pending_transcript_redraw
971
+ requested = @mutex.synchronize do
972
+ pending = @transcript_redraw_requested
973
+ @transcript_redraw_requested = false
974
+ pending
975
+ end
976
+ @redraw_handler&.call if requested
977
+ end
978
+
907
979
  def prompt_workspace_root
908
980
  @workspace_root
909
981
  end
@@ -28,6 +28,7 @@ module Kward
28
28
  { name: "diff", description: "Open the file changes recorded in this session.", argument_hint: "" },
29
29
  { name: "files", description: "Browse project files.", argument_hint: "" },
30
30
  { name: "shell", description: "Open the embedded Kward shell.", argument_hint: "" },
31
+ { name: "capture", description: "Run a command with bounded transcript capture.", argument_hint: "<command>" },
31
32
  { name: "scratchpad", description: "Open an unsaved editor buffer.", argument_hint: "[text|markdown|ruby]" },
32
33
  { name: "pty", description: "Run a command in an interactive PTY passthrough session.", argument_hint: "<command>" },
33
34
  { name: "tab", description: "Manage tabs.", argument_hint: "[1-n|move|close|new|name|worktree]" },
@@ -36,6 +37,7 @@ module Kward
36
37
  { name: "sandbox", description: "Inspect or configure model command sandboxing.", argument_hint: "[status|off|read_only|workspace_write|network allow|network deny]" },
37
38
  { name: "hooks", description: "Inspect lifecycle hooks.", argument_hint: "[list|events|logs|doctor|trust|untrust]" },
38
39
  { name: "skill", description: "Activate a skill or capture one from a saved session.", argument_hint: "<name>|capture" },
40
+ { name: "skills", description: "Review project skill trust.", argument_hint: "[status|trust|untrust]" },
39
41
  { name: "memory", description: "Inspect and manage Kward memory.", argument_hint: "[enable|disable|auto-summary|core|add|list|forget|promote|relax|inspect|why|summarize]" }
40
42
  ].freeze
41
43
  BUILTIN_RESERVED_COMMAND_NAMES = BUILTIN_COMMANDS.map { |command| command[:name] }.freeze
@@ -4,10 +4,11 @@ module Kward
4
4
  module Prompts
5
5
  # Parsed prompt template loaded from disk.
6
6
  class Templates
7
- def initialize(config_dir:, template_class:, markdown_parser:)
7
+ def initialize(config_dir:, template_class:, markdown_parser:, warning_sink: nil)
8
8
  @config_dir = config_dir
9
9
  @template_class = template_class
10
10
  @markdown_parser = markdown_parser
11
+ @warning_sink = warning_sink
11
12
  end
12
13
 
13
14
  def prompt_templates(reserved_commands: [])
@@ -21,11 +22,11 @@ module Kward
21
22
  next unless template
22
23
 
23
24
  if reserved.include?(template.command)
24
- warn "Warning: skipping Kward prompt command /#{template.command}: reserved command"
25
+ emit_warning "Warning: skipping Kward prompt command /#{template.command}: reserved command"
25
26
  next
26
27
  end
27
28
  if seen[template.command]
28
- warn "Warning: skipping duplicate Kward prompt command /#{template.command}: #{path}"
29
+ emit_warning "Warning: skipping duplicate Kward prompt command /#{template.command}: #{path}"
29
30
  next
30
31
  end
31
32
 
@@ -33,7 +34,7 @@ module Kward
33
34
  template
34
35
  end
35
36
  rescue StandardError => e
36
- warn "Warning: skipping Kward prompt templates in #{prompts_root}: #{e.message}"
37
+ emit_warning "Warning: skipping Kward prompt templates in #{prompts_root}: #{e.message}"
37
38
  []
38
39
  end
39
40
 
@@ -42,7 +43,7 @@ module Kward
42
43
  def parse_prompt_template(path)
43
44
  command = File.basename(path, ".md")
44
45
  unless command.match?(/\A[A-Za-z0-9][A-Za-z0-9_-]*\z/)
45
- warn "Warning: skipping Kward prompt template #{path}: invalid command name"
46
+ emit_warning "Warning: skipping Kward prompt template #{path}: invalid command name"
46
47
  return nil
47
48
  end
48
49
 
@@ -55,9 +56,13 @@ module Kward
55
56
  path: path
56
57
  )
57
58
  rescue StandardError => e
58
- warn "Warning: skipping Kward prompt template #{path}: #{e.message}"
59
+ emit_warning "Warning: skipping Kward prompt template #{path}: #{e.message}"
59
60
  nil
60
61
  end
62
+
63
+ def emit_warning(message)
64
+ @warning_sink ? @warning_sink.call(message) : warn(message)
65
+ end
61
66
  end
62
67
  end
63
68
  end
data/lib/kward/prompts.rb CHANGED
@@ -22,22 +22,22 @@ module Kward
22
22
  # @param plugin_context [String, nil] trusted plugin-provided instructions
23
23
  # @return [Hash] role/content system message
24
24
  # @api public
25
- def system_message(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil, execution_profile_context: nil)
25
+ def system_message(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil, execution_profile_context: nil, project_skill_paths: nil)
26
26
  {
27
27
  role: "system",
28
- content: prompt_parts(workspace_root: workspace_root, include_workspace_personality: include_workspace_personality, model: model, reasoning_effort: reasoning_effort, now: now, memory_context: memory_context, plugin_context: plugin_context, execution_profile_context: execution_profile_context).compact.join("\n\n")
28
+ content: prompt_parts(workspace_root: workspace_root, include_workspace_personality: include_workspace_personality, model: model, reasoning_effort: reasoning_effort, now: now, memory_context: memory_context, plugin_context: plugin_context, execution_profile_context: execution_profile_context, project_skill_paths: project_skill_paths).compact.join("\n\n")
29
29
  }
30
30
  end
31
31
 
32
- def prompt_parts(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil, execution_profile_context: nil)
33
- prompt_sections(workspace_root: workspace_root, include_workspace_personality: include_workspace_personality, model: model, reasoning_effort: reasoning_effort, now: now, memory_context: memory_context, plugin_context: plugin_context, execution_profile_context: execution_profile_context).map { |section| section[:content] }
32
+ def prompt_parts(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil, execution_profile_context: nil, project_skill_paths: nil)
33
+ prompt_sections(workspace_root: workspace_root, include_workspace_personality: include_workspace_personality, model: model, reasoning_effort: reasoning_effort, now: now, memory_context: memory_context, plugin_context: plugin_context, execution_profile_context: execution_profile_context, project_skill_paths: project_skill_paths).map { |section| section[:content] }
34
34
  end
35
35
 
36
36
  # Returns labeled prompt sections for inspection by CLI and RPC frontends.
37
37
  #
38
38
  # @return [Array<Hash>] section hashes with label, content, and optional source
39
39
  # @api public
40
- def prompt_sections(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil, execution_profile_context: nil)
40
+ def prompt_sections(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil, execution_profile_context: nil, project_skill_paths: nil)
41
41
  return replacement_prompt_sections if ConfigFiles.replacement_system_prompt?
42
42
 
43
43
  sections = [prompt_section("Built-in system prompt", base_prompt)]
@@ -48,7 +48,7 @@ module Kward
48
48
  sections << prompt_section("Persona", persona_prompt(workspace_root, model: model, reasoning_effort: reasoning_effort, now: now))
49
49
  sections << prompt_section("Plugin context", plugin_context) unless plugin_context.to_s.empty?
50
50
  end
51
- skills = ConfigFiles.skills(workspace_root: workspace_root)
51
+ skills = ConfigFiles.skills(workspace_root: workspace_root, project_skill_paths: project_skill_paths)
52
52
  sections << prompt_section("Configured skills", skills_prompt(skills), source: skills.empty? ? nil : File.join(ConfigFiles.config_dir, "skills"))
53
53
  sections << prompt_section(workspace_agents_context_label(workspace_root), workspace_agents_context(workspace_root), source: ConfigFiles.workspace_agents_file?(workspace_root) ? ConfigFiles.workspace_agents_path(workspace_root) : nil)
54
54
  sections.compact
@@ -579,6 +579,7 @@ module Kward
579
579
  stability: { protocol: "stable", compatibility: "additive-fields-unless-protocol-version-changes", experimentalCapabilities: [] },
580
580
  commands: { supported: true, methods: COMMAND_METHODS, method: COMMAND_METHODS[0], runMethod: COMMAND_METHODS[1], sources: ["builtin", "prompt", "skill", "plugin"], executableSources: ["builtin", "plugin"] },
581
581
  skillCapture: { supported: true, methods: SKILL_CAPTURE_METHODS, destination: "personal", source: "savedSessionActiveLeaf", reviewRequired: true, overwrite: "explicit", autoActivate: false },
582
+ projectSkillTrust: { supported: false, trustRequired: true, reason: "RPC has no interactive trust decision bridge; project skills remain skipped unless globally enabled." },
582
583
  mcp: {
583
584
  supported: true,
584
585
  transport: "stdio",
@@ -297,7 +297,7 @@ module Kward
297
297
  # `workspace` is used both for the active root and to restore read-before-write
298
298
  # paths from successful read tool results. If a session moved workspaces, load
299
299
  # it through `session_location` first so the original cwd is respected.
300
- def load(path, workspace: Workspace.new, provider: nil, model: nil, reasoning_effort: nil)
300
+ def load(path, workspace: Workspace.new, provider: nil, model: nil, reasoning_effort: nil, project_skill_paths: nil)
301
301
  resolved_path = resolve_session_path(path)
302
302
  records = records_from_file(resolved_path)
303
303
  header = session_header(records, resolved_path)
@@ -317,7 +317,8 @@ module Kward
317
317
  model: runtime["model"] || model,
318
318
  reasoning_effort: runtime["reasoningEffort"] || reasoning_effort,
319
319
  session_memories: memory_state["sessionMemories"],
320
- last_memory_retrieval: memory_state["lastRetrieval"]
320
+ last_memory_retrieval: memory_state["lastRetrieval"],
321
+ project_skill_paths: project_skill_paths
321
322
  )
322
323
  restore_tool_output_artifacts(records, conversation)
323
324
  conversation.mark_last_entry_compaction! if latest_record_type(records) == "compaction"