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
@@ -0,0 +1,66 @@
1
+ require "json"
2
+ require "open3"
3
+ require "shellwords"
4
+ require "timeout"
5
+ require_relative "catalog"
6
+ require_relative "decision"
7
+
8
+ # Namespace for the Kward CLI agent runtime.
9
+ module Kward
10
+ module Hooks
11
+ # Executes an external command hook using JSON over stdin/stdout.
12
+ class CommandHandler
13
+ DEFAULT_TIMEOUT_SECONDS = 10
14
+
15
+ def initialize(command:, timeout_seconds: DEFAULT_TIMEOUT_SECONDS, env: nil, failure_policy: Catalog::DEFAULT_FAILURE_POLICY)
16
+ @command = command.to_s
17
+ @timeout_seconds = positive_timeout(timeout_seconds)
18
+ @env = stringify_hash(env || {})
19
+ @failure_policy = Catalog.normalize_failure_policy(failure_policy)
20
+ end
21
+
22
+ def call(event, _context = nil)
23
+ stdout, stderr, status = run_command(JSON.dump(event.to_h))
24
+ return failure_decision("Command hook failed: #{stderr.strip.empty? ? "exit #{status.exitstatus}" : stderr.strip}") unless status.success?
25
+
26
+ parse_decision(stdout)
27
+ rescue Timeout::Error
28
+ failure_decision("Command hook timed out after #{@timeout_seconds}s")
29
+ rescue StandardError => e
30
+ failure_decision("Command hook failed: #{e.message}")
31
+ end
32
+
33
+ private
34
+
35
+ def run_command(input)
36
+ Timeout.timeout(@timeout_seconds) do
37
+ Open3.capture3(@env, *@command.shellsplit, stdin_data: input)
38
+ end
39
+ end
40
+
41
+ def parse_decision(stdout)
42
+ text = stdout.to_s.strip
43
+ return Decision.allow if text.empty?
44
+
45
+ Decision.normalize(JSON.parse(text))
46
+ rescue JSON::ParserError => e
47
+ failure_decision("Command hook returned invalid JSON: #{e.message}")
48
+ end
49
+
50
+ def failure_decision(message)
51
+ Catalog.failure_decision(@failure_policy, message)
52
+ end
53
+
54
+ def positive_timeout(value)
55
+ seconds = value.to_i
56
+ seconds.positive? ? seconds : DEFAULT_TIMEOUT_SECONDS
57
+ end
58
+
59
+ def stringify_hash(value)
60
+ value.each_with_object({}) do |(key, item), result|
61
+ result[key.to_s] = item.to_s
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,104 @@
1
+ require_relative "catalog"
2
+ require_relative "command_handler"
3
+ require_relative "http_handler"
4
+ require_relative "manager"
5
+
6
+ # Namespace for the Kward CLI agent runtime.
7
+ module Kward
8
+ module Hooks
9
+ # Builds hook managers from Kward configuration hashes.
10
+ class ConfigLoader
11
+ def initialize(config)
12
+ @config = config || {}
13
+ end
14
+
15
+ def manager
16
+ Manager.new.tap do |manager|
17
+ hooks_config.each do |event, entries|
18
+ Array(entries).each_with_index do |entry, index|
19
+ register_entry(manager, event.to_s, entry, index)
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def hooks_config
28
+ value = @config["hooks"] || @config[:hooks]
29
+ value.is_a?(Hash) ? value : {}
30
+ end
31
+
32
+ def register_entry(manager, event, entry, index)
33
+ entry = normalize_entry(entry)
34
+ return if truthy?(entry["disabled"])
35
+
36
+ handler = handler_for(entry)
37
+ callback = truthy?(entry["async"]) ? async_callback(handler) : handler
38
+ manager.register(
39
+ event,
40
+ id: entry["id"] || "config:#{event}:#{index + 1}",
41
+ source: "config",
42
+ order: entry.fetch("order", 100),
43
+ match: entry["match"],
44
+ failure_policy: entry["failure_policy"]
45
+ ) { |hook_event, context| callback.call(hook_event, context) }
46
+ end
47
+
48
+ def normalize_entry(entry)
49
+ case entry
50
+ when String
51
+ { "type" => "command", "command" => entry }
52
+ when Hash
53
+ entry.transform_keys(&:to_s)
54
+ else
55
+ raise ArgumentError, "Invalid hook config entry: #{entry.inspect}"
56
+ end
57
+ end
58
+
59
+ def handler_for(entry)
60
+ type = (entry["type"] || "command").to_s
61
+ case type
62
+ when "command"
63
+ CommandHandler.new(
64
+ command: required(entry, "command"),
65
+ timeout_seconds: entry["timeout_seconds"],
66
+ env: entry["env"],
67
+ failure_policy: entry["failure_policy"] || Catalog::DEFAULT_FAILURE_POLICY
68
+ )
69
+ when "http"
70
+ HttpHandler.new(
71
+ url: required(entry, "url"),
72
+ timeout_seconds: entry["timeout_seconds"],
73
+ headers: entry["headers"],
74
+ failure_policy: entry["failure_policy"] || Catalog::DEFAULT_FAILURE_POLICY
75
+ )
76
+ else
77
+ raise ArgumentError, "Unsupported hook type: #{type}"
78
+ end
79
+ end
80
+
81
+ def async_callback(handler)
82
+ lambda do |hook_event, context|
83
+ Thread.new do
84
+ handler.call(hook_event, context)
85
+ rescue StandardError
86
+ nil
87
+ end
88
+ Decision.allow("Async hook scheduled")
89
+ end
90
+ end
91
+
92
+ def required(entry, key)
93
+ value = entry[key]
94
+ raise ArgumentError, "Hook config requires #{key}" if value.to_s.empty?
95
+
96
+ value
97
+ end
98
+
99
+ def truthy?(value)
100
+ value == true || value.to_s == "true"
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,102 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ module Hooks
4
+ # Normalized result returned by lifecycle hooks.
5
+ class Decision
6
+ VALID_DECISIONS = %w[allow deny ask modify warn retry defer].freeze
7
+
8
+ attr_reader :decision, :message, :payload, :metadata
9
+
10
+ def initialize(decision:, message: nil, payload: nil, metadata: nil)
11
+ @decision = decision.to_s
12
+ raise ArgumentError, "Unknown hook decision: #{@decision}" unless VALID_DECISIONS.include?(@decision)
13
+
14
+ @message = message&.to_s
15
+ @payload = payload || {}
16
+ @metadata = metadata || {}
17
+ end
18
+
19
+ def self.allow(message = nil, metadata: nil)
20
+ new(decision: "allow", message: message, metadata: metadata)
21
+ end
22
+
23
+ def self.deny(message = nil, metadata: nil)
24
+ new(decision: "deny", message: message, metadata: metadata)
25
+ end
26
+
27
+ def self.ask(message = nil, metadata: nil)
28
+ new(decision: "ask", message: message, metadata: metadata)
29
+ end
30
+
31
+ def self.modify(payload = nil, message: nil, metadata: nil, **payload_keywords)
32
+ payload = payload_keywords unless payload_keywords.empty?
33
+ new(decision: "modify", message: message, payload: payload || {}, metadata: metadata)
34
+ end
35
+
36
+ def self.warn(message = nil, metadata: nil)
37
+ new(decision: "warn", message: message, metadata: metadata)
38
+ end
39
+
40
+ def self.retry(message = nil, payload: nil, metadata: nil)
41
+ new(decision: "retry", message: message, payload: payload, metadata: metadata)
42
+ end
43
+
44
+ def self.defer(message = nil, payload: nil, metadata: nil)
45
+ new(decision: "defer", message: message, payload: payload, metadata: metadata)
46
+ end
47
+
48
+ def self.normalize(value)
49
+ case value
50
+ when nil
51
+ allow
52
+ when Decision
53
+ value
54
+ when String, Symbol
55
+ new(decision: value)
56
+ when Hash
57
+ new(
58
+ decision: value[:decision] || value["decision"] || "allow",
59
+ message: value[:message] || value["message"],
60
+ payload: value[:payload] || value["payload"],
61
+ metadata: value[:metadata] || value["metadata"]
62
+ )
63
+ else
64
+ raise ArgumentError, "Invalid hook decision: #{value.inspect}"
65
+ end
66
+ end
67
+
68
+ def allow?
69
+ decision == "allow"
70
+ end
71
+
72
+ def deny?
73
+ decision == "deny"
74
+ end
75
+
76
+ def ask?
77
+ decision == "ask"
78
+ end
79
+
80
+ def modify?
81
+ decision == "modify"
82
+ end
83
+
84
+ def warning?
85
+ decision == "warn"
86
+ end
87
+
88
+ def blocking?
89
+ deny? || ask?
90
+ end
91
+
92
+ def to_h
93
+ {
94
+ decision: decision,
95
+ message: message,
96
+ payload: payload,
97
+ metadata: metadata
98
+ }
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,60 @@
1
+ require "securerandom"
2
+ require "time"
3
+ require_relative "../deep_copy"
4
+
5
+ # Namespace for the Kward CLI agent runtime.
6
+ module Kward
7
+ module Hooks
8
+ # Immutable lifecycle event delivered to hook handlers.
9
+ class Event
10
+ attr_reader :id, :name, :phase, :timestamp, :session, :turn, :workspace, :frontend, :agent, :payload
11
+
12
+ def initialize(name:, phase: nil, timestamp: Time.now.utc, session: nil, turn: nil, workspace: nil, frontend: nil, agent: nil, payload: nil, id: nil)
13
+ @id = id || "hookevt_#{SecureRandom.hex(12)}"
14
+ @name = name.to_s
15
+ @phase = phase&.to_s || inferred_phase(@name)
16
+ @timestamp = timestamp.is_a?(Time) ? timestamp.utc : Time.parse(timestamp.to_s).utc
17
+ @session = frozen_copy(session || {})
18
+ @turn = frozen_copy(turn || {})
19
+ @workspace = frozen_copy(workspace || {})
20
+ @frontend = frozen_copy(frontend || {})
21
+ @agent = frozen_copy(agent || {})
22
+ @payload = frozen_copy(payload || {})
23
+ freeze
24
+ end
25
+
26
+ def [](key)
27
+ payload[key] || payload[key.to_s]
28
+ end
29
+
30
+ def to_h
31
+ {
32
+ id: id,
33
+ name: name,
34
+ phase: phase,
35
+ timestamp: timestamp.iso8601,
36
+ session: session,
37
+ turn: turn,
38
+ workspace: workspace,
39
+ frontend: frontend,
40
+ agent: agent,
41
+ payload: payload
42
+ }
43
+ end
44
+
45
+ private
46
+
47
+ def inferred_phase(name)
48
+ return "before" if name.end_with?("_before") || name.include?("before_")
49
+ return "after" if name.end_with?("_after") || name.include?("after_")
50
+ return "error" if name.end_with?("_error") || name == "error"
51
+
52
+ "during"
53
+ end
54
+
55
+ def frozen_copy(value)
56
+ DeepCopy.freeze(DeepCopy.dup(value))
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,72 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+ require_relative "catalog"
5
+ require_relative "decision"
6
+
7
+ # Namespace for the Kward CLI agent runtime.
8
+ module Kward
9
+ module Hooks
10
+ # Executes an HTTP lifecycle hook using JSON request/response bodies.
11
+ class HttpHandler
12
+ DEFAULT_TIMEOUT_SECONDS = 10
13
+
14
+ def initialize(url:, timeout_seconds: DEFAULT_TIMEOUT_SECONDS, headers: nil, failure_policy: Catalog::DEFAULT_FAILURE_POLICY, http_client: Net::HTTP)
15
+ @uri = URI.parse(url.to_s)
16
+ raise ArgumentError, "HTTP hook requires http or https URL" unless %w[http https].include?(@uri.scheme)
17
+
18
+ @timeout_seconds = positive_timeout(timeout_seconds)
19
+ @headers = stringify_hash(headers || {})
20
+ @failure_policy = Catalog.normalize_failure_policy(failure_policy)
21
+ @http_client = http_client
22
+ end
23
+
24
+ def call(event, _context = nil)
25
+ response = post(JSON.dump(event.to_h))
26
+ return failure_decision("HTTP hook failed: #{response.code} #{response.message}") unless response.is_a?(Net::HTTPSuccess)
27
+
28
+ parse_decision(response.body)
29
+ rescue StandardError => e
30
+ failure_decision("HTTP hook failed: #{e.message}")
31
+ end
32
+
33
+ private
34
+
35
+ def post(body)
36
+ request = Net::HTTP::Post.new(@uri)
37
+ request["Content-Type"] = "application/json"
38
+ request["Accept"] = "application/json"
39
+ @headers.each { |key, value| request[key] = value }
40
+ request.body = body
41
+
42
+ @http_client.start(@uri.hostname, @uri.port, use_ssl: @uri.scheme == "https", open_timeout: @timeout_seconds, read_timeout: @timeout_seconds) do |http|
43
+ http.request(request)
44
+ end
45
+ end
46
+
47
+ def parse_decision(body)
48
+ text = body.to_s.strip
49
+ return Decision.allow if text.empty?
50
+
51
+ Decision.normalize(JSON.parse(text))
52
+ rescue JSON::ParserError => e
53
+ failure_decision("HTTP hook returned invalid JSON: #{e.message}")
54
+ end
55
+
56
+ def failure_decision(message)
57
+ Catalog.failure_decision(@failure_policy, message)
58
+ end
59
+
60
+ def positive_timeout(value)
61
+ seconds = value.to_i
62
+ seconds.positive? ? seconds : DEFAULT_TIMEOUT_SECONDS
63
+ end
64
+
65
+ def stringify_hash(value)
66
+ value.each_with_object({}) do |(key, item), result|
67
+ result[key.to_s] = item.to_s
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,158 @@
1
+ require_relative "../deep_copy"
2
+ require_relative "audit_log"
3
+ require_relative "catalog"
4
+ require_relative "decision"
5
+ require_relative "event"
6
+ require_relative "matcher"
7
+
8
+ # Namespace for the Kward CLI agent runtime.
9
+ module Kward
10
+ module Hooks
11
+ # Dispatches lifecycle events to registered hooks and combines decisions.
12
+ class Manager
13
+ Handler = Struct.new(:id, :event, :source, :order, :match, :callback, :failure_policy, keyword_init: true)
14
+ Result = Struct.new(:event, :decision, :decisions, :warnings, :messages, :payload, keyword_init: true) do
15
+ def allowed?
16
+ !decision.deny? && !decision.ask?
17
+ end
18
+
19
+ def denied?
20
+ decision.deny?
21
+ end
22
+
23
+ def approval_required?
24
+ decision.ask?
25
+ end
26
+ end
27
+
28
+ attr_reader :handlers
29
+ attr_writer :on_result
30
+
31
+ def initialize(audit_log: AuditLog.new, on_result: nil)
32
+ @handlers = []
33
+ @audit_log = audit_log
34
+ @on_result = on_result
35
+ end
36
+
37
+ def register(event, id: nil, source: nil, order: 100, match: nil, failure_policy: nil, &callback)
38
+ raise ArgumentError, "Hook requires an event name" if event.to_s.empty?
39
+ raise ArgumentError, "Hook requires a handler" unless callback
40
+
41
+ handler = Handler.new(
42
+ id: id || "#{source || "hook"}:#{event}:#{@handlers.length + 1}",
43
+ event: event.to_s,
44
+ source: source,
45
+ order: order.to_i,
46
+ match: Matcher.new(match),
47
+ callback: callback,
48
+ failure_policy: Catalog.failure_policy(event, failure_policy)
49
+ )
50
+ @handlers << handler
51
+ handler
52
+ end
53
+
54
+ def empty?
55
+ @handlers.empty?
56
+ end
57
+
58
+ def run(event, context: nil)
59
+ event = normalize_event(event)
60
+ decisions = []
61
+ warnings = []
62
+ messages = []
63
+ payload = DeepCopy.dup(event.payload)
64
+
65
+ handlers_for(event).each do |handler|
66
+ decision = call_handler(handler, event, context)
67
+ decisions << decision
68
+ warnings << decision.message if decision.warning? && decision.message
69
+ messages << decision.message if decision.message && !decision.warning?
70
+
71
+ if decision.modify?
72
+ modifications = Catalog.filter_modifications(event.name, decision.payload)
73
+ payload = DeepCopy.merge(payload, modifications)
74
+ event = event_with_payload(event, payload)
75
+ end
76
+
77
+ break if decision.deny?
78
+ end
79
+
80
+ final_decision = final_decision(decisions)
81
+ result = Result.new(
82
+ event: event,
83
+ decision: final_decision,
84
+ decisions: decisions,
85
+ warnings: warnings,
86
+ messages: messages,
87
+ payload: payload
88
+ )
89
+ @audit_log&.log_result(event: event, result: result)
90
+ @on_result&.call(event, result) unless decisions.empty?
91
+ result
92
+ end
93
+
94
+ private
95
+
96
+ def normalize_event(event)
97
+ return event if event.is_a?(Event)
98
+
99
+ Event.new(**event)
100
+ end
101
+
102
+ def handlers_for(event)
103
+ @handlers
104
+ .select { |handler| handler.event == event.name && handler.match.match?(event) }
105
+ .sort_by { |handler| [handler.order, handler.id.to_s] }
106
+ end
107
+
108
+ def call_handler(handler, event, context)
109
+ started_at = @audit_log&.monotonic_now
110
+ value = if handler.callback.arity == 1
111
+ handler.callback.call(event)
112
+ else
113
+ handler.callback.call(event, context)
114
+ end
115
+ Decision.normalize(value).tap do |decision|
116
+ log_handler(event, handler, decision, started_at)
117
+ end
118
+ rescue StandardError => e
119
+ Catalog.failure_decision(
120
+ handler.failure_policy,
121
+ "Hook #{handler.id} failed: #{e.message}",
122
+ metadata: { hook_id: handler.id, source: handler.source }
123
+ ).tap do |decision|
124
+ log_handler(event, handler, decision, started_at)
125
+ end
126
+ end
127
+
128
+ def log_handler(event, handler, decision, started_at)
129
+ @audit_log&.log_handler(
130
+ event: event,
131
+ handler: handler,
132
+ decision: decision,
133
+ duration_ms: started_at ? @audit_log.duration_ms(started_at) : nil
134
+ )
135
+ end
136
+
137
+ def final_decision(decisions)
138
+ decisions.find(&:deny?) || decisions.find(&:ask?) || decisions.reverse.find(&:modify?) || decisions.find(&:warning?) || Decision.allow
139
+ end
140
+
141
+ def event_with_payload(event, payload)
142
+ Event.new(
143
+ id: event.id,
144
+ name: event.name,
145
+ phase: event.phase,
146
+ timestamp: event.timestamp,
147
+ session: event.session,
148
+ turn: event.turn,
149
+ workspace: event.workspace,
150
+ frontend: event.frontend,
151
+ agent: event.agent,
152
+ payload: payload
153
+ )
154
+ end
155
+
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,75 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ module Hooks
4
+ # Matches lifecycle hook selectors against event payloads.
5
+ class Matcher
6
+ def initialize(match = nil)
7
+ @match = match || {}
8
+ end
9
+
10
+ def match?(event)
11
+ return true if @match.empty?
12
+
13
+ @match.all? { |key, expected| matches_key?(event, key.to_s, expected) }
14
+ end
15
+
16
+ private
17
+
18
+ def matches_key?(event, key, expected)
19
+ case key
20
+ when "event", "name"
21
+ matches_value?(event.name, expected)
22
+ when "phase"
23
+ matches_value?(event.phase, expected)
24
+ when "tool", "tool_name"
25
+ matches_value?(payload_value(event, "tool_name") || payload_value(event, "tool"), expected)
26
+ when "mcp_server", "server"
27
+ matches_value?(payload_value(event, "server_name") || payload_value(event, "mcp_server"), expected)
28
+ when "mcp_tool", "remote_name"
29
+ matches_value?(payload_value(event, "remote_name") || payload_value(event, "mcp_tool"), expected)
30
+ when "operation"
31
+ matches_value?(payload_value(event, "operation"), expected)
32
+ when "frontend"
33
+ matches_value?(event.frontend[:name] || event.frontend["name"], expected)
34
+ when "provider"
35
+ matches_value?(event.agent[:provider] || event.agent["provider"], expected)
36
+ when "model"
37
+ matches_value?(event.agent[:model] || event.agent["model"], expected)
38
+ when "path", "paths"
39
+ matches_path?(payload_paths(event), expected)
40
+ when "command_regex"
41
+ value = payload_value(event, "command").to_s
42
+ Array(expected).any? { |pattern| Regexp.new(pattern.to_s).match?(value) }
43
+ else
44
+ matches_value?(payload_value(event, key), expected)
45
+ end
46
+ end
47
+
48
+ def payload_value(event, key)
49
+ event.payload[key.to_sym] || event.payload[key]
50
+ end
51
+
52
+ def payload_paths(event)
53
+ values = []
54
+ values << payload_value(event, "path")
55
+ values.concat(Array(payload_value(event, "paths")))
56
+ Array(payload_value(event, "files")).each do |file|
57
+ values << (file[:path] || file["path"]) if file.respond_to?(:[])
58
+ end
59
+ values.compact.map(&:to_s)
60
+ end
61
+
62
+ def matches_path?(paths, expected)
63
+ patterns = Array(expected).map(&:to_s)
64
+ paths.any? do |path|
65
+ patterns.any? { |pattern| File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB) }
66
+ end
67
+ end
68
+
69
+ def matches_value?(actual, expected)
70
+ expected_values = Array(expected).map(&:to_s)
71
+ expected_values.include?(actual.to_s)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,16 @@
1
+ require_relative "hooks/audit_log"
2
+ require_relative "hooks/catalog"
3
+ require_relative "hooks/decision"
4
+ require_relative "hooks/event"
5
+ require_relative "hooks/matcher"
6
+ require_relative "hooks/manager"
7
+ require_relative "hooks/command_handler"
8
+ require_relative "hooks/http_handler"
9
+ require_relative "hooks/config_loader"
10
+
11
+ # Namespace for the Kward CLI agent runtime.
12
+ module Kward
13
+ # Lifecycle hook primitives and dispatch helpers.
14
+ module Hooks
15
+ end
16
+ end
@@ -1,4 +1,5 @@
1
1
  require_relative "message_access"
2
+ require_relative "message_text"
2
3
 
3
4
  # Namespace for the Kward CLI agent runtime.
4
5
  module Kward
@@ -37,10 +38,10 @@ module Kward
37
38
  end
38
39
 
39
40
  def message_display_text(message)
40
- display_content = MessageAccess.value(message, :display_content) || MessageAccess.value(message, :displayContent)
41
- return display_content.to_s unless display_content.nil?
41
+ return MessageText.full_text(message) unless MessageAccess.display_content(message).nil?
42
42
 
43
- markdown_content(MessageAccess.content(message))
43
+ content = MessageAccess.content(message)
44
+ content.is_a?(Array) ? markdown_content(content) : MessageText.full_text(message)
44
45
  end
45
46
 
46
47
  def markdown_content(content)
@@ -8,7 +8,7 @@ module Kward
8
8
 
9
9
  # Minimal MCP client for discovering and invoking server tools.
10
10
  class Client
11
- attr_reader :name
11
+ attr_reader :name, :transport
12
12
 
13
13
  def initialize(name:, transport:)
14
14
  @name = name.to_s