kward 0.75.0 → 0.77.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +8 -1
  3. data/CHANGELOG.md +48 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +12 -4
  6. data/doc/agent-tools.md +0 -3
  7. data/doc/api.md +4 -0
  8. data/doc/composer.md +219 -0
  9. data/doc/configuration.md +87 -27
  10. data/doc/editor.md +18 -2
  11. data/doc/files.md +1 -1
  12. data/doc/getting-started.md +4 -0
  13. data/doc/git.md +2 -2
  14. data/doc/lifecycle-hooks.md +2 -14
  15. data/doc/pan.md +167 -0
  16. data/doc/plugins.md +1 -0
  17. data/doc/rpc.md +3 -22
  18. data/doc/security.md +180 -0
  19. data/doc/usage.md +15 -32
  20. data/lib/kward/agent.rb +5 -0
  21. data/lib/kward/ansi.rb +2 -2
  22. data/lib/kward/cancellation.rb +2 -2
  23. data/lib/kward/cli/commands.rb +1 -3
  24. data/lib/kward/cli/interactive_turn.rb +3 -17
  25. data/lib/kward/cli/plugins.rb +2 -7
  26. data/lib/kward/cli/prompt_interface.rb +10 -17
  27. data/lib/kward/cli/rendering.rb +7 -3
  28. data/lib/kward/cli/runtime_helpers.rb +0 -38
  29. data/lib/kward/cli/sessions.rb +14 -5
  30. data/lib/kward/cli/settings.rb +22 -0
  31. data/lib/kward/cli/slash_commands.rb +1 -561
  32. data/lib/kward/cli/tabs.rb +14 -2
  33. data/lib/kward/cli.rb +1 -17
  34. data/lib/kward/cli_transcript_formatter.rb +21 -11
  35. data/lib/kward/compactor.rb +3 -3
  36. data/lib/kward/config_files.rb +21 -0
  37. data/lib/kward/ekwsh.rb +0 -4
  38. data/lib/kward/events.rb +1 -0
  39. data/lib/kward/hooks/catalog.rb +0 -5
  40. data/lib/kward/image_attachments.rb +1 -1
  41. data/lib/kward/model/client.rb +12 -9
  42. data/lib/kward/model/model_info.rb +14 -5
  43. data/lib/kward/model/stream_parser.rb +12 -12
  44. data/lib/kward/pan/index.html.erb +460 -64
  45. data/lib/kward/pan/kward_logo.png +0 -0
  46. data/lib/kward/pan/server.rb +250 -23
  47. data/lib/kward/plugin_registry.rb +20 -0
  48. data/lib/kward/prompt_interface/editor/auto_indent.rb +31 -23
  49. data/lib/kward/prompt_interface/editor/modes/vibe.rb +72 -3
  50. data/lib/kward/prompt_interface/layout.rb +0 -4
  51. data/lib/kward/prompt_interface/project_browser.rb +40 -1
  52. data/lib/kward/prompt_interface/runtime_state.rb +4 -0
  53. data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
  54. data/lib/kward/prompt_interface/transcript_renderer.rb +1 -13
  55. data/lib/kward/prompt_interface.rb +18 -1
  56. data/lib/kward/prompts/commands.rb +1 -3
  57. data/lib/kward/prompts.rb +20 -0
  58. data/lib/kward/rpc/server.rb +14 -42
  59. data/lib/kward/rpc/session_manager.rb +77 -25
  60. data/lib/kward/rpc/tool_event_normalizer.rb +2 -0
  61. data/lib/kward/rpc/tool_metadata.rb +23 -0
  62. data/lib/kward/rpc/transcript_normalizer.rb +14 -10
  63. data/lib/kward/session_store.rb +55 -16
  64. data/lib/kward/skills/registry.rb +21 -1
  65. data/lib/kward/tools/base.rb +14 -0
  66. data/lib/kward/tools/registry.rb +11 -16
  67. data/lib/kward/tools/search/code.rb +1 -1
  68. data/lib/kward/tools/search/web.rb +1 -1
  69. data/lib/kward/tools/tool_call.rb +0 -4
  70. data/lib/kward/transcript_export.rb +1 -1
  71. data/lib/kward/update_check.rb +12 -7
  72. data/lib/kward/version.rb +1 -1
  73. data/templates/default/kward_navigation.rb +5 -1
  74. data/templates/default/layout/html/layout.erb +0 -2
  75. metadata +5 -12
  76. data/lib/kward/workers/git_guard.rb +0 -93
  77. data/lib/kward/workers/job.rb +0 -99
  78. data/lib/kward/workers/live_view.rb +0 -49
  79. data/lib/kward/workers/manager.rb +0 -328
  80. data/lib/kward/workers/queue_runner.rb +0 -166
  81. data/lib/kward/workers/queue_store.rb +0 -112
  82. data/lib/kward/workers/store.rb +0 -72
  83. data/lib/kward/workers/tool_policy.rb +0 -23
  84. data/lib/kward/workers/worker.rb +0 -82
  85. data/lib/kward/workers/write_lock.rb +0 -38
  86. data/lib/kward/workers.rb +0 -10
@@ -9,6 +9,7 @@ module KwardDocsNavigationData
9
9
  ["Usage", "file.usage.html"],
10
10
  ["Configuration", "file.configuration.html"],
11
11
  ["Authentication", "file.authentication.html"],
12
+ ["Security and trust", "file.security.html"],
12
13
  ["Troubleshooting", "file.troubleshooting.html"]
13
14
  ]
14
15
  ],
@@ -16,10 +17,12 @@ module KwardDocsNavigationData
16
17
  "Feature guides",
17
18
  [
18
19
  ["Sessions", "file.session-management.html"],
20
+ ["Interactive composer", "file.composer.html"],
19
21
  ["Tabs", "file.tabs.html"],
20
22
  ["Memory", "file.memory.html"],
21
23
  ["Personas", "file.personas.html"],
22
- ["Skills", "file.skills.html"]
24
+ ["Skills", "file.skills.html"],
25
+ ["Pan mode", "file.pan.html"]
23
26
  ]
24
27
  ],
25
28
  [
@@ -45,6 +48,7 @@ module KwardDocsNavigationData
45
48
  [
46
49
  "Integrate",
47
50
  [
51
+ ["MCP servers", "file.mcp.html"],
48
52
  ["RPC protocol", "file.rpc.html"],
49
53
  ["Releasing", "file.releasing.html"]
50
54
  ]
@@ -110,7 +110,6 @@
110
110
  <article><strong>⌘</strong><h2>Project-aware agent loop</h2><p>Inspect, reason, patch, verify.</p></article>
111
111
  <article><strong>▤</strong><h2>Unix filter mode</h2><p>Pipe diffs, logs, and code through Kward.</p></article>
112
112
  <article><strong>▰</strong><h2>Branchable sessions</h2><p>Resume, rewind, fork, compact, export.</p></article>
113
- <article><strong>›_</strong><h2>Worker pipelines</h2><p>Queue focused agent tasks from the TUI.</p></article>
114
113
  <article><strong>◎</strong><h2>Live source intelligence</h2><p>Search the web and read public repos.</p></article>
115
114
  <article><strong>✣</strong><h2>Ruby-native extensions</h2><p>Shape Kward with plugins, skills, and prompts.</p></article>
116
115
  </section>
@@ -137,7 +136,6 @@
137
136
  <li>Review diffs, stage files, and commit changes from the integrated Git workflow.</li>
138
137
  <li>Pipe diffs, logs, code, and generated text through Kward as a Unix filter.</li>
139
138
  <li>Organize long-running work with tabs, sessions, rewind, forks, compaction, and exports.</li>
140
- <li>Dispatch worker tasks, leave them running, review the results later.</li>
141
139
  <li>Ground answers with live web search and public source repositories before guessing.</li>
142
140
  <li>Shape Kward with memory, skills, prompts, personas, and trusted Ruby plugins.</li>
143
141
  <li>Build custom UIs on top of Kward’s JSON-RPC backend.</li>
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kward
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.75.0
4
+ version: 0.77.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kai Wood
@@ -129,6 +129,7 @@ files:
129
129
  - doc/api.md
130
130
  - doc/authentication.md
131
131
  - doc/code-search.md
132
+ - doc/composer.md
132
133
  - doc/configuration.md
133
134
  - doc/context-budgeting.md
134
135
  - doc/context-tools.md
@@ -140,10 +141,12 @@ files:
140
141
  - doc/lifecycle-hooks.md
141
142
  - doc/mcp.md
142
143
  - doc/memory.md
144
+ - doc/pan.md
143
145
  - doc/personas.md
144
146
  - doc/plugins.md
145
147
  - doc/releasing.md
146
148
  - doc/rpc.md
149
+ - doc/security.md
147
150
  - doc/session-management.md
148
151
  - doc/shell.md
149
152
  - doc/skills.md
@@ -234,6 +237,7 @@ files:
234
237
  - lib/kward/model/stream_parser.rb
235
238
  - lib/kward/openrouter_model_cache.rb
236
239
  - lib/kward/pan/index.html.erb
240
+ - lib/kward/pan/kward_logo.png
237
241
  - lib/kward/pan/server.rb
238
242
  - lib/kward/path_guard.rb
239
243
  - lib/kward/plugin_registry.rb
@@ -348,17 +352,6 @@ files:
348
352
  - lib/kward/transcript_export.rb
349
353
  - lib/kward/update_check.rb
350
354
  - lib/kward/version.rb
351
- - lib/kward/workers.rb
352
- - lib/kward/workers/git_guard.rb
353
- - lib/kward/workers/job.rb
354
- - lib/kward/workers/live_view.rb
355
- - lib/kward/workers/manager.rb
356
- - lib/kward/workers/queue_runner.rb
357
- - lib/kward/workers/queue_store.rb
358
- - lib/kward/workers/store.rb
359
- - lib/kward/workers/tool_policy.rb
360
- - lib/kward/workers/worker.rb
361
- - lib/kward/workers/write_lock.rb
362
355
  - lib/kward/workspace.rb
363
356
  - lib/main.rb
364
357
  - templates/default/fulldoc/html/css/kward.css
@@ -1,93 +0,0 @@
1
- require "open3"
2
-
3
- module Kward
4
- module Workers
5
- # Small git boundary used by write-lane workers to keep implementation work isolated.
6
- class GitGuard
7
- def initialize(root: Dir.pwd)
8
- @root = root.to_s
9
- end
10
-
11
- def repository?
12
- success?("rev-parse", "--is-inside-work-tree")
13
- end
14
-
15
- def clean?
16
- return true unless repository?
17
-
18
- status.empty?
19
- end
20
-
21
- def dirty?
22
- !clean?
23
- end
24
-
25
- def status
26
- run("status", "--porcelain").stdout
27
- end
28
-
29
- def head
30
- result = run("rev-parse", "--verify", "HEAD")
31
- result.success? ? result.stdout.strip : nil
32
- end
33
-
34
- def commit_all(message)
35
- add = run("add", "-A")
36
- return Result.new(success: false, stdout: add.stdout, stderr: add.stderr) unless add.success?
37
-
38
- commit = run("commit", "-m", message)
39
- return Result.new(success: false, stdout: commit.stdout, stderr: commit.stderr) unless commit.success?
40
-
41
- Result.new(success: true, stdout: commit.stdout, stderr: commit.stderr, commit: head)
42
- end
43
-
44
- def stash(message)
45
- before = stash_refs
46
- result = run("stash", "push", "--include-untracked", "-m", message)
47
- return Result.new(success: false, stdout: result.stdout, stderr: result.stderr) unless result.success?
48
- return Result.new(success: true, stdout: result.stdout, stderr: result.stderr) if result.stdout.include?("No local changes")
49
-
50
- ref = (stash_refs - before).first || stash_refs.first
51
- Result.new(success: true, stdout: result.stdout, stderr: result.stderr, commit: ref)
52
- end
53
-
54
- def apply_stash(ref)
55
- run("stash", "apply", ref.to_s)
56
- end
57
-
58
- def drop_stash(ref)
59
- run("stash", "drop", ref.to_s)
60
- end
61
-
62
- private
63
-
64
- Result = Struct.new(:success, :stdout, :stderr, :commit, keyword_init: true) do
65
- def success?
66
- success
67
- end
68
-
69
- def output
70
- [stdout, stderr].compact.reject(&:empty?).join("\n")
71
- end
72
- end
73
-
74
- def success?(*args)
75
- run(*args).success?
76
- end
77
-
78
- def stash_refs
79
- result = run("stash", "list", "--format=%gd")
80
- return [] unless result.success?
81
-
82
- result.stdout.lines.map(&:strip).reject(&:empty?)
83
- end
84
-
85
- def run(*args)
86
- stdout, stderr, status = Open3.capture3("git", "-C", @root, *args)
87
- Result.new(success: status.success?, stdout: stdout.to_s, stderr: stderr.to_s)
88
- rescue Errno::ENOENT
89
- Result.new(success: false, stdout: "", stderr: "git executable not found")
90
- end
91
- end
92
- end
93
- end
@@ -1,99 +0,0 @@
1
- require "securerandom"
2
- require "time"
3
- require_relative "../config_files"
4
-
5
- module Kward
6
- module Workers
7
- # Persistent queue entry for a session-backed worker.
8
- class Job
9
- STATUSES = %w[queued running suspended ready_for_review failed blocked cancelled archived].freeze
10
-
11
- def initialize(id: SecureRandom.hex(4), title:, session_path:, workspace_root: Dir.pwd, status: "queued", position: nil, commit_sha: nil, stash_ref: nil, error: nil, enqueued_at: Time.now.utc, started_at: nil, finished_at: nil, updated_at: nil)
12
- @id = id.to_s
13
- @title = title.to_s
14
- @session_path = session_path.to_s
15
- @workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
16
- @status = status.to_s
17
- @position = position
18
- @commit_sha = commit_sha
19
- @stash_ref = stash_ref
20
- @error = error
21
- @enqueued_at = enqueued_at
22
- @started_at = started_at
23
- @finished_at = finished_at
24
- @updated_at = updated_at || enqueued_at
25
- end
26
-
27
- attr_reader :id, :title, :session_path, :workspace_root, :position, :commit_sha, :stash_ref, :error, :enqueued_at, :started_at, :finished_at, :updated_at
28
-
29
- def status
30
- @status
31
- end
32
-
33
- def update_status(status, commit_sha: nil, stash_ref: nil, error: nil, position: nil)
34
- @status = status.to_s
35
- @commit_sha = commit_sha unless commit_sha.nil?
36
- @stash_ref = stash_ref unless stash_ref.nil?
37
- @error = error unless error.nil?
38
- @position = position unless position.nil?
39
- now = Time.now.utc
40
- @updated_at = now
41
- @started_at ||= now if @status == "running"
42
- @finished_at = now if %w[ready_for_review failed blocked cancelled archived].include?(@status)
43
- self
44
- end
45
-
46
- def to_h
47
- {
48
- "id" => id,
49
- "title" => title,
50
- "session_path" => session_path,
51
- "workspace_root" => workspace_root,
52
- "status" => status,
53
- "position" => position,
54
- "commit_sha" => commit_sha,
55
- "stash_ref" => stash_ref,
56
- "error" => error,
57
- "enqueued_at" => timestamp(enqueued_at),
58
- "started_at" => timestamp(started_at),
59
- "finished_at" => timestamp(finished_at),
60
- "updated_at" => timestamp(updated_at)
61
- }
62
- end
63
-
64
- def self.from_h(record)
65
- new(
66
- id: record.fetch("id"),
67
- title: record.fetch("title"),
68
- session_path: record.fetch("session_path"),
69
- workspace_root: record["workspace_root"] || Dir.pwd,
70
- status: record["status"] || "queued",
71
- position: record["position"],
72
- commit_sha: record["commit_sha"],
73
- stash_ref: record["stash_ref"],
74
- error: record["error"],
75
- enqueued_at: parse_time(record["enqueued_at"]) || Time.now.utc,
76
- started_at: parse_time(record["started_at"]),
77
- finished_at: parse_time(record["finished_at"]),
78
- updated_at: parse_time(record["updated_at"])
79
- )
80
- end
81
-
82
- def self.parse_time(value)
83
- return nil if value.to_s.empty?
84
-
85
- Time.parse(value.to_s).utc
86
- rescue ArgumentError
87
- nil
88
- end
89
-
90
- private_class_method :parse_time
91
-
92
- private
93
-
94
- def timestamp(value)
95
- value&.utc&.iso8601(3)
96
- end
97
- end
98
- end
99
- end
@@ -1,49 +0,0 @@
1
- module Kward
2
- module Workers
3
- # Drains a running worker's accumulated event history into a frontend renderer.
4
- class LiveView
5
- FINISHED_STATUSES = %w[ready failed cancelled archived].freeze
6
-
7
- def initialize(worker:, agent:, renderer:, poll_interval: 0.05)
8
- @worker = worker
9
- @agent = agent
10
- @renderer = renderer
11
- @poll_interval = poll_interval
12
- @seen_events = worker.event_history.length
13
- @stop = false
14
- @thread = nil
15
- end
16
-
17
- attr_reader :worker, :agent
18
-
19
- def start
20
- @thread = Thread.new { run }
21
- @thread.report_on_exception = false
22
- self
23
- end
24
-
25
- def stop
26
- @stop = true
27
- @thread&.join(0.2)
28
- end
29
-
30
- private
31
-
32
- def run
33
- until @stop
34
- events = @worker.event_history[@seen_events..] || []
35
- events.each { |event| @renderer.call(event, @agent) }
36
- @seen_events += events.length
37
- @renderer.call(:flush, @agent) if finished?
38
- break if finished?
39
-
40
- sleep @poll_interval
41
- end
42
- end
43
-
44
- def finished?
45
- FINISHED_STATUSES.include?(@worker.status)
46
- end
47
- end
48
- end
49
- end
@@ -1,328 +0,0 @@
1
- require "timeout"
2
- require_relative "../agent"
3
- require_relative "../cancellation"
4
- require_relative "../conversation"
5
- require_relative "../hooks"
6
- require_relative "../model/client"
7
- require_relative "../session_store"
8
- require_relative "../tools/registry"
9
- require_relative "../workspace"
10
- require_relative "git_guard"
11
- require_relative "tool_policy"
12
- require_relative "worker"
13
-
14
- module Kward
15
- module Workers
16
- # Coordinates background worker execution and role-specific tool policy.
17
- class Manager
18
- DEFAULT_TIMEOUT_SECONDS = 180
19
-
20
- def initialize(client_factory: -> { Client.new }, prompt: nil, workspace_root: Dir.pwd, timeout_seconds: DEFAULT_TIMEOUT_SECONDS, on_status_change: nil, session_store: nil, provider: nil, model: nil, reasoning_effort: nil, write_lock: nil, worker_store: nil, git_guard: nil, write_lane_available: -> { true }, hook_manager: nil, hook_context: nil)
21
- @client_factory = client_factory
22
- @prompt = prompt
23
- @workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
24
- @timeout_seconds = timeout_seconds
25
- @on_status_change = on_status_change
26
- @session_store = session_store
27
- @provider = provider
28
- @model = model
29
- @reasoning_effort = reasoning_effort
30
- @write_lock = write_lock
31
- @worker_store = worker_store
32
- @git_guard = git_guard || GitGuard.new(root: @workspace_root)
33
- @write_lane_available = write_lane_available
34
- @hook_manager = hook_manager
35
- @hook_context = hook_context
36
- @workers = {}
37
- @mutex = Mutex.new
38
- end
39
-
40
- def start(role:, prompt:, title: nil, id: nil)
41
- worker = build_worker(role: role, prompt: prompt, title: title, id: id)
42
- run_blocking_hook("worker_job_create", worker)
43
- enqueue(worker)
44
- end
45
-
46
- def continue(id, role:, prompt:, title: nil)
47
- archived = nil
48
- worker = build_worker(role: role, prompt: prompt, title: title, id: id)
49
- @mutex.synchronize do
50
- archived = @workers.delete(id.to_s)
51
- @workers[worker.id] = worker
52
- end
53
- archived&.update_status("archived")
54
- @worker_store&.archive(id) if archived || @worker_store&.find(id)
55
- enqueue(worker, store: false)
56
- end
57
-
58
- def list
59
- @mutex.synchronize { @workers.values.reject { |worker| worker.status == "archived" }.sort_by(&:created_at) }
60
- end
61
-
62
- def find(id)
63
- @mutex.synchronize { @workers[id.to_s] }
64
- end
65
-
66
- def cancel(id)
67
- worker = find(id) || raise(ArgumentError, "Unknown worker: #{id}")
68
- worker.cancellation.cancel!
69
- worker.thread.raise(Cancellation::CancelledError, "cancelled") if worker.thread&.alive?
70
- update_status(worker, "cancelled")
71
- end
72
-
73
- def archive(id)
74
- worker = find(id) || raise(ArgumentError, "Unknown worker: #{id}")
75
- worker.cancellation.cancel! if %w[queued running].include?(worker.status)
76
- worker.thread.raise(Cancellation::CancelledError, "cancelled") if worker.thread&.alive?
77
- update_status(worker, "archived")
78
- end
79
-
80
- private
81
-
82
- def build_worker(role:, prompt:, title: nil, id: nil)
83
- Worker.new(
84
- id: id || SecureRandom.hex(4),
85
- title: title || title_for(prompt),
86
- role: role,
87
- prompt: prompt,
88
- workspace_root: @workspace_root,
89
- status: "queued"
90
- )
91
- end
92
-
93
- def enqueue(worker, store: true)
94
- @mutex.synchronize { @workers[worker.id] = worker }
95
- @worker_store&.upsert(worker) if store
96
- worker.thread = Thread.new { run_worker(worker) }
97
- worker.thread.report_on_exception = false
98
- worker
99
- end
100
-
101
- def run_worker(worker)
102
- conversation = Conversation.new(
103
- system_message: { role: "system", content: system_message(worker) },
104
- workspace_root: worker.workspace_root,
105
- provider: @provider,
106
- model: @model,
107
- reasoning_effort: @reasoning_effort
108
- )
109
- worker.conversation = conversation
110
- attach_session(worker, conversation)
111
- writer_id = wait_for_worker_writer(worker)
112
- run_blocking_hook("worker_job_start_before", worker)
113
- update_status(worker, "running")
114
- run_hook("worker_job_start_after", worker)
115
- registry = ToolRegistry.new(
116
- workspace: Workspace.new(root: worker.workspace_root),
117
- prompt: @prompt,
118
- allowed_tool_names: ToolPolicy.allowed_tool_names(worker.role),
119
- write_lock: @write_lock,
120
- writer_id: writer_id,
121
- hook_manager: @hook_manager,
122
- hook_context: @hook_context
123
- )
124
- agent = Agent.new(client: @client_factory.call, tool_registry: registry, conversation: conversation, hook_manager: @hook_manager, hook_context: @hook_context)
125
- report = Timeout.timeout(@timeout_seconds, WorkerTimeoutError) do
126
- agent.ask(worker_prompt(worker), cancellation: worker.cancellation) do |event|
127
- worker.record_event(event)
128
- end
129
- end
130
- report = finalize_write_worker(worker, report)
131
- update_status(worker, "ready", report: report, error: "")
132
- run_hook("worker_job_ready_for_review", worker)
133
- rescue WorkerTimeoutError
134
- update_status(worker, "failed", error: "Worker timed out after #{@timeout_seconds} seconds")
135
- run_hook("worker_job_failed", worker, error: "Worker timed out after #{@timeout_seconds} seconds")
136
- rescue Cancellation::CancelledError
137
- update_status(worker, "cancelled")
138
- rescue StandardError => e
139
- update_status(worker, "failed", error: e.message)
140
- run_hook("worker_job_failed", worker, error: e.message)
141
- ensure
142
- release_worker_writer(worker)
143
- end
144
-
145
- def run_blocking_hook(name, worker, payload = {})
146
- result = run_hook(name, worker, payload)
147
- return result unless result && !result.allowed?
148
-
149
- raise HookDenied, result.decision.message || "Worker lifecycle hook #{name} blocked the job"
150
- end
151
-
152
- def run_hook(name, worker, payload = {})
153
- return unless @hook_manager
154
-
155
- @hook_manager.run(Hooks::Event.new(
156
- name: name,
157
- workspace: { root: worker.workspace_root },
158
- payload: worker_payload(worker).merge(payload)
159
- ), context: @hook_context)
160
- end
161
-
162
- HookDenied = Class.new(StandardError)
163
-
164
- def worker_payload(worker)
165
- {
166
- worker_id: worker.id,
167
- role: worker.role,
168
- title: worker.title,
169
- status: worker.status,
170
- session_path: worker.session&.path
171
- }.compact
172
- end
173
-
174
- def update_status(worker, status, **values)
175
- return worker if worker.status == "archived" && status.to_s != "archived"
176
-
177
- worker.update_status(status, **values)
178
- @worker_store&.upsert(worker)
179
- @on_status_change&.call(worker)
180
- worker
181
- end
182
-
183
- def wait_for_worker_writer(worker)
184
- return nil unless ToolPolicy.write_capable?(worker.role)
185
-
186
- loop do
187
- worker.cancellation.raise_if_cancelled!
188
- wait_for_write_lane_available(worker)
189
- wait_for_clean_workspace(worker)
190
- return worker.id unless @write_lock
191
-
192
- release_foreground_writer_if_clean
193
- return worker.id if @write_lock.acquire(worker.id)
194
-
195
- sleep 0.1
196
- end
197
- end
198
-
199
- def wait_for_write_lane_available(worker)
200
- until @write_lane_available.call
201
- worker.cancellation.raise_if_cancelled!
202
- sleep 0.1
203
- end
204
- end
205
-
206
- def wait_for_clean_workspace(worker)
207
- return unless @git_guard.repository?
208
-
209
- until @git_guard.clean?
210
- worker.cancellation.raise_if_cancelled!
211
- sleep 0.5
212
- end
213
- end
214
-
215
- def release_foreground_writer_if_clean
216
- return unless @write_lock&.owned_by?("implementation")
217
- return unless @git_guard.repository?
218
- return unless @git_guard.clean?
219
-
220
- @write_lock.release("implementation")
221
- end
222
-
223
- def finalize_write_worker(worker, report)
224
- return report unless ToolPolicy.write_capable?(worker.role)
225
- return report unless @git_guard.repository?
226
- return report if @git_guard.clean?
227
-
228
- commit = @git_guard.commit_all(commit_message(worker))
229
- unless commit.success?
230
- raise "Worker changed files but commit failed: #{commit.output}"
231
- end
232
-
233
- [report, "", "Committed workspace changes: #{commit.commit}"].join("\n")
234
- end
235
-
236
- def commit_message(worker)
237
- "Kward worker #{worker.id}: #{worker.title}"
238
- end
239
-
240
- def release_worker_writer(worker)
241
- return unless ToolPolicy.write_capable?(worker.role)
242
-
243
- @write_lock&.release(worker.id)
244
- end
245
-
246
- def attach_session(worker, conversation)
247
- return unless @session_store
248
-
249
- session = @session_store.create(provider: @provider, model: @model, reasoning_effort: @reasoning_effort)
250
- session.rename("#{worker.role}: #{worker.title}")
251
- session.attach(conversation)
252
- worker.session = session
253
- @worker_store&.upsert(worker)
254
- @on_status_change&.call(worker)
255
- rescue StandardError
256
- nil
257
- end
258
-
259
- def worker_prompt(worker)
260
- return request_prompt(worker.prompt) if worker.role == "request"
261
-
262
- worker.prompt
263
- end
264
-
265
- def system_message(worker)
266
- return request_system_message if worker.role == "request"
267
-
268
- "You are a Kward worker. Complete the user's task carefully."
269
- end
270
-
271
- def request_system_message
272
- <<~SYSTEM
273
- You are a Kward request worker running the read-only exploration phase.
274
- Inspect the workspace, map relevant terrain, and produce a practical review for the user.
275
- Do not edit files, write files, delete files, alter configuration, or claim implementation work was done.
276
- SYSTEM
277
- end
278
-
279
- def request_prompt(prompt)
280
- <<~PROMPT
281
- #{prompt}
282
-
283
- ---
284
-
285
- You are handling this as a structured Kward background request.
286
- First perform a read-only exploration phase. Inspect relevant files and documentation, reason about the request, and prepare a reviewable result for the user.
287
- Do not modify files, write files, delete files, alter configuration, run destructive commands, or claim implementation work was done.
288
-
289
- Return a concise request review with these sections:
290
- # Request Review: <title>
291
-
292
- ## Request
293
- Restate the user's request.
294
-
295
- ## Summary
296
- The short answer.
297
-
298
- ## Relevant files
299
- Bullet list of likely files and why they matter.
300
-
301
- ## Findings
302
- What you discovered.
303
-
304
- ## Recommended next step
305
- A practical next step. If implementation appears useful, say so clearly, but do not implement it.
306
-
307
- ## Risks
308
- Important risks or unknowns.
309
-
310
- ## Verification
311
- Focused verification commands or checks.
312
-
313
- ## Open questions
314
- Decisions the user should make before proceeding.
315
-
316
- End by asking: Should we proceed?
317
- PROMPT
318
- end
319
-
320
- def title_for(prompt)
321
- text = prompt.to_s.strip.gsub(/\s+/, " ")
322
- text.empty? ? "Untitled worker" : text[0, 80]
323
- end
324
-
325
- class WorkerTimeoutError < StandardError; end
326
- end
327
- end
328
- end