kward 0.73.1 → 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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -2
  5. data/Rakefile +18 -0
  6. data/doc/configuration.md +70 -4
  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/mcp.md +72 -0
  11. data/doc/memory.md +1 -1
  12. data/doc/personas.md +2 -2
  13. data/doc/plugins.md +1 -1
  14. data/doc/releasing.md +7 -15
  15. data/doc/rpc.md +108 -15
  16. data/doc/session-management.md +9 -9
  17. data/doc/skills.md +199 -0
  18. data/doc/usage.md +14 -7
  19. data/lib/kward/agent.rb +72 -22
  20. data/lib/kward/auth/anthropic_oauth.rb +5 -87
  21. data/lib/kward/auth/oauth_helpers.rb +105 -0
  22. data/lib/kward/auth/openai_oauth.rb +4 -86
  23. data/lib/kward/cli/commands.rb +12 -2
  24. data/lib/kward/cli/compaction.rb +7 -0
  25. data/lib/kward/cli/doctor.rb +18 -10
  26. data/lib/kward/cli/git.rb +30 -3
  27. data/lib/kward/cli/hook_commands.rb +258 -0
  28. data/lib/kward/cli/interactive_turn.rb +9 -3
  29. data/lib/kward/cli/memory_commands.rb +1 -5
  30. data/lib/kward/cli/plugins.rb +41 -1
  31. data/lib/kward/cli/prompt_interface.rb +32 -3
  32. data/lib/kward/cli/rendering.rb +1 -1
  33. data/lib/kward/cli/runtime_helpers.rb +44 -5
  34. data/lib/kward/cli/sessions.rb +13 -0
  35. data/lib/kward/cli/settings.rb +226 -152
  36. data/lib/kward/cli/slash_commands.rb +81 -42
  37. data/lib/kward/cli/tabs.rb +32 -15
  38. data/lib/kward/cli.rb +64 -4
  39. data/lib/kward/cli_transcript_formatter.rb +0 -7
  40. data/lib/kward/compaction/token_estimator.rb +112 -0
  41. data/lib/kward/compactor.rb +42 -116
  42. data/lib/kward/config_files.rb +152 -27
  43. data/lib/kward/deep_copy.rb +42 -0
  44. data/lib/kward/diff_view_mode.rb +36 -0
  45. data/lib/kward/events.rb +1 -0
  46. data/lib/kward/export_path.rb +2 -6
  47. data/lib/kward/frontmatter.rb +43 -0
  48. data/lib/kward/hooks/audit_log.rb +121 -0
  49. data/lib/kward/hooks/catalog.rb +110 -0
  50. data/lib/kward/hooks/command_handler.rb +66 -0
  51. data/lib/kward/hooks/config_loader.rb +104 -0
  52. data/lib/kward/hooks/decision.rb +102 -0
  53. data/lib/kward/hooks/event.rb +60 -0
  54. data/lib/kward/hooks/http_handler.rb +72 -0
  55. data/lib/kward/hooks/manager.rb +158 -0
  56. data/lib/kward/hooks/matcher.rb +75 -0
  57. data/lib/kward/hooks.rb +16 -0
  58. data/lib/kward/markdown_transcript.rb +4 -3
  59. data/lib/kward/mcp/client.rb +56 -0
  60. data/lib/kward/mcp/server_config.rb +59 -0
  61. data/lib/kward/mcp/stdio_transport.rb +112 -0
  62. data/lib/kward/memory/turn_context.rb +19 -0
  63. data/lib/kward/message_access.rb +2 -2
  64. data/lib/kward/message_text.rb +19 -0
  65. data/lib/kward/model/client.rb +13 -45
  66. data/lib/kward/model/context_usage.rb +10 -4
  67. data/lib/kward/model/copilot_models.rb +44 -0
  68. data/lib/kward/model/model_info.rb +24 -0
  69. data/lib/kward/model/payloads.rb +15 -17
  70. data/lib/kward/model/stream_parser.rb +178 -13
  71. data/lib/kward/pan/server.rb +51 -2
  72. data/lib/kward/path_guard.rb +13 -0
  73. data/lib/kward/plugin_registry.rb +86 -25
  74. data/lib/kward/prompt_interface/editor/controller.rb +124 -4
  75. data/lib/kward/prompt_interface/editor/modes/emacs.rb +4 -0
  76. data/lib/kward/prompt_interface/editor/modes/modern.rb +10 -4
  77. data/lib/kward/prompt_interface/editor/modes/vibe.rb +448 -20
  78. data/lib/kward/prompt_interface/editor/renderer.rb +27 -0
  79. data/lib/kward/prompt_interface/editor/search.rb +70 -13
  80. data/lib/kward/prompt_interface/editor/state.rb +72 -13
  81. data/lib/kward/prompt_interface/editor/vibe_state.rb +12 -1
  82. data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
  83. data/lib/kward/prompt_interface.rb +4 -1
  84. data/lib/kward/prompts/commands.rb +3 -3
  85. data/lib/kward/prompts.rb +7 -7
  86. data/lib/kward/rpc/mcp_status.rb +50 -0
  87. data/lib/kward/rpc/prompt_bridge.rb +38 -4
  88. data/lib/kward/rpc/runtime_payloads.rb +1 -1
  89. data/lib/kward/rpc/server.rb +94 -13
  90. data/lib/kward/rpc/session_manager.rb +258 -33
  91. data/lib/kward/rpc/session_metrics.rb +1 -1
  92. data/lib/kward/rpc/session_tree_rows.rb +13 -64
  93. data/lib/kward/rpc/tool_event_normalizer.rb +16 -2
  94. data/lib/kward/rpc/transcript_normalizer.rb +9 -10
  95. data/lib/kward/rpc/turn_context.rb +109 -0
  96. data/lib/kward/session_store.rb +6 -20
  97. data/lib/kward/session_tree_nodes.rb +98 -18
  98. data/lib/kward/session_tree_renderer.rb +6 -61
  99. data/lib/kward/skills/registry.rb +109 -17
  100. data/lib/kward/tools/mcp_tool.rb +122 -0
  101. data/lib/kward/tools/read_skill.rb +9 -4
  102. data/lib/kward/tools/registry.rb +297 -9
  103. data/lib/kward/tools/search/code.rb +4 -22
  104. data/lib/kward/tools/search/web.rb +28 -2
  105. data/lib/kward/update_check.rb +117 -0
  106. data/lib/kward/version.rb +1 -1
  107. data/lib/kward/workers/manager.rb +43 -3
  108. data/lib/kward/workspace.rb +3 -1
  109. data/templates/default/fulldoc/html/css/kward.css +22 -0
  110. data/templates/default/fulldoc/html/js/kward.js +2 -0
  111. data/templates/default/kward_navigation.rb +4 -2
  112. data/templates/default/layout/html/setup.rb +2 -0
  113. metadata +30 -1
@@ -0,0 +1,121 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "time"
4
+ require_relative "../config_files"
5
+ require_relative "../rpc/redactor"
6
+
7
+ # Namespace for the Kward CLI agent runtime.
8
+ module Kward
9
+ module Hooks
10
+ # Append-only JSONL audit log for lifecycle hook activity.
11
+ class AuditLog
12
+ DEFAULT_MAX_BYTES = 10 * 1024 * 1024
13
+
14
+ def initialize(path: nil, config_path: ConfigFiles.config_path, max_bytes: DEFAULT_MAX_BYTES, clock: Time, monotonic_clock: Process, error_output: $stderr)
15
+ @path = path
16
+ @config_path = config_path
17
+ @max_bytes = max_bytes.to_i.positive? ? max_bytes.to_i : DEFAULT_MAX_BYTES
18
+ @clock = clock
19
+ @monotonic_clock = monotonic_clock
20
+ @error_output = error_output
21
+ @mutex = Mutex.new
22
+ @warned = false
23
+ end
24
+
25
+ def monotonic_now
26
+ @monotonic_clock.clock_gettime(Process::CLOCK_MONOTONIC)
27
+ end
28
+
29
+ def duration_ms(started_at)
30
+ ((monotonic_now - started_at.to_f) * 1000).round(1)
31
+ end
32
+
33
+ def log_handler(event:, handler:, decision:, duration_ms: nil)
34
+ write_record(
35
+ "kind" => "handler",
36
+ "event_id" => event.id,
37
+ "event" => event.name,
38
+ "phase" => event.phase,
39
+ "hook_id" => handler.id,
40
+ "source" => handler.source,
41
+ "decision" => decision.decision,
42
+ "message" => redact_string(decision.message),
43
+ "duration_ms" => duration_ms,
44
+ "payload_keys" => safe_keys(event.payload),
45
+ "modified_keys" => decision.modify? ? safe_keys(decision.payload) : []
46
+ )
47
+ end
48
+
49
+ def log_result(event:, result:)
50
+ write_record(
51
+ "kind" => "result",
52
+ "event_id" => event.id,
53
+ "event" => event.name,
54
+ "phase" => event.phase,
55
+ "decision" => result.decision.decision,
56
+ "message" => redact_string(result.decision.message),
57
+ "handler_count" => result.decisions.length,
58
+ "warnings" => result.warnings.map { |warning| redact_string(warning) },
59
+ "messages" => result.messages.map { |message| redact_string(message) },
60
+ "payload_keys" => safe_keys(result.payload)
61
+ )
62
+ end
63
+
64
+ private
65
+
66
+ def write_record(payload)
67
+ @mutex.synchronize do
68
+ path = current_path
69
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
70
+ File.open(path, File::WRONLY | File::CREAT | File::APPEND, 0o600) do |file|
71
+ file.write(JSON.generate(payload.compact.merge("timestamp" => @clock.now.utc.iso8601(3))))
72
+ file.write("\n")
73
+ end
74
+ File.chmod(0o600, path)
75
+ end
76
+ true
77
+ rescue StandardError => e
78
+ warn_once(e)
79
+ false
80
+ end
81
+
82
+ def current_path
83
+ explicit_path = @path || File.join(File.dirname(File.expand_path(@config_path)), "logs", "hooks.jsonl")
84
+ return explicit_path if writable_log_path?(explicit_path)
85
+
86
+ index = 1
87
+ loop do
88
+ path = explicit_path.sub(/\.jsonl\z/, "-#{index}.jsonl")
89
+ return path if writable_log_path?(path)
90
+
91
+ index += 1
92
+ end
93
+ end
94
+
95
+ def writable_log_path?(path)
96
+ !File.exist?(path) || File.size(path) < @max_bytes
97
+ end
98
+
99
+ def safe_keys(value)
100
+ return [] unless value.is_a?(Hash)
101
+
102
+ value.keys.map(&:to_s).sort
103
+ end
104
+
105
+ def redact_string(value)
106
+ return nil if value.nil?
107
+
108
+ RPC::Redactor.redact_string(value.to_s)[0, 500]
109
+ end
110
+
111
+ def warn_once(error)
112
+ return if @warned
113
+
114
+ @warned = true
115
+ @error_output&.puts("Warning: hook audit logging failed: #{error.message}")
116
+ rescue StandardError
117
+ nil
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,110 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ module Hooks
4
+ # Known lifecycle hook events and their safety defaults.
5
+ module Catalog
6
+ DEFAULT_FAILURE_POLICY = "warn"
7
+ VALID_FAILURE_POLICIES = %w[allow warn deny ask].freeze
8
+
9
+ EventDefinition = Struct.new(:name, :failure_policy, :modifiable_fields, keyword_init: true)
10
+
11
+ DEFINITIONS = [
12
+ EventDefinition.new(name: "turn_start", failure_policy: "warn", modifiable_fields: %w[input display_input]),
13
+ EventDefinition.new(name: "turn_context_build_before", failure_policy: "warn"),
14
+ EventDefinition.new(name: "turn_context_build_after", failure_policy: "warn"),
15
+ EventDefinition.new(name: "model_request_before", failure_policy: "warn", modifiable_fields: %w[messages tools provider model reasoning]),
16
+ EventDefinition.new(name: "turn_model_request_before", failure_policy: "warn"),
17
+ EventDefinition.new(name: "model_response_after_parse", failure_policy: "warn"),
18
+ EventDefinition.new(name: "turn_model_response_complete", failure_policy: "warn"),
19
+ EventDefinition.new(name: "turn_end", failure_policy: "warn"),
20
+ EventDefinition.new(name: "session_create", failure_policy: "warn"),
21
+ EventDefinition.new(name: "session_resume", failure_policy: "warn"),
22
+ EventDefinition.new(name: "session_clone", failure_policy: "warn"),
23
+ EventDefinition.new(name: "session_fork", failure_policy: "warn"),
24
+ EventDefinition.new(name: "session_rename", failure_policy: "warn"),
25
+ EventDefinition.new(name: "session_export_before", failure_policy: "deny"),
26
+ EventDefinition.new(name: "session_export_after", failure_policy: "warn"),
27
+ EventDefinition.new(name: "session_compact_before", failure_policy: "deny"),
28
+ EventDefinition.new(name: "session_compact_after", failure_policy: "warn"),
29
+ EventDefinition.new(name: "tool_output_compact_before", failure_policy: "warn"),
30
+ EventDefinition.new(name: "tool_output_compact_after", failure_policy: "warn"),
31
+ EventDefinition.new(name: "tool_call_before", failure_policy: "deny", modifiable_fields: %w[arguments]),
32
+ EventDefinition.new(name: "tool_call_after", failure_policy: "warn"),
33
+ EventDefinition.new(name: "tool_call_error", failure_policy: "warn"),
34
+ EventDefinition.new(name: "mcp_tool_before", failure_policy: "deny"),
35
+ EventDefinition.new(name: "mcp_tool_after", failure_policy: "warn"),
36
+ EventDefinition.new(name: "mcp_tool_error", failure_policy: "warn"),
37
+ EventDefinition.new(name: "shell_command_before", failure_policy: "deny", modifiable_fields: %w[command timeout_seconds]),
38
+ EventDefinition.new(name: "shell_command_after", failure_policy: "warn"),
39
+ EventDefinition.new(name: "file_change_before", failure_policy: "deny"),
40
+ EventDefinition.new(name: "file_change_after", failure_policy: "warn"),
41
+ EventDefinition.new(name: "git_status_after", failure_policy: "warn"),
42
+ EventDefinition.new(name: "git_diff_before", failure_policy: "deny"),
43
+ EventDefinition.new(name: "git_diff_after", failure_policy: "warn"),
44
+ EventDefinition.new(name: "git_stage_before", failure_policy: "deny"),
45
+ EventDefinition.new(name: "git_stage_after", failure_policy: "warn"),
46
+ EventDefinition.new(name: "git_commit_before", failure_policy: "deny"),
47
+ EventDefinition.new(name: "git_commit_after", failure_policy: "warn"),
48
+ EventDefinition.new(name: "worker_job_create", failure_policy: "warn"),
49
+ EventDefinition.new(name: "worker_job_start_before", failure_policy: "warn"),
50
+ EventDefinition.new(name: "worker_job_start_after", failure_policy: "warn"),
51
+ EventDefinition.new(name: "worker_job_ready_for_review", failure_policy: "warn"),
52
+ EventDefinition.new(name: "worker_job_failed", failure_policy: "warn")
53
+ ].each_with_object({}) { |definition, result| result[definition.name] = definition }.freeze
54
+
55
+ module_function
56
+
57
+ def event_names
58
+ DEFINITIONS.keys.sort
59
+ end
60
+
61
+ def definition(event_name)
62
+ DEFINITIONS[event_name.to_s]
63
+ end
64
+
65
+ def known?(event_name)
66
+ DEFINITIONS.key?(event_name.to_s)
67
+ end
68
+
69
+ def failure_policy(event_name, explicit_policy = nil)
70
+ normalize_failure_policy(explicit_policy || definition(event_name)&.failure_policy || DEFAULT_FAILURE_POLICY)
71
+ end
72
+
73
+ def normalize_failure_policy(policy)
74
+ value = policy.to_s
75
+ return value if VALID_FAILURE_POLICIES.include?(value)
76
+
77
+ raise ArgumentError, "Unknown hook failure policy: #{policy}"
78
+ end
79
+
80
+ def modifiable_fields(event_name)
81
+ Array(definition(event_name)&.modifiable_fields)
82
+ end
83
+
84
+ def filter_modifications(event_name, payload)
85
+ definition = definition(event_name)
86
+ return payload unless definition
87
+
88
+ fields = Array(definition.modifiable_fields)
89
+ return {} if fields.empty?
90
+
91
+ payload.each_with_object({}) do |(key, value), result|
92
+ result[key] = value if fields.include?(key.to_s)
93
+ end
94
+ end
95
+
96
+ def failure_decision(policy, message, metadata: nil)
97
+ case normalize_failure_policy(policy)
98
+ when "allow"
99
+ Decision.allow(message, metadata: metadata)
100
+ when "warn"
101
+ Decision.warn(message, metadata: metadata)
102
+ when "deny"
103
+ Decision.deny(message, metadata: metadata)
104
+ when "ask"
105
+ Decision.ask(message, metadata: metadata)
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -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