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
@@ -2,9 +2,12 @@ require "digest"
2
2
  require "fileutils"
3
3
  require "json"
4
4
  require "yaml"
5
+ require_relative "frontmatter"
5
6
  require_relative "private_file"
7
+ require_relative "path_guard"
6
8
  require_relative "ekwsh"
7
9
  require_relative "editor_mode"
10
+ require_relative "diff_view_mode"
8
11
  require_relative "prompts/templates"
9
12
  require_relative "skills/registry"
10
13
 
@@ -41,7 +44,7 @@ module Kward
41
44
  {
42
45
  "key" => "kward",
43
46
  "label" => "Kward",
44
- "instruction" => "Your name is Kward, the grim Andruid - robotic keeper of the Forrest of Code, protecting the nature of good engineering priciples. Speak like an old druid, be suspicous of everyone, but with a good intend."
47
+ "instruction" => "Your name is Kward, the grim Andruid - robotic keeper of the Forest of Code, protecting the nature of good engineering principles. Speak like an old druid, be suspicious of everyone, but with good intent."
45
48
  }
46
49
  ],
47
50
  "default" => "kward"
@@ -49,7 +52,7 @@ module Kward
49
52
  OVERLAY_ALIGNMENTS = %w[left center right].freeze
50
53
  OVERLAY_WIDTHS = %w[capped maximum].freeze
51
54
 
52
- Skill = Struct.new(:name, :description, :folder, :path, keyword_init: true)
55
+ Skill = Struct.new(:name, :description, :folder, :path, :license, :compatibility, :metadata, :allowed_tools, keyword_init: true)
53
56
  PromptTemplate = Struct.new(:command, :description, :argument_hint, :body, :path, keyword_init: true) do
54
57
  def expand(arguments)
55
58
  body.gsub("$ARGUMENTS", arguments.to_s)
@@ -92,6 +95,14 @@ module Kward
92
95
  File.join(config_dir, "ekwsh.yml")
93
96
  end
94
97
 
98
+ def workspace_hooks_path(root = Dir.pwd)
99
+ File.join(File.expand_path(root), ".kward", "hooks.json")
100
+ end
101
+
102
+ def trusted_workspace_hooks_path
103
+ File.join(config_dir, "trusted_workspace_hooks.json")
104
+ end
105
+
95
106
  def default_config
96
107
  {
97
108
  "personas" => JSON.parse(JSON.generate(DEFAULT_PERSONAS)),
@@ -109,11 +120,21 @@ module Kward
109
120
  "auto_close_pairs" => true,
110
121
  "soft_wrap" => true,
111
122
  "bar_cursor" => true,
112
- "line_numbers" => "absolute"
123
+ "line_numbers" => "absolute",
124
+ "diff_view" => "auto"
125
+ },
126
+ "overlay" => DEFAULT_OVERLAY_SETTINGS.dup,
127
+ "web_search" => {
128
+ "enabled" => true,
129
+ "provider" => "auto",
130
+ "allow_model_providers" => false
113
131
  },
114
132
  "sessions" => {
115
133
  "auto_resume" => false
116
134
  },
135
+ "skills" => {
136
+ "trust_project" => false
137
+ },
117
138
  "enforce_workspace_agents_file" => false,
118
139
  "mcpServers" => {},
119
140
  "tools" => {
@@ -140,6 +161,10 @@ module Kward
140
161
  File.join(cache_dir, "openrouter_models.json")
141
162
  end
142
163
 
164
+ def update_check_cache_path
165
+ File.join(cache_dir, "update_check.json")
166
+ end
167
+
143
168
  def project_browser_state_path
144
169
  File.join(cache_dir, "project_browser_state.json")
145
170
  end
@@ -198,6 +223,72 @@ module Kward
198
223
  PrivateFile.write_json(path, config)
199
224
  end
200
225
 
226
+ def lifecycle_hooks_config(workspace_root = Dir.pwd)
227
+ config = read_config
228
+ workspace_config = read_trusted_workspace_hooks_config(workspace_root)
229
+ return config if workspace_config.empty?
230
+
231
+ merge_hooks_config(config, workspace_config)
232
+ end
233
+
234
+ def read_trusted_workspace_hooks_config(workspace_root = Dir.pwd)
235
+ path = workspace_hooks_path(workspace_root)
236
+ return {} unless workspace_hooks_trusted?(workspace_root)
237
+
238
+ read_config(path)
239
+ end
240
+
241
+ def workspace_hooks_trusted?(workspace_root = Dir.pwd)
242
+ path = workspace_hooks_path(workspace_root)
243
+ return false unless File.file?(path)
244
+
245
+ trusted = read_trusted_workspace_hooks
246
+ trusted[File.expand_path(path)] == workspace_hooks_digest(workspace_root)
247
+ end
248
+
249
+ def trust_workspace_hooks!(workspace_root = Dir.pwd)
250
+ path = workspace_hooks_path(workspace_root)
251
+ raise "No workspace hook config found: #{path}" unless File.file?(path)
252
+
253
+ trusted = read_trusted_workspace_hooks
254
+ trusted[File.expand_path(path)] = workspace_hooks_digest(workspace_root)
255
+ PrivateFile.write_json(trusted_workspace_hooks_path, trusted)
256
+ end
257
+
258
+ def untrust_workspace_hooks!(workspace_root = Dir.pwd)
259
+ path = File.expand_path(workspace_hooks_path(workspace_root))
260
+ trusted = read_trusted_workspace_hooks
261
+ trusted.delete(path)
262
+ PrivateFile.write_json(trusted_workspace_hooks_path, trusted)
263
+ end
264
+
265
+ def workspace_hooks_digest(workspace_root = Dir.pwd)
266
+ Digest::SHA256.file(workspace_hooks_path(workspace_root)).hexdigest
267
+ end
268
+
269
+ def read_trusted_workspace_hooks
270
+ config = read_config(trusted_workspace_hooks_path)
271
+ config.is_a?(Hash) ? config : {}
272
+ rescue ConfigError
273
+ {}
274
+ end
275
+
276
+ def merge_hooks_config(config, workspace_config)
277
+ merged = JSON.parse(JSON.generate(config || {}))
278
+ merged["hooks"] = merge_hook_maps(merged["hooks"], workspace_config["hooks"] || workspace_config[:hooks])
279
+ merged
280
+ end
281
+
282
+ def merge_hook_maps(left, right)
283
+ left = left.is_a?(Hash) ? left : {}
284
+ right = right.is_a?(Hash) ? right : {}
285
+ merged = JSON.parse(JSON.generate(left))
286
+ right.each do |event, entries|
287
+ merged[event.to_s] = Array(merged[event.to_s]) + Array(entries)
288
+ end
289
+ merged
290
+ end
291
+
201
292
  def read_ekwsh_config(path = ekwsh_config_path)
202
293
  path = File.expand_path(path)
203
294
  return normalize_ekwsh_config(nil) unless File.exist?(path)
@@ -364,12 +455,24 @@ module Kward
364
455
  EditorMode.normalize_line_numbers(editor["line_numbers"])
365
456
  end
366
457
 
458
+ # Returns the integrated diff viewer display mode.
459
+ def diff_view(config = read_config)
460
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
461
+ DiffViewMode.normalize(editor["diff_view"])
462
+ end
463
+
367
464
  # Returns whether file tools must stay inside the active workspace root.
368
465
  def workspace_guardrails_enabled?(config = read_config)
369
466
  tools = config["tools"].is_a?(Hash) ? config["tools"] : {}
370
467
  tools["workspace_guardrails"] != false
371
468
  end
372
469
 
470
+ # Returns whether project-level Agent Skills should be loaded from the workspace.
471
+ def project_skills_trusted?(config = read_config)
472
+ skills = config["skills"].is_a?(Hash) ? config["skills"] : {}
473
+ skills["trust_project"] == true
474
+ end
475
+
373
476
  # Returns whether new frontends should resume the last active session automatically.
374
477
  def session_auto_resume_enabled?(config = read_config)
375
478
  sessions = config["sessions"].is_a?(Hash) ? config["sessions"] : {}
@@ -399,7 +502,7 @@ module Kward
399
502
  raise "Overlay settings must be an object" unless values.is_a?(Hash)
400
503
 
401
504
  config = read_config
402
- overlay = config["overlay"].is_a?(Hash) ? config["overlay"].dup : {}
505
+ overlay = overlay_settings(config)
403
506
  values.each do |key, value|
404
507
  key = key.to_s
405
508
  value = value.to_s
@@ -674,8 +777,8 @@ module Kward
674
777
  # Lists configured skills discovered under the config directory.
675
778
  #
676
779
  # @return [Array<Skill>] skill metadata available to the model
677
- def skills
678
- skills_registry.skills
780
+ def skills(workspace_root: Dir.pwd)
781
+ skills_registry(workspace_root: workspace_root).skills
679
782
  end
680
783
 
681
784
  # @return [String] trusted user plugin directory
@@ -712,16 +815,18 @@ module Kward
712
815
  # @param name [String] configured skill name
713
816
  # @param relative_path [String, nil] path inside the skill directory
714
817
  # @return [String] file contents or an error string
715
- def read_skill_file(name, relative_path = nil)
716
- skills_registry.read_skill_file(name, relative_path)
818
+ def read_skill_file(name, relative_path = nil, workspace_root: Dir.pwd)
819
+ skills_registry(workspace_root: workspace_root).read_skill_file(name, relative_path)
717
820
  end
718
821
 
719
- def skills_registry
822
+ def skills_registry(workspace_root: Dir.pwd)
720
823
  Skills::Registry.new(
721
824
  config_dir: config_dir,
825
+ workspace_root: workspace_root,
826
+ project_skills_trusted: project_skills_trusted?,
722
827
  skill_class: Skill,
723
828
  max_file_bytes: MAX_SKILL_FILE_BYTES,
724
- markdown_parser: method(:markdown_parts),
829
+ markdown_parser: ->(path) { Frontmatter.markdown_parts(path, lenient: true) },
725
830
  inside_directory: method(:inside_directory?)
726
831
  )
727
832
  end
@@ -730,25 +835,12 @@ module Kward
730
835
  Prompts::Templates.new(
731
836
  config_dir: config_dir,
732
837
  template_class: PromptTemplate,
733
- markdown_parser: method(:markdown_parts)
838
+ markdown_parser: ->(path) { Frontmatter.markdown_parts(path) }
734
839
  )
735
840
  end
736
841
 
737
- def markdown_parts(path)
738
- content = File.read(path)
739
- return [{}, content] unless content.start_with?("---\n", "---\r\n")
740
-
741
- _opening, rest = content.split(/\A---\r?\n/, 2)
742
- yaml_text, body = rest.to_s.split(/\r?\n---\r?\n/, 2)
743
- raise "missing frontmatter closing delimiter" if body.nil?
744
-
745
- data = yaml_text.to_s.empty? ? {} : YAML.safe_load(yaml_text, permitted_classes: [], aliases: false)
746
- frontmatter = data.is_a?(Hash) ? data.transform_keys(&:to_s) : {}
747
- [frontmatter, body]
748
- end
749
-
750
842
  def inside_directory?(path, base)
751
- path == base || path.start_with?(base + File::SEPARATOR)
843
+ PathGuard.inside?(path, base)
752
844
  end
753
845
 
754
846
  def presence(value)
@@ -0,0 +1,42 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Small recursive copy/freeze helpers for plain Hash/Array payload objects.
4
+ module DeepCopy
5
+ module_function
6
+
7
+ def dup(value)
8
+ case value
9
+ when Hash
10
+ value.each_with_object({}) { |(key, item), result| result[key] = dup(item) }
11
+ when Array
12
+ value.map { |item| dup(item) }
13
+ else
14
+ value.dup
15
+ end
16
+ rescue TypeError
17
+ value
18
+ end
19
+
20
+ def freeze(value)
21
+ case value
22
+ when Hash
23
+ value.each_value { |item| freeze(item) }
24
+ when Array
25
+ value.each { |item| freeze(item) }
26
+ end
27
+ value.freeze
28
+ end
29
+
30
+ def merge(left, right)
31
+ left = dup(left)
32
+ right.each do |key, value|
33
+ left[key] = if left[key].is_a?(Hash) && value.is_a?(Hash)
34
+ merge(left[key], value)
35
+ else
36
+ dup(value)
37
+ end
38
+ end
39
+ left
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,36 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Normalizes and resolves the integrated diff viewer display mode.
4
+ module DiffViewMode
5
+ AUTO = "auto"
6
+ UNIFIED = "unified"
7
+ SIDE_BY_SIDE = "side_by_side"
8
+ MODES = [AUTO, UNIFIED, SIDE_BY_SIDE].freeze
9
+ SIDE_BY_SIDE_MIN_WIDTH = 120
10
+
11
+ module_function
12
+
13
+ def normalize(value)
14
+ text = value.to_s.downcase.tr("-", "_")
15
+ MODES.include?(text) ? text : AUTO
16
+ end
17
+
18
+ def label(value)
19
+ case normalize(value)
20
+ when SIDE_BY_SIDE
21
+ "side-by-side"
22
+ when UNIFIED
23
+ "unified"
24
+ else
25
+ "auto"
26
+ end
27
+ end
28
+
29
+ def resolve(value, terminal_width: nil)
30
+ mode = normalize(value)
31
+ return mode unless mode == AUTO
32
+
33
+ terminal_width.to_i >= SIDE_BY_SIDE_MIN_WIDTH ? SIDE_BY_SIDE : UNIFIED
34
+ end
35
+ end
36
+ end
data/lib/kward/events.rb CHANGED
@@ -9,6 +9,7 @@ module Kward
9
9
  Steering = Struct.new(:input, :created_at, keyword_init: true)
10
10
  SteeringApplied = Struct.new(:count, keyword_init: true)
11
11
  ToolCall = Struct.new(:tool_call, keyword_init: true)
12
+ ToolUpdate = Struct.new(:tool_call, :content, :elapsed_ms, keyword_init: true)
12
13
  ToolResult = Struct.new(:tool_call, :content, keyword_init: true)
13
14
  Answer = Struct.new(:content, keyword_init: true)
14
15
  end
@@ -1,4 +1,5 @@
1
1
  require "pathname"
2
+ require_relative "path_guard"
2
3
 
3
4
  # Namespace for the Kward CLI agent runtime.
4
5
  module Kward
@@ -11,7 +12,7 @@ module Kward
11
12
  resolved = File.expand_path(explicit, workspace_root)
12
13
  allowed_roots = [workspace_root, session_dir].compact.map { |root| Pathname.new(root).expand_path }
13
14
  expanded = Pathname.new(resolved).expand_path
14
- unless allowed_roots.any? { |root| inside?(expanded, root) }
15
+ unless allowed_roots.any? { |root| PathGuard.inside?(expanded, root) }
15
16
  raise ArgumentError, "export path outside workspace or session directory: #{path}"
16
17
  end
17
18
 
@@ -21,10 +22,5 @@ module Kward
21
22
 
22
23
  resolved
23
24
  end
24
-
25
- def self.inside?(path, root)
26
- path.to_s == root.to_s || path.to_s.start_with?("#{root}/")
27
- end
28
- private_class_method :inside?
29
25
  end
30
26
  end
@@ -0,0 +1,43 @@
1
+ require "yaml"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Markdown frontmatter parsing shared by prompt templates and skills.
6
+ module Frontmatter
7
+ module_function
8
+
9
+ def markdown_parts(path, lenient: false)
10
+ content = File.read(path)
11
+ return [{}, content] unless content.start_with?("---\n", "---\r\n")
12
+
13
+ _opening, rest = content.split(/\A---\r?\n/, 2)
14
+ yaml_text, body = rest.to_s.split(/\r?\n---\r?\n/, 2)
15
+ raise "missing frontmatter closing delimiter" if body.nil?
16
+
17
+ data = yaml_text.to_s.empty? ? {} : load_frontmatter(yaml_text, lenient: lenient)
18
+ frontmatter = data.is_a?(Hash) ? data.transform_keys(&:to_s) : {}
19
+ [frontmatter, body]
20
+ end
21
+
22
+ def load_frontmatter(yaml_text, lenient: false)
23
+ YAML.safe_load(yaml_text, permitted_classes: [], aliases: false)
24
+ rescue Psych::SyntaxError
25
+ raise unless lenient
26
+
27
+ lenient_frontmatter(yaml_text)
28
+ end
29
+
30
+ def lenient_frontmatter(yaml_text)
31
+ yaml_text.each_line.each_with_object({}) do |line, result|
32
+ match = line.chomp.match(/\A([A-Za-z0-9_-]+):\s*(.*)\z/)
33
+ next unless match
34
+
35
+ key = match[1]
36
+ value = match[2].strip
37
+ next if value.empty?
38
+
39
+ result[key] = value.delete_prefix('"').delete_suffix('"')
40
+ end
41
+ end
42
+ end
43
+ end
@@ -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