kward 0.74.0 → 0.75.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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -2
  5. data/Rakefile +18 -0
  6. data/doc/configuration.md +56 -3
  7. data/doc/extensibility.md +6 -19
  8. data/doc/getting-started.md +1 -1
  9. data/doc/lifecycle-hooks.md +424 -0
  10. data/doc/memory.md +1 -1
  11. data/doc/personas.md +2 -2
  12. data/doc/plugins.md +1 -1
  13. data/doc/releasing.md +7 -15
  14. data/doc/rpc.md +108 -16
  15. data/doc/session-management.md +9 -9
  16. data/doc/skills.md +199 -0
  17. data/doc/usage.md +7 -7
  18. data/lib/kward/agent.rb +72 -22
  19. data/lib/kward/auth/anthropic_oauth.rb +5 -87
  20. data/lib/kward/auth/oauth_helpers.rb +105 -0
  21. data/lib/kward/auth/openai_oauth.rb +4 -86
  22. data/lib/kward/cli/commands.rb +9 -2
  23. data/lib/kward/cli/compaction.rb +7 -0
  24. data/lib/kward/cli/git.rb +30 -3
  25. data/lib/kward/cli/hook_commands.rb +258 -0
  26. data/lib/kward/cli/memory_commands.rb +1 -5
  27. data/lib/kward/cli/plugins.rb +41 -1
  28. data/lib/kward/cli/prompt_interface.rb +32 -3
  29. data/lib/kward/cli/rendering.rb +1 -1
  30. data/lib/kward/cli/runtime_helpers.rb +44 -5
  31. data/lib/kward/cli/sessions.rb +13 -0
  32. data/lib/kward/cli/settings.rb +226 -152
  33. data/lib/kward/cli/slash_commands.rb +81 -42
  34. data/lib/kward/cli/tabs.rb +26 -6
  35. data/lib/kward/cli.rb +26 -3
  36. data/lib/kward/cli_transcript_formatter.rb +0 -7
  37. data/lib/kward/compaction/token_estimator.rb +112 -0
  38. data/lib/kward/compactor.rb +42 -116
  39. data/lib/kward/config_files.rb +117 -25
  40. data/lib/kward/deep_copy.rb +42 -0
  41. data/lib/kward/diff_view_mode.rb +36 -0
  42. data/lib/kward/events.rb +1 -0
  43. data/lib/kward/export_path.rb +2 -6
  44. data/lib/kward/frontmatter.rb +43 -0
  45. data/lib/kward/hooks/audit_log.rb +121 -0
  46. data/lib/kward/hooks/catalog.rb +110 -0
  47. data/lib/kward/hooks/command_handler.rb +66 -0
  48. data/lib/kward/hooks/config_loader.rb +104 -0
  49. data/lib/kward/hooks/decision.rb +102 -0
  50. data/lib/kward/hooks/event.rb +60 -0
  51. data/lib/kward/hooks/http_handler.rb +72 -0
  52. data/lib/kward/hooks/manager.rb +158 -0
  53. data/lib/kward/hooks/matcher.rb +75 -0
  54. data/lib/kward/hooks.rb +16 -0
  55. data/lib/kward/markdown_transcript.rb +4 -3
  56. data/lib/kward/mcp/client.rb +1 -1
  57. data/lib/kward/mcp/server_config.rb +7 -3
  58. data/lib/kward/mcp/stdio_transport.rb +7 -1
  59. data/lib/kward/memory/turn_context.rb +19 -0
  60. data/lib/kward/message_access.rb +2 -2
  61. data/lib/kward/message_text.rb +19 -0
  62. data/lib/kward/model/client.rb +13 -45
  63. data/lib/kward/model/context_usage.rb +10 -4
  64. data/lib/kward/model/copilot_models.rb +44 -0
  65. data/lib/kward/model/model_info.rb +24 -0
  66. data/lib/kward/model/payloads.rb +15 -17
  67. data/lib/kward/model/stream_parser.rb +178 -13
  68. data/lib/kward/pan/server.rb +51 -2
  69. data/lib/kward/path_guard.rb +13 -0
  70. data/lib/kward/plugin_registry.rb +86 -25
  71. data/lib/kward/prompt_interface/editor/controller.rb +60 -1
  72. data/lib/kward/prompt_interface/editor/renderer.rb +11 -0
  73. data/lib/kward/prompt_interface/editor/state.rb +5 -1
  74. data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
  75. data/lib/kward/prompt_interface.rb +4 -1
  76. data/lib/kward/prompts/commands.rb +3 -3
  77. data/lib/kward/prompts.rb +7 -7
  78. data/lib/kward/rpc/mcp_status.rb +50 -0
  79. data/lib/kward/rpc/prompt_bridge.rb +38 -4
  80. data/lib/kward/rpc/runtime_payloads.rb +1 -1
  81. data/lib/kward/rpc/server.rb +94 -14
  82. data/lib/kward/rpc/session_manager.rb +258 -33
  83. data/lib/kward/rpc/session_metrics.rb +1 -1
  84. data/lib/kward/rpc/session_tree_rows.rb +13 -64
  85. data/lib/kward/rpc/tool_event_normalizer.rb +16 -2
  86. data/lib/kward/rpc/transcript_normalizer.rb +9 -10
  87. data/lib/kward/rpc/turn_context.rb +109 -0
  88. data/lib/kward/session_store.rb +6 -20
  89. data/lib/kward/session_tree_nodes.rb +98 -18
  90. data/lib/kward/session_tree_renderer.rb +6 -61
  91. data/lib/kward/skills/registry.rb +109 -17
  92. data/lib/kward/tools/mcp_tool.rb +4 -0
  93. data/lib/kward/tools/read_skill.rb +9 -4
  94. data/lib/kward/tools/registry.rb +266 -8
  95. data/lib/kward/tools/search/code.rb +4 -22
  96. data/lib/kward/tools/search/web.rb +28 -2
  97. data/lib/kward/update_check.rb +117 -0
  98. data/lib/kward/version.rb +1 -1
  99. data/lib/kward/workers/manager.rb +43 -3
  100. data/lib/kward/workspace.rb +3 -1
  101. data/templates/default/fulldoc/html/css/kward.css +22 -0
  102. data/templates/default/fulldoc/html/js/kward.js +2 -0
  103. data/templates/default/kward_navigation.rb +4 -2
  104. data/templates/default/layout/html/setup.rb +2 -0
  105. metadata +25 -1
@@ -45,11 +45,12 @@ module Kward
45
45
  raise "Copilot returned invalid SSE JSON: #{e.message}"
46
46
  end
47
47
 
48
- def parse_codex_sse(body, on_reasoning_delta: nil, on_assistant_delta: nil, usage_normalizer: nil, request_error_class: nil)
49
- state = codex_sse_state
48
+ def parse_codex_sse(body, on_reasoning_delta: nil, on_assistant_delta: nil, show_raw_reasoning: false, usage_normalizer: nil, request_error_class: nil)
49
+ state = codex_sse_state(show_raw_reasoning: show_raw_reasoning)
50
50
  body.split(/\r?\n\r?\n/).each do |block|
51
51
  process_codex_sse_block(block, state, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class)
52
52
  end
53
+ flush_codex_reasoning_delta(state, on_reasoning_delta: on_reasoning_delta)
53
54
  codex_sse_message(state)
54
55
  rescue JSON::ParserError => e
55
56
  raise "Codex OAuth returned invalid SSE JSON: #{e.message}"
@@ -60,8 +61,8 @@ module Kward
60
61
  # Deltas are yielded as soon as complete SSE blocks arrive so interactive
61
62
  # frontends can render streamed assistant and reasoning text without waiting
62
63
  # for the provider to close the response.
63
- def parse_codex_sse_stream(response, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil, usage_normalizer: nil, request_error_class: nil)
64
- state = codex_sse_state
64
+ def parse_codex_sse_stream(response, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil, show_raw_reasoning: false, usage_normalizer: nil, request_error_class: nil)
65
+ state = codex_sse_state(show_raw_reasoning: show_raw_reasoning)
65
66
  buffer = +""
66
67
 
67
68
  response.read_body do |chunk|
@@ -76,6 +77,7 @@ module Kward
76
77
  end
77
78
  cancellation&.raise_if_cancelled!
78
79
  process_codex_sse_block(buffer, state, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class) unless buffer.empty?
80
+ flush_codex_reasoning_delta(state, on_reasoning_delta: on_reasoning_delta)
79
81
  codex_sse_message(state)
80
82
  rescue JSON::ParserError => e
81
83
  raise "Codex OAuth returned invalid SSE JSON: #{e.message}"
@@ -218,17 +220,21 @@ module Kward
218
220
  end
219
221
  end
220
222
 
221
- def codex_sse_state
223
+ def codex_sse_state(show_raw_reasoning: false)
222
224
  {
223
225
  content: +"",
224
226
  raw_content: +"",
225
227
  emitted_message_keys: [],
226
228
  reasoning_summary: +"",
229
+ raw_reasoning_visible: +"",
230
+ pending_reasoning_delta: +"",
231
+ show_raw_reasoning: show_raw_reasoning,
227
232
  tool_calls: [],
228
233
  response_item_keys: [],
229
234
  items_by_id: {},
230
235
  active_item_id: nil,
231
236
  current_text_content_part: nil,
237
+ current_reasoning_content_part: nil,
232
238
  usage: nil
233
239
  }
234
240
  end
@@ -252,7 +258,9 @@ module Kward
252
258
  when "response.reasoning_summary_part.done"
253
259
  codex_reasoning_part_done(state, on_reasoning_delta: on_reasoning_delta)
254
260
  when "response.reasoning_text.delta"
255
- codex_reasoning_delta(state, event["delta"], on_reasoning_delta: on_reasoning_delta)
261
+ codex_raw_reasoning_delta(state, event["delta"], on_reasoning_delta: on_reasoning_delta)
262
+ when "response.reasoning_text.done"
263
+ codex_raw_reasoning_done(state, event["text"], on_reasoning_delta: on_reasoning_delta)
256
264
  when "response.function_call_arguments.delta", "response.custom_tool_call_input.delta"
257
265
  codex_tool_arguments_delta(state, event["delta"])
258
266
  when "response.function_call_arguments.done"
@@ -299,6 +307,7 @@ module Kward
299
307
  remember_codex_item(state, item)
300
308
  state[:active_item_id] = codex_item_key(item)
301
309
  state[:current_text_content_part] = nil
310
+ state[:current_reasoning_content_part] = nil
302
311
  end
303
312
 
304
313
  def codex_content_part_added(state, part)
@@ -333,20 +342,53 @@ module Kward
333
342
 
334
343
  item["summary"] ||= []
335
344
  item["summary"] << deep_dup_hash(part)
345
+ state[:current_reasoning_content_part] = nil
336
346
  end
337
347
 
338
348
  def codex_reasoning_delta(state, delta, on_reasoning_delta: nil)
349
+ text = visible_codex_reasoning_delta(state, delta)
350
+ return if text.empty?
351
+
352
+ append_codex_reasoning_text(state, text, on_reasoning_delta: on_reasoning_delta)
353
+ end
354
+
355
+ def codex_raw_reasoning_delta(state, delta, on_reasoning_delta: nil)
339
356
  text = delta.to_s
340
357
  return if text.empty?
341
358
 
342
359
  item = active_codex_item(state)
343
- if item&.fetch("type", nil) == "reasoning"
344
- item["summary"] ||= []
345
- item["summary"] << { "type" => "summary_text", "text" => +"" } if item["summary"].empty?
346
- item["summary"].last["text"] = item["summary"].last["text"].to_s + text
360
+ if item&.fetch("type", nil) == "reasoning" && state[:show_raw_reasoning]
361
+ item["content"] ||= [{ "type" => "reasoning_text", "text" => +"" }]
362
+ part = state[:current_reasoning_content_part] || item["content"].last
363
+ part = item["content"].last unless part.is_a?(Hash)
364
+ part["type"] ||= "reasoning_text"
365
+ part["text"] = part["text"].to_s + text
366
+ state[:current_reasoning_content_part] = part
347
367
  end
348
- state[:reasoning_summary] << text
349
- on_reasoning_delta&.call(text)
368
+ return unless state[:show_raw_reasoning]
369
+
370
+ visible = visible_codex_reasoning_delta(state, text)
371
+ append_codex_raw_reasoning_text(state, visible, on_reasoning_delta: on_reasoning_delta) unless visible.empty?
372
+ end
373
+
374
+ def codex_raw_reasoning_done(state, text, on_reasoning_delta: nil)
375
+ item = active_codex_item(state)
376
+ full_text = text.to_s
377
+ if item&.fetch("type", nil) == "reasoning" && state[:show_raw_reasoning]
378
+ item["content"] ||= [{ "type" => "reasoning_text", "text" => +"" }]
379
+ part = state[:current_reasoning_content_part] || item["content"].last
380
+ part = item["content"].last unless part.is_a?(Hash)
381
+ part["type"] ||= "reasoning_text"
382
+ part["text"] = full_text
383
+ state[:current_reasoning_content_part] = part
384
+ end
385
+ return unless state[:show_raw_reasoning]
386
+
387
+ flush_codex_reasoning_delta(state, on_reasoning_delta: on_reasoning_delta)
388
+ visible = sanitize_codex_reasoning_text(full_text)
389
+ emitted = state[:raw_reasoning_visible]
390
+ text = visible.start_with?(emitted) ? visible[emitted.length..].to_s : (emitted.empty? ? visible : +"")
391
+ append_codex_raw_reasoning_text(state, text, on_reasoning_delta: on_reasoning_delta) unless text.empty?
350
392
  end
351
393
 
352
394
  def codex_reasoning_part_done(state, on_reasoning_delta: nil)
@@ -354,12 +396,97 @@ module Kward
354
396
  return unless item&.fetch("type", nil) == "reasoning"
355
397
  return if item["summary"].to_a.empty?
356
398
 
399
+ flush_codex_reasoning_delta(state, on_reasoning_delta: on_reasoning_delta)
357
400
  text = "\n\n"
358
401
  item["summary"].last["text"] = item["summary"].last["text"].to_s + text
359
402
  state[:reasoning_summary] << text
360
403
  on_reasoning_delta&.call(text)
361
404
  end
362
405
 
406
+ def append_codex_reasoning_text(state, text, on_reasoning_delta: nil)
407
+ text = compact_codex_reasoning_delta_spacing(state[:reasoning_summary], text)
408
+ return if text.empty?
409
+
410
+ item = active_codex_item(state)
411
+ if item&.fetch("type", nil) == "reasoning"
412
+ item["summary"] ||= []
413
+ item["summary"] << { "type" => "summary_text", "text" => +"" } if item["summary"].empty?
414
+ item["summary"].last["text"] = item["summary"].last["text"].to_s + text
415
+ end
416
+ state[:reasoning_summary] << text
417
+ on_reasoning_delta&.call(text)
418
+ end
419
+
420
+ def append_codex_raw_reasoning_text(state, text, on_reasoning_delta: nil)
421
+ text = compact_codex_reasoning_delta_spacing(state[:reasoning_summary], text)
422
+ return if text.empty?
423
+
424
+ state[:raw_reasoning_visible] << text
425
+ state[:reasoning_summary] << text
426
+ on_reasoning_delta&.call(text)
427
+ end
428
+
429
+ def visible_codex_reasoning_delta(state, delta)
430
+ state[:pending_reasoning_delta] << delta.to_s
431
+ sanitized = sanitize_codex_reasoning_text(state[:pending_reasoning_delta], keep_incomplete_comment: true)
432
+ text, pending_prefix = split_trailing_comment_prefix(sanitized[:text])
433
+ state[:pending_reasoning_delta].replace(pending_prefix + sanitized[:pending])
434
+ text
435
+ end
436
+
437
+ def flush_codex_reasoning_delta(state, on_reasoning_delta: nil)
438
+ pending = state[:pending_reasoning_delta].to_s
439
+ state[:pending_reasoning_delta].clear
440
+ text = sanitize_codex_reasoning_text(pending)
441
+ append_codex_raw_reasoning_text(state, text, on_reasoning_delta: on_reasoning_delta) unless text.empty?
442
+ end
443
+
444
+ def split_trailing_comment_prefix(text)
445
+ string = text.to_s
446
+ prefix = ["<!-", "<!", "<"].find { |candidate| string.end_with?(candidate) }
447
+ return [string, +""] unless prefix
448
+
449
+ [string[0...-prefix.length], prefix.dup]
450
+ end
451
+
452
+ def sanitize_codex_reasoning_text(text, keep_incomplete_comment: false)
453
+ string = text.to_s
454
+ visible = +""
455
+ index = 0
456
+
457
+ while (start_index = string.index("<!--", index))
458
+ end_index = string.index("-->", start_index + 4)
459
+ if end_index
460
+ visible << string[index...start_index]
461
+ index = end_index + 3
462
+ elsif keep_incomplete_comment
463
+ visible << string[index...start_index]
464
+ return { text: visible, pending: string[start_index..] }
465
+ else
466
+ visible << string[index..]
467
+ index = string.length
468
+ end
469
+ end
470
+
471
+ visible << string[index..].to_s
472
+ visible = compact_blank_codex_reasoning_lines(visible)
473
+ keep_incomplete_comment ? { text: visible, pending: +"" } : visible
474
+ end
475
+
476
+ def compact_blank_codex_reasoning_lines(text)
477
+ text.to_s.gsub(/(?:[ \t]*\n){3,}/, "\n\n")
478
+ end
479
+
480
+ def compact_codex_reasoning_delta_spacing(existing, text)
481
+ text = compact_blank_codex_reasoning_lines(text)
482
+ trailing_newlines = existing.to_s[/\n+\z/].to_s.length
483
+ leading_newlines = text[/\A\n+/].to_s.length
484
+ excess_newlines = trailing_newlines + leading_newlines - 2
485
+ return text unless excess_newlines.positive? && leading_newlines.positive?
486
+
487
+ text[[excess_newlines, leading_newlines].min..].to_s
488
+ end
489
+
363
490
  def codex_tool_arguments_delta(state, delta)
364
491
  item = active_codex_item(state)
365
492
  return unless item && ["function_call", "custom_tool_call"].include?(item["type"])
@@ -379,10 +506,40 @@ module Kward
379
506
  return unless item.is_a?(Hash)
380
507
 
381
508
  item = merge_codex_item(active_or_known_codex_item(state, item), item)
509
+ item = codex_visible_reasoning_item(state, item)
382
510
  remember_codex_item(state, item)
383
511
  collect_codex_item_output(state, item, on_assistant_delta: on_assistant_delta, on_reasoning_delta: on_reasoning_delta)
384
512
  state[:active_item_id] = nil if state[:active_item_id] == codex_item_key(item)
385
513
  state[:current_text_content_part] = nil
514
+ state[:current_reasoning_content_part] = nil
515
+ end
516
+
517
+ def codex_visible_reasoning_item(state, item)
518
+ return item unless item.is_a?(Hash) && item["type"] == "reasoning"
519
+
520
+ visible = deep_dup_hash(item)
521
+ sanitize_codex_reasoning_summary_item(visible)
522
+ visible.delete("content") unless state[:show_raw_reasoning]
523
+ return visible unless state[:show_raw_reasoning]
524
+ return visible if visible["summary"].is_a?(Array) && !visible["summary"].empty?
525
+
526
+ text = sanitize_codex_reasoning_text(raw_reasoning_from_codex_items([visible]))
527
+ visible["summary"] = [{ "type" => "summary_text", "text" => text }] unless text.empty?
528
+ visible
529
+ end
530
+
531
+ def sanitize_codex_reasoning_summary_item(item)
532
+ return unless item["summary"].is_a?(Array)
533
+
534
+ pending = +""
535
+ item["summary"].each do |part|
536
+ next unless part.is_a?(Hash) && part.key?("text")
537
+
538
+ pending << part["text"].to_s
539
+ sanitized = sanitize_codex_reasoning_text(pending, keep_incomplete_comment: true)
540
+ part["text"] = sanitized[:text]
541
+ pending.replace(sanitized[:pending])
542
+ end
386
543
  end
387
544
 
388
545
  def collect_codex_item_output(state, item, on_assistant_delta: nil, on_reasoning_delta: nil)
@@ -398,7 +555,7 @@ module Kward
398
555
  state[:emitted_message_keys] << key
399
556
  end
400
557
  when "reasoning"
401
- text = reasoning_summary_from_codex_items([item])
558
+ text = sanitize_codex_reasoning_text(reasoning_summary_from_codex_items([item]))
402
559
  if state[:reasoning_summary].empty? && !text.empty?
403
560
  state[:reasoning_summary] << text
404
561
  on_reasoning_delta&.call(text)
@@ -563,5 +720,13 @@ module Kward
563
720
  end
564
721
  end.join
565
722
  end
723
+
724
+ def raw_reasoning_from_codex_items(items)
725
+ items.flat_map do |item|
726
+ next [] unless item.is_a?(Hash) && item["type"] == "reasoning" && item["content"].is_a?(Array)
727
+
728
+ item["content"].filter_map { |part| part["text"] if part.is_a?(Hash) && part["type"] == "reasoning_text" }
729
+ end.join
730
+ end
566
731
  end
567
732
  end
@@ -9,6 +9,8 @@ require_relative "../agent"
9
9
  require_relative "../config_files"
10
10
  require_relative "../events"
11
11
  require_relative "../model/retry_message"
12
+ require_relative "../plugin_registry"
13
+ require_relative "../prompts/commands"
12
14
  require_relative "../rpc/transcript_normalizer"
13
15
  require_relative "../session_store"
14
16
  require_relative "../tools/tool_call"
@@ -42,10 +44,14 @@ module Kward
42
44
  reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort))
43
45
  )
44
46
  @session.attach(@conversation)
47
+ hook_context = lifecycle_hook_context
48
+ hook_manager = lifecycle_hook_manager
45
49
  @agent = Agent.new(
46
50
  client: @client,
47
- tool_registry: ToolRegistry.new(workspace: @workspace, ask_user_question_enabled: false),
48
- conversation: @conversation
51
+ tool_registry: ToolRegistry.new(workspace: @workspace, ask_user_question_enabled: false, hook_manager: hook_manager, hook_context: hook_context),
52
+ conversation: @conversation,
53
+ hook_manager: hook_manager,
54
+ hook_context: hook_context
49
55
  )
50
56
  @prompt_queue = Queue.new
51
57
  @subscribers = []
@@ -97,6 +103,49 @@ module Kward
97
103
 
98
104
  private
99
105
 
106
+ def lifecycle_hook_manager
107
+ manager = Hooks::ConfigLoader.new(ConfigFiles.lifecycle_hooks_config(@workspace.root)).manager
108
+ manager.on_result = method(:broadcast_lifecycle_hook_event)
109
+ plugin_registry.hook_handlers.each do |hook|
110
+ manager.register(hook.event, id: hook.id, source: hook.path, order: hook.order, match: hook.match, failure_policy: hook.failure_policy) do |event, context|
111
+ hook.handler.call(event, context)
112
+ end
113
+ end
114
+ manager
115
+ end
116
+
117
+ def broadcast_lifecycle_hook_event(event, result)
118
+ broadcast("hook_event", {
119
+ event: {
120
+ id: event.id,
121
+ name: event.name,
122
+ phase: event.phase,
123
+ timestamp: event.timestamp.iso8601,
124
+ payloadKeys: event.payload.keys.map(&:to_s).sort
125
+ },
126
+ result: {
127
+ decision: result.decision.decision,
128
+ warnings: result.warnings,
129
+ messages: result.messages,
130
+ decisionCount: result.decisions.length
131
+ }
132
+ })
133
+ end
134
+
135
+ def lifecycle_hook_context
136
+ PluginRegistry::Context.new(
137
+ conversation: @conversation,
138
+ args: "",
139
+ session: @session,
140
+ workspace_root: @workspace.root.to_s,
141
+ say_callback: lambda { |message| broadcast("hook_message", { message: message.to_s }) }
142
+ )
143
+ end
144
+
145
+ def plugin_registry
146
+ @plugin_registry ||= PluginRegistry.load(reserved_commands: PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES)
147
+ end
148
+
100
149
  def start_worker
101
150
  return if @worker_started
102
151
 
@@ -0,0 +1,13 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Shared path containment predicate for local filesystem guardrails.
4
+ module PathGuard
5
+ module_function
6
+
7
+ def inside?(path, root)
8
+ path = path.to_s
9
+ root = root.to_s
10
+ path == root || path.start_with?(root + File::SEPARATOR)
11
+ end
12
+ end
13
+ end
@@ -1,4 +1,6 @@
1
1
  require_relative "config_files"
2
+ require_relative "deep_copy"
3
+ require_relative "hooks"
2
4
 
3
5
  # Namespace for the Kward CLI agent runtime.
4
6
  module Kward
@@ -39,6 +41,9 @@ module Kward
39
41
  end
40
42
  end
41
43
 
44
+ # Registered lifecycle hook handler.
45
+ HookHandler = Struct.new(:event, :id, :description, :path, :order, :match, :failure_policy, :handler, keyword_init: true)
46
+
42
47
  # Read-only transcript view exposed to plugin code.
43
48
  class Transcript
44
49
  # Creates an object for trusted plugin loading and dispatch.
@@ -50,7 +55,7 @@ module Kward
50
55
  #
51
56
  # @return [Array<Hash>] immutable transcript message data
52
57
  def messages
53
- PluginRegistry.deep_freeze(PluginRegistry.deep_dup(@conversation.messages))
58
+ DeepCopy.freeze(DeepCopy.dup(@conversation.messages))
54
59
  end
55
60
  end
56
61
 
@@ -102,6 +107,34 @@ module Kward
102
107
  @conversation.refresh_system_message! if @conversation.respond_to?(:refresh_system_message!)
103
108
  nil
104
109
  end
110
+
111
+ def allow(message = nil, metadata: nil)
112
+ Hooks::Decision.allow(message, metadata: metadata)
113
+ end
114
+
115
+ def deny(message = nil, metadata: nil)
116
+ Hooks::Decision.deny(message, metadata: metadata)
117
+ end
118
+
119
+ def ask(message = nil, metadata: nil)
120
+ Hooks::Decision.ask(message, metadata: metadata)
121
+ end
122
+
123
+ def modify(payload, message: nil, metadata: nil)
124
+ Hooks::Decision.modify(payload, message: message, metadata: metadata)
125
+ end
126
+
127
+ def warn(message = nil, metadata: nil)
128
+ Hooks::Decision.warn(message, metadata: metadata)
129
+ end
130
+
131
+ def retry(message = nil, payload: nil, metadata: nil)
132
+ Hooks::Decision.retry(message, payload: payload, metadata: metadata)
133
+ end
134
+
135
+ def defer(message = nil, payload: nil, metadata: nil)
136
+ Hooks::Decision.defer(message, payload: payload, metadata: metadata)
137
+ end
105
138
  end
106
139
 
107
140
  # Public DSL object yielded by `Kward.plugin` blocks.
@@ -165,6 +198,25 @@ module Kward
165
198
  @registry.register_transcript_event(path: @path, &block)
166
199
  end
167
200
 
201
+ # Registers a lifecycle hook handler.
202
+ #
203
+ # Hooks are deterministic runtime callbacks around Kward lifecycle events.
204
+ # They can return a {Hooks::Decision}, a decision hash, a decision string,
205
+ # or nil to allow the operation.
206
+ #
207
+ # @param event [String, #to_s] lifecycle event name
208
+ # @param id [String, nil] stable hook identifier for logs and diagnostics
209
+ # @param description [String] short human-readable purpose
210
+ # @param order [Integer] lower values run first
211
+ # @param match [Hash, nil] optional event selector
212
+ # @yieldparam event [Hooks::Event] immutable lifecycle event
213
+ # @yieldparam ctx [Context] plugin execution context and decision helpers
214
+ # @return [void]
215
+ # @api public
216
+ def hook(event, id: nil, description: "", order: 100, match: nil, failure_policy: nil, &block)
217
+ @registry.register_hook(event, id: id, description: description, order: order, match: match, failure_policy: failure_policy, path: @path, &block)
218
+ end
219
+
168
220
  # Registers prompt context text injected into future system prompts.
169
221
  #
170
222
  # Keep this text short and never include secrets. The returned string can
@@ -205,29 +257,6 @@ module Kward
205
257
  paths.each { |path| registry.load_file(path) }
206
258
  registry
207
259
  end
208
-
209
- def deep_dup(value)
210
- case value
211
- when Hash
212
- value.each_with_object({}) { |(key, item), result| result[key] = deep_dup(item) }
213
- when Array
214
- value.map { |item| deep_dup(item) }
215
- else
216
- value.dup
217
- end
218
- rescue TypeError
219
- value
220
- end
221
-
222
- def deep_freeze(value)
223
- case value
224
- when Hash
225
- value.each_value { |item| deep_freeze(item) }
226
- when Array
227
- value.each { |item| deep_freeze(item) }
228
- end
229
- value.freeze
230
- end
231
260
  end
232
261
 
233
262
  # Creates an object for trusted plugin loading and dispatch.
@@ -239,6 +268,7 @@ module Kward
239
268
  @footer_path = nil
240
269
  @transcript_event_handlers = []
241
270
  @prompt_context_renderers = []
271
+ @hook_handlers = []
242
272
  @paths = []
243
273
  end
244
274
 
@@ -276,6 +306,20 @@ module Kward
276
306
  @prompt_context_renderers.map { |entry| entry[:renderer] }
277
307
  end
278
308
 
309
+ def hook_handlers
310
+ @hook_handlers.dup
311
+ end
312
+
313
+ def hook_manager
314
+ manager = Hooks::Manager.new
315
+ @hook_handlers.each do |hook|
316
+ manager.register(hook.event, id: hook.id, source: hook.path, order: hook.order, match: hook.match, failure_policy: hook.failure_policy) do |event, context|
317
+ hook.handler.call(event, context)
318
+ end
319
+ end
320
+ manager
321
+ end
322
+
279
323
  def prompt_context(context)
280
324
  parts = []
281
325
  @prompt_context_renderers.each do |entry|
@@ -387,6 +431,23 @@ module Kward
387
431
  @prompt_context_renderers << { path: path, renderer: renderer }
388
432
  end
389
433
 
434
+ def register_hook(event, id: nil, description: "", order: 100, match: nil, failure_policy: nil, path: nil, &handler)
435
+ event = event.to_s
436
+ raise "Plugin hook event is required" if event.empty?
437
+ raise "Plugin hook #{event} requires a handler" unless handler
438
+
439
+ @hook_handlers << HookHandler.new(
440
+ event: event,
441
+ id: id&.to_s || "#{File.basename(path.to_s.empty? ? "plugin" : path)}:#{event}:#{@hook_handlers.length + 1}",
442
+ description: description.to_s,
443
+ path: path,
444
+ order: order.to_i,
445
+ match: match,
446
+ failure_policy: failure_policy,
447
+ handler: handler
448
+ )
449
+ end
450
+
390
451
  private
391
452
 
392
453
  def transcript_event_for(event)
@@ -422,7 +483,7 @@ module Kward
422
483
  def transcript_event(type, payload)
423
484
  TranscriptEvent.new(
424
485
  type: type,
425
- payload: PluginRegistry.deep_freeze(PluginRegistry.deep_dup(payload))
486
+ payload: DeepCopy.freeze(DeepCopy.dup(payload))
426
487
  ).freeze
427
488
  end
428
489
  end
@@ -112,7 +112,9 @@ module Kward
112
112
  end
113
113
 
114
114
  def open_diff_viewer(path, content)
115
- @editor_state = EditorState.new(path: path.to_s, content: content.to_s, new_file: true, editor_mode: current_editor_mode, readonly: true, diff_view: true)
115
+ diff_view_mode = current_diff_view_mode
116
+ viewer_content = diff_view_mode == DiffViewMode::SIDE_BY_SIDE ? side_by_side_diff_content(content.to_s) : content.to_s
117
+ @editor_state = EditorState.new(path: path.to_s, content: viewer_content, new_file: true, editor_mode: current_editor_mode, readonly: true, diff_view: diff_view_mode)
116
118
  @prompt_label = "Diff>"
117
119
  self.composer_input = ""
118
120
  self.composer_cursor = 0
@@ -131,6 +133,63 @@ module Kward
131
133
  @editor_mode
132
134
  end
133
135
 
136
+ def current_diff_view_mode
137
+ configured = @diff_view_source.respond_to?(:call) ? @diff_view_source.call : @diff_view
138
+ DiffViewMode.resolve(configured, terminal_width: screen_width)
139
+ rescue StandardError
140
+ DiffViewMode.resolve(@diff_view, terminal_width: screen_width)
141
+ end
142
+
143
+ def side_by_side_diff_content(content)
144
+ width = side_by_side_diff_column_width
145
+ rows = []
146
+ pending_deletions = []
147
+
148
+ content.to_s.each_line(chomp: true) do |line|
149
+ if side_by_side_diff_metadata_line?(line)
150
+ rows.concat(flush_side_by_side_deletions(pending_deletions, width))
151
+ rows << line
152
+ elsif line.start_with?("-")
153
+ pending_deletions << line[1..].to_s
154
+ elsif line.start_with?("+")
155
+ deleted = pending_deletions.shift
156
+ rows << side_by_side_diff_row(deleted && "- #{deleted}", "+ #{line[1..]}", width)
157
+ else
158
+ rows.concat(flush_side_by_side_deletions(pending_deletions, width))
159
+ text = line.start_with?(" ") ? line[1..].to_s : line
160
+ rows << side_by_side_diff_row(" #{text}", " #{text}", width)
161
+ end
162
+ end
163
+
164
+ rows.concat(flush_side_by_side_deletions(pending_deletions, width))
165
+ rows.join("\n")
166
+ end
167
+
168
+ def side_by_side_diff_metadata_line?(line)
169
+ line.start_with?("diff --git", "index ", "---", "+++", "@@")
170
+ end
171
+
172
+ def flush_side_by_side_deletions(lines, width)
173
+ lines.shift(lines.length).map { |line| side_by_side_diff_row("- #{line}", nil, width) }
174
+ end
175
+
176
+ def side_by_side_diff_row(left, right, width)
177
+ left_text = side_by_side_diff_cell(left, width)
178
+ right_text = side_by_side_diff_cell(right, width)
179
+ "#{left_text.ljust(width)} │ #{right_text}"
180
+ end
181
+
182
+ def side_by_side_diff_cell(text, width)
183
+ value = text.nil? ? "" : text.to_s
184
+ visible_truncate(value, width)
185
+ end
186
+
187
+ def side_by_side_diff_column_width
188
+ content_width = [screen_width - 4, 1].max
189
+ gutter_width = [[999.to_s.length, 4].max + 3, 1].max
190
+ [[(content_width - gutter_width - 3) / 2, 10].max, 80].min
191
+ end
192
+
134
193
  def current_editor_soft_wrap?
135
194
  return @editor_soft_wrap_source.call != false if @editor_soft_wrap_source.respond_to?(:call)
136
195
 
@@ -174,6 +174,7 @@ module Kward
174
174
 
175
175
  def editor_render_diff_line(line)
176
176
  text = line.to_s
177
+ return editor_render_side_by_side_diff_line(text) if @editor_state.side_by_side_diff_view?
177
178
  return colored(text, :green) if text.start_with?("+") && !text.start_with?("+++")
178
179
  return colored(text, :red) if text.start_with?("-") && !text.start_with?("---")
179
180
  return colored(text, :cyan) if text.start_with?("@@")
@@ -181,6 +182,16 @@ module Kward
181
182
  text
182
183
  end
183
184
 
185
+ def editor_render_side_by_side_diff_line(text)
186
+ return colored(text, :cyan) if text.start_with?("@@")
187
+ return text unless text.include?(" │ ")
188
+
189
+ left, right = text.split(" │ ", 2)
190
+ rendered_left = left.start_with?("-") ? colored(left, :red) : left
191
+ rendered_right = right.start_with?("+") ? colored(right, :green) : right
192
+ "#{rendered_left} │ #{rendered_right}"
193
+ end
194
+
184
195
  def editor_line_number_gutter_width
185
196
  [[@editor_state.lines.length.to_s.length, 4].max + 3, 1].max
186
197
  end
@@ -373,7 +373,11 @@ module Kward
373
373
  end
374
374
 
375
375
  def diff_view?
376
- @diff_view == true
376
+ !@diff_view.nil? && @diff_view != false
377
+ end
378
+
379
+ def side_by_side_diff_view?
380
+ @diff_view == DiffViewMode::SIDE_BY_SIDE
377
381
  end
378
382
 
379
383
  def modern?