kward 0.71.0 → 0.73.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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +30 -0
  3. data/CHANGELOG.md +93 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +4 -0
  6. data/doc/agent-tools.md +15 -6
  7. data/doc/authentication.md +22 -1
  8. data/doc/code-search.md +42 -2
  9. data/doc/configuration.md +106 -3
  10. data/doc/context-budgeting.md +136 -0
  11. data/doc/context-tools.md +16 -3
  12. data/doc/editor.md +415 -0
  13. data/doc/extensibility.md +16 -7
  14. data/doc/files.md +100 -0
  15. data/doc/getting-started.md +25 -18
  16. data/doc/git.md +123 -0
  17. data/doc/memory.md +24 -4
  18. data/doc/personas.md +34 -5
  19. data/doc/plugins.md +72 -1
  20. data/doc/releasing.md +37 -9
  21. data/doc/rpc.md +75 -5
  22. data/doc/session-management.md +35 -1
  23. data/doc/shell.md +332 -0
  24. data/doc/tabs.md +122 -0
  25. data/doc/troubleshooting.md +77 -1
  26. data/doc/usage.md +79 -7
  27. data/doc/web-search.md +12 -4
  28. data/doc/workspace-tools.md +51 -12
  29. data/examples/plugins/space_invaders.rb +377 -0
  30. data/lib/kward/agent.rb +1 -1
  31. data/lib/kward/ansi.rb +62 -23
  32. data/lib/kward/cli/commands.rb +33 -2
  33. data/lib/kward/cli/git.rb +150 -0
  34. data/lib/kward/cli/interactive_turn.rb +73 -9
  35. data/lib/kward/cli/plugins.rb +54 -4
  36. data/lib/kward/cli/prompt_interface.rb +32 -1
  37. data/lib/kward/cli/rendering.rb +4 -1
  38. data/lib/kward/cli/runtime_helpers.rb +268 -4
  39. data/lib/kward/cli/sessions.rb +2 -2
  40. data/lib/kward/cli/settings.rb +217 -9
  41. data/lib/kward/cli/slash_commands.rb +628 -2
  42. data/lib/kward/cli/tabs.rb +725 -0
  43. data/lib/kward/cli/tool_summaries.rb +6 -0
  44. data/lib/kward/cli.rb +150 -26
  45. data/lib/kward/clipboard.rb +2 -3
  46. data/lib/kward/compactor.rb +7 -19
  47. data/lib/kward/config_files.rb +145 -1
  48. data/lib/kward/context_budget_meter.rb +44 -0
  49. data/lib/kward/conversation.rb +12 -4
  50. data/lib/kward/editor_mode.rb +25 -0
  51. data/lib/kward/ekwsh.rb +559 -0
  52. data/lib/kward/image_attachments.rb +3 -1
  53. data/lib/kward/interactive_pty_runner.rb +151 -0
  54. data/lib/kward/local_command_runner.rb +155 -0
  55. data/lib/kward/local_pty_command_runner.rb +171 -0
  56. data/lib/kward/model/context_usage.rb +2 -2
  57. data/lib/kward/model/payloads.rb +2 -5
  58. data/lib/kward/plugin_registry.rb +61 -0
  59. data/lib/kward/project_files.rb +52 -0
  60. data/lib/kward/prompt_history.rb +84 -0
  61. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  62. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  63. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  64. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  65. data/lib/kward/prompt_interface/editor/auto_indent.rb +510 -0
  66. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  67. data/lib/kward/prompt_interface/editor/controller.rb +1218 -0
  68. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  69. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  70. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  71. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  72. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  73. data/lib/kward/prompt_interface/editor/modes/modern.rb +354 -0
  74. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1812 -0
  75. data/lib/kward/prompt_interface/editor/modes/vibe_insert_readline.rb +166 -0
  76. data/lib/kward/prompt_interface/editor/renderer.rb +244 -0
  77. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  78. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  79. data/lib/kward/prompt_interface/editor/state.rb +1271 -0
  80. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  81. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +422 -0
  82. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  83. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  84. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  85. data/lib/kward/prompt_interface/git_prompt.rb +288 -0
  86. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  87. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  88. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  89. data/lib/kward/prompt_interface/key_handler.rb +451 -57
  90. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  91. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  92. data/lib/kward/prompt_interface/question_prompt.rb +99 -56
  93. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  94. data/lib/kward/prompt_interface/screen.rb +19 -3
  95. data/lib/kward/prompt_interface/selection_prompt.rb +10 -19
  96. data/lib/kward/prompt_interface/slash_overlay.rb +2 -0
  97. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  98. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  99. data/lib/kward/prompt_interface.rb +366 -222
  100. data/lib/kward/prompts/commands.rb +9 -0
  101. data/lib/kward/prompts.rb +2 -0
  102. data/lib/kward/rpc/memory_methods.rb +83 -0
  103. data/lib/kward/rpc/server.rb +169 -83
  104. data/lib/kward/rpc/session_manager.rb +45 -121
  105. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  106. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  107. data/lib/kward/rpc/tool_metadata.rb +11 -0
  108. data/lib/kward/rpc/transcript_normalizer.rb +4 -39
  109. data/lib/kward/scratchpad_runner.rb +56 -0
  110. data/lib/kward/session_diff.rb +20 -3
  111. data/lib/kward/session_naming.rb +11 -0
  112. data/lib/kward/session_store.rb +44 -0
  113. data/lib/kward/session_tree_nodes.rb +136 -0
  114. data/lib/kward/session_tree_renderer.rb +9 -131
  115. data/lib/kward/tab_store.rb +47 -0
  116. data/lib/kward/terminal_keys.rb +84 -0
  117. data/lib/kward/terminal_sequences.rb +42 -0
  118. data/lib/kward/text_boundary.rb +25 -0
  119. data/lib/kward/tools/context_budget_stats.rb +54 -0
  120. data/lib/kward/tools/context_for_task.rb +204 -0
  121. data/lib/kward/tools/read_file.rb +8 -4
  122. data/lib/kward/tools/registry.rb +62 -16
  123. data/lib/kward/tools/tool_call.rb +10 -0
  124. data/lib/kward/version.rb +1 -1
  125. data/lib/kward/workers/git_guard.rb +93 -0
  126. data/lib/kward/workers/job.rb +99 -0
  127. data/lib/kward/workers/live_view.rb +49 -0
  128. data/lib/kward/workers/manager.rb +288 -0
  129. data/lib/kward/workers/queue_runner.rb +166 -0
  130. data/lib/kward/workers/queue_store.rb +112 -0
  131. data/lib/kward/workers/store.rb +72 -0
  132. data/lib/kward/workers/tool_policy.rb +23 -0
  133. data/lib/kward/workers/worker.rb +82 -0
  134. data/lib/kward/workers/write_lock.rb +38 -0
  135. data/lib/kward/workers.rb +10 -0
  136. data/lib/kward/workspace.rb +125 -87
  137. data/templates/default/fulldoc/html/css/kward.css +140 -36
  138. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
  139. data/templates/default/fulldoc/html/setup.rb +1 -0
  140. data/templates/default/kward_navigation.rb +12 -1
  141. data/templates/default/layout/html/layout.erb +23 -34
  142. data/templates/default/layout/html/setup.rb +6 -0
  143. metadata +67 -1
@@ -0,0 +1,288 @@
1
+ require "timeout"
2
+ require_relative "../agent"
3
+ require_relative "../cancellation"
4
+ require_relative "../conversation"
5
+ require_relative "../model/client"
6
+ require_relative "../session_store"
7
+ require_relative "../tools/registry"
8
+ require_relative "../workspace"
9
+ require_relative "git_guard"
10
+ require_relative "tool_policy"
11
+ require_relative "worker"
12
+
13
+ module Kward
14
+ module Workers
15
+ # Coordinates background worker execution and role-specific tool policy.
16
+ class Manager
17
+ DEFAULT_TIMEOUT_SECONDS = 180
18
+
19
+ 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 })
20
+ @client_factory = client_factory
21
+ @prompt = prompt
22
+ @workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
23
+ @timeout_seconds = timeout_seconds
24
+ @on_status_change = on_status_change
25
+ @session_store = session_store
26
+ @provider = provider
27
+ @model = model
28
+ @reasoning_effort = reasoning_effort
29
+ @write_lock = write_lock
30
+ @worker_store = worker_store
31
+ @git_guard = git_guard || GitGuard.new(root: @workspace_root)
32
+ @write_lane_available = write_lane_available
33
+ @workers = {}
34
+ @mutex = Mutex.new
35
+ end
36
+
37
+ def start(role:, prompt:, title: nil, id: nil)
38
+ worker = build_worker(role: role, prompt: prompt, title: title, id: id)
39
+ enqueue(worker)
40
+ end
41
+
42
+ def continue(id, role:, prompt:, title: nil)
43
+ archived = nil
44
+ worker = build_worker(role: role, prompt: prompt, title: title, id: id)
45
+ @mutex.synchronize do
46
+ archived = @workers.delete(id.to_s)
47
+ @workers[worker.id] = worker
48
+ end
49
+ archived&.update_status("archived")
50
+ @worker_store&.archive(id) if archived || @worker_store&.find(id)
51
+ enqueue(worker, store: false)
52
+ end
53
+
54
+ def list
55
+ @mutex.synchronize { @workers.values.reject { |worker| worker.status == "archived" }.sort_by(&:created_at) }
56
+ end
57
+
58
+ def find(id)
59
+ @mutex.synchronize { @workers[id.to_s] }
60
+ end
61
+
62
+ def cancel(id)
63
+ worker = find(id) || raise(ArgumentError, "Unknown worker: #{id}")
64
+ worker.cancellation.cancel!
65
+ worker.thread.raise(Cancellation::CancelledError, "cancelled") if worker.thread&.alive?
66
+ update_status(worker, "cancelled")
67
+ end
68
+
69
+ def archive(id)
70
+ worker = find(id) || raise(ArgumentError, "Unknown worker: #{id}")
71
+ worker.cancellation.cancel! if %w[queued running].include?(worker.status)
72
+ worker.thread.raise(Cancellation::CancelledError, "cancelled") if worker.thread&.alive?
73
+ update_status(worker, "archived")
74
+ end
75
+
76
+ private
77
+
78
+ def build_worker(role:, prompt:, title: nil, id: nil)
79
+ Worker.new(
80
+ id: id || SecureRandom.hex(4),
81
+ title: title || title_for(prompt),
82
+ role: role,
83
+ prompt: prompt,
84
+ workspace_root: @workspace_root,
85
+ status: "queued"
86
+ )
87
+ end
88
+
89
+ def enqueue(worker, store: true)
90
+ @mutex.synchronize { @workers[worker.id] = worker }
91
+ @worker_store&.upsert(worker) if store
92
+ worker.thread = Thread.new { run_worker(worker) }
93
+ worker.thread.report_on_exception = false
94
+ worker
95
+ end
96
+
97
+ def run_worker(worker)
98
+ conversation = Conversation.new(
99
+ system_message: { role: "system", content: system_message(worker) },
100
+ workspace_root: worker.workspace_root,
101
+ provider: @provider,
102
+ model: @model,
103
+ reasoning_effort: @reasoning_effort
104
+ )
105
+ worker.conversation = conversation
106
+ attach_session(worker, conversation)
107
+ writer_id = wait_for_worker_writer(worker)
108
+ update_status(worker, "running")
109
+ registry = ToolRegistry.new(
110
+ workspace: Workspace.new(root: worker.workspace_root),
111
+ prompt: @prompt,
112
+ allowed_tool_names: ToolPolicy.allowed_tool_names(worker.role),
113
+ write_lock: @write_lock,
114
+ writer_id: writer_id
115
+ )
116
+ agent = Agent.new(client: @client_factory.call, tool_registry: registry, conversation: conversation)
117
+ report = Timeout.timeout(@timeout_seconds, WorkerTimeoutError) do
118
+ agent.ask(worker_prompt(worker), cancellation: worker.cancellation) do |event|
119
+ worker.record_event(event)
120
+ end
121
+ end
122
+ report = finalize_write_worker(worker, report)
123
+ update_status(worker, "ready", report: report, error: "")
124
+ rescue WorkerTimeoutError
125
+ update_status(worker, "failed", error: "Worker timed out after #{@timeout_seconds} seconds")
126
+ rescue Cancellation::CancelledError
127
+ update_status(worker, "cancelled")
128
+ rescue StandardError => e
129
+ update_status(worker, "failed", error: e.message)
130
+ ensure
131
+ release_worker_writer(worker)
132
+ end
133
+
134
+ def update_status(worker, status, **values)
135
+ return worker if worker.status == "archived" && status.to_s != "archived"
136
+
137
+ worker.update_status(status, **values)
138
+ @worker_store&.upsert(worker)
139
+ @on_status_change&.call(worker)
140
+ worker
141
+ end
142
+
143
+ def wait_for_worker_writer(worker)
144
+ return nil unless ToolPolicy.write_capable?(worker.role)
145
+
146
+ loop do
147
+ worker.cancellation.raise_if_cancelled!
148
+ wait_for_write_lane_available(worker)
149
+ wait_for_clean_workspace(worker)
150
+ return worker.id unless @write_lock
151
+
152
+ release_foreground_writer_if_clean
153
+ return worker.id if @write_lock.acquire(worker.id)
154
+
155
+ sleep 0.1
156
+ end
157
+ end
158
+
159
+ def wait_for_write_lane_available(worker)
160
+ until @write_lane_available.call
161
+ worker.cancellation.raise_if_cancelled!
162
+ sleep 0.1
163
+ end
164
+ end
165
+
166
+ def wait_for_clean_workspace(worker)
167
+ return unless @git_guard.repository?
168
+
169
+ until @git_guard.clean?
170
+ worker.cancellation.raise_if_cancelled!
171
+ sleep 0.5
172
+ end
173
+ end
174
+
175
+ def release_foreground_writer_if_clean
176
+ return unless @write_lock&.owned_by?("implementation")
177
+ return unless @git_guard.repository?
178
+ return unless @git_guard.clean?
179
+
180
+ @write_lock.release("implementation")
181
+ end
182
+
183
+ def finalize_write_worker(worker, report)
184
+ return report unless ToolPolicy.write_capable?(worker.role)
185
+ return report unless @git_guard.repository?
186
+ return report if @git_guard.clean?
187
+
188
+ commit = @git_guard.commit_all(commit_message(worker))
189
+ unless commit.success?
190
+ raise "Worker changed files but commit failed: #{commit.output}"
191
+ end
192
+
193
+ [report, "", "Committed workspace changes: #{commit.commit}"].join("\n")
194
+ end
195
+
196
+ def commit_message(worker)
197
+ "Kward worker #{worker.id}: #{worker.title}"
198
+ end
199
+
200
+ def release_worker_writer(worker)
201
+ return unless ToolPolicy.write_capable?(worker.role)
202
+
203
+ @write_lock&.release(worker.id)
204
+ end
205
+
206
+ def attach_session(worker, conversation)
207
+ return unless @session_store
208
+
209
+ session = @session_store.create(provider: @provider, model: @model, reasoning_effort: @reasoning_effort)
210
+ session.rename("#{worker.role}: #{worker.title}")
211
+ session.attach(conversation)
212
+ worker.session = session
213
+ @worker_store&.upsert(worker)
214
+ @on_status_change&.call(worker)
215
+ rescue StandardError
216
+ nil
217
+ end
218
+
219
+ def worker_prompt(worker)
220
+ return request_prompt(worker.prompt) if worker.role == "request"
221
+
222
+ worker.prompt
223
+ end
224
+
225
+ def system_message(worker)
226
+ return request_system_message if worker.role == "request"
227
+
228
+ "You are a Kward worker. Complete the user's task carefully."
229
+ end
230
+
231
+ def request_system_message
232
+ <<~SYSTEM
233
+ You are a Kward request worker running the read-only exploration phase.
234
+ Inspect the workspace, map relevant terrain, and produce a practical review for the user.
235
+ Do not edit files, write files, delete files, alter configuration, or claim implementation work was done.
236
+ SYSTEM
237
+ end
238
+
239
+ def request_prompt(prompt)
240
+ <<~PROMPT
241
+ #{prompt}
242
+
243
+ ---
244
+
245
+ You are handling this as a structured Kward background request.
246
+ First perform a read-only exploration phase. Inspect relevant files and documentation, reason about the request, and prepare a reviewable result for the user.
247
+ Do not modify files, write files, delete files, alter configuration, run destructive commands, or claim implementation work was done.
248
+
249
+ Return a concise request review with these sections:
250
+ # Request Review: <title>
251
+
252
+ ## Request
253
+ Restate the user's request.
254
+
255
+ ## Summary
256
+ The short answer.
257
+
258
+ ## Relevant files
259
+ Bullet list of likely files and why they matter.
260
+
261
+ ## Findings
262
+ What you discovered.
263
+
264
+ ## Recommended next step
265
+ A practical next step. If implementation appears useful, say so clearly, but do not implement it.
266
+
267
+ ## Risks
268
+ Important risks or unknowns.
269
+
270
+ ## Verification
271
+ Focused verification commands or checks.
272
+
273
+ ## Open questions
274
+ Decisions the user should make before proceeding.
275
+
276
+ End by asking: Should we proceed?
277
+ PROMPT
278
+ end
279
+
280
+ def title_for(prompt)
281
+ text = prompt.to_s.strip.gsub(/\s+/, " ")
282
+ text.empty? ? "Untitled worker" : text[0, 80]
283
+ end
284
+
285
+ class WorkerTimeoutError < StandardError; end
286
+ end
287
+ end
288
+ end
@@ -0,0 +1,166 @@
1
+ require_relative "../agent"
2
+ require_relative "../config_files"
3
+ require_relative "../model/client"
4
+ require_relative "../tools/registry"
5
+ require_relative "../workspace"
6
+ require_relative "git_guard"
7
+ require_relative "tool_policy"
8
+
9
+ module Kward
10
+ module Workers
11
+ # Executes session-backed worker queue jobs one at a time.
12
+ class QueueRunner
13
+ CONTINUE_PROMPT = <<~PROMPT.freeze
14
+ Continue this session as an implementation worker.
15
+ Make the smallest correct change, preserve existing style, and run focused verification when practical.
16
+ Stop when the work is ready for human review.
17
+ PROMPT
18
+
19
+ def initialize(queue_store:, session_store:, client_factory: -> { Client.new }, prompt: nil, workspace_root: Dir.pwd, provider: nil, model: nil, reasoning_effort: nil, git_guard: nil, write_lock: nil)
20
+ @queue_store = queue_store
21
+ @session_store = session_store
22
+ @client_factory = client_factory
23
+ @prompt = prompt
24
+ @workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
25
+ @provider = provider
26
+ @model = model
27
+ @reasoning_effort = reasoning_effort
28
+ @git_guard = git_guard || GitGuard.new(root: @workspace_root)
29
+ @write_lock = write_lock
30
+ end
31
+
32
+ def run_next
33
+ record = @queue_store.next_queued
34
+ return nil unless record
35
+
36
+ run_job(record)
37
+ end
38
+
39
+ def run_all
40
+ results = []
41
+ loop do
42
+ record = run_next
43
+ break unless record
44
+
45
+ results << record
46
+ break unless record["status"] == "ready_for_review"
47
+ end
48
+ results
49
+ end
50
+
51
+ def suspend(id)
52
+ record = find_job(id)
53
+ raise ArgumentError, "Worker job #{id} is not running" unless record["status"] == "running"
54
+
55
+ stash_ref = stash_worker_changes(record)
56
+ @queue_store.update_status(record.fetch("id"), "suspended", stash_ref: stash_ref, error: "")
57
+ rescue StandardError => e
58
+ @queue_store.update_status(id, "blocked", error: e.message)
59
+ end
60
+
61
+ def resume(id)
62
+ record = find_job(id)
63
+ raise ArgumentError, "Worker job #{id} is not suspended" unless record["status"] == "suspended"
64
+
65
+ restore_worker_changes(record)
66
+ queued = @queue_store.update_status(record.fetch("id"), "queued", stash_ref: "", error: "")
67
+ run_job(queued, require_clean: false)
68
+ rescue DirtyWorkspaceError => e
69
+ @queue_store.update_status(id, "blocked", error: e.message)
70
+ rescue StandardError => e
71
+ @queue_store.update_status(id, "blocked", error: e.message)
72
+ end
73
+
74
+ private
75
+
76
+ def run_job(record, require_clean: true)
77
+ id = record.fetch("id")
78
+ ensure_clean_workspace!(record) if require_clean
79
+ @queue_store.update_status(id, "running", error: "")
80
+ session, conversation = load_job_session(record)
81
+ agent = Agent.new(client: @client_factory.call, tool_registry: tool_registry(record, id), conversation: conversation)
82
+ report = agent.ask(CONTINUE_PROMPT)
83
+ commit = commit_if_needed(record)
84
+ session.append_message({ role: "assistant", content: completion_report(report, commit) }) if commit
85
+ @queue_store.update_status(id, "ready_for_review", commit_sha: commit, error: "")
86
+ rescue DirtyWorkspaceError => e
87
+ @queue_store.update_status(id, "blocked", error: e.message)
88
+ rescue StandardError => e
89
+ @queue_store.update_status(id, "failed", error: e.message)
90
+ end
91
+
92
+ def ensure_clean_workspace!(record)
93
+ return unless @git_guard.repository?
94
+ return if @git_guard.clean?
95
+
96
+ raise DirtyWorkspaceError, "Workspace is dirty; clean or stash changes before running worker #{record.fetch('id')}"
97
+ end
98
+
99
+ def find_job(id)
100
+ @queue_store.find(id) || raise(ArgumentError, "Unknown worker job: #{id}")
101
+ end
102
+
103
+ def stash_worker_changes(record)
104
+ return "" unless @git_guard.repository?
105
+ return "" if @git_guard.clean?
106
+
107
+ result = @git_guard.stash("Kward worker #{record.fetch('id')}: #{record.fetch('title')}")
108
+ raise "Worker changes could not be stashed: #{result.output}" unless result.success?
109
+
110
+ result.commit.to_s
111
+ end
112
+
113
+ def restore_worker_changes(record)
114
+ ensure_clean_workspace!(record)
115
+ ref = record["stash_ref"].to_s
116
+ return if ref.empty?
117
+
118
+ apply = @git_guard.apply_stash(ref)
119
+ raise "Worker stash could not be restored: #{apply.output}" unless apply.success?
120
+
121
+ drop = @git_guard.drop_stash(ref)
122
+ raise "Worker stash could not be dropped: #{drop.output}" unless drop.success?
123
+ end
124
+
125
+ def load_job_session(record)
126
+ @session_store.load(
127
+ record.fetch("session_path"),
128
+ workspace: Workspace.new(root: record["workspace_root"] || @workspace_root),
129
+ provider: @provider,
130
+ model: @model,
131
+ reasoning_effort: @reasoning_effort
132
+ )
133
+ end
134
+
135
+ def tool_registry(record, writer_id)
136
+ ToolRegistry.new(
137
+ workspace: Workspace.new(root: record["workspace_root"] || @workspace_root),
138
+ prompt: @prompt,
139
+ allowed_tool_names: ToolPolicy.allowed_tool_names("implementation"),
140
+ write_lock: @write_lock,
141
+ writer_id: writer_id
142
+ )
143
+ end
144
+
145
+ def commit_if_needed(record)
146
+ return nil unless @git_guard.repository?
147
+ return nil if @git_guard.clean?
148
+
149
+ result = @git_guard.commit_all(commit_message(record))
150
+ raise "Worker changed files but commit failed: #{result.output}" unless result.success?
151
+
152
+ result.commit
153
+ end
154
+
155
+ def commit_message(record)
156
+ "Kward worker #{record.fetch('id')}: #{record.fetch('title')}"
157
+ end
158
+
159
+ def completion_report(report, commit)
160
+ [report, "", "Committed workspace changes: #{commit}"].join("\n")
161
+ end
162
+
163
+ class DirtyWorkspaceError < StandardError; end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,112 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require_relative "../config_files"
4
+ require_relative "job"
5
+
6
+ module Kward
7
+ module Workers
8
+ # JSON-backed queue store for session-backed worker jobs.
9
+ class QueueStore
10
+ def initialize(path: File.join(ConfigFiles.config_dir, "worker_queue.json"))
11
+ @path = path
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ attr_reader :path
16
+
17
+ def enqueue(title:, session_path:, workspace_root:, id: nil)
18
+ job = Job.new(
19
+ id: id || SecureRandom.hex(4),
20
+ title: title,
21
+ session_path: session_path,
22
+ workspace_root: workspace_root,
23
+ position: next_position
24
+ )
25
+ upsert(job)
26
+ job
27
+ end
28
+
29
+ def upsert(job)
30
+ record = job.respond_to?(:to_h) ? job.to_h : job.to_h
31
+ update_records do |records|
32
+ index = records.index { |item| item["id"] == record["id"] }
33
+ index ? records[index] = record : records << record
34
+ normalize_positions(records)
35
+ end
36
+ record
37
+ end
38
+
39
+ def list(include_archived: false)
40
+ records = read_records
41
+ records = records.reject { |record| record["status"] == "archived" } unless include_archived
42
+ sorted(records)
43
+ end
44
+
45
+ def find(id)
46
+ read_records.find { |record| record["id"] == id.to_s }
47
+ end
48
+
49
+ def update_status(id, status, **values)
50
+ record = nil
51
+ update_records do |records|
52
+ index = records.index { |item| item["id"] == id.to_s }
53
+ raise ArgumentError, "Unknown worker job: #{id}" unless index
54
+
55
+ job = Job.from_h(records[index])
56
+ job.update_status(status, **values)
57
+ record = job.to_h
58
+ records[index] = record
59
+ normalize_positions(records)
60
+ end
61
+ record
62
+ end
63
+
64
+ def archive(id)
65
+ update_status(id, "archived")
66
+ end
67
+
68
+ def next_queued
69
+ list.find { |record| record["status"] == "queued" }
70
+ end
71
+
72
+ private
73
+
74
+ def next_position
75
+ last = list(include_archived: true).map { |record| record["position"].to_i }.max
76
+ last ? last + 1 : 1
77
+ end
78
+
79
+ def read_records
80
+ @mutex.synchronize { read_records_unlocked }
81
+ end
82
+
83
+ def read_records_unlocked
84
+ return [] unless File.exist?(@path)
85
+
86
+ data = JSON.parse(File.read(@path))
87
+ data.is_a?(Array) ? data : []
88
+ rescue JSON::ParserError
89
+ raise "Invalid worker queue JSON: #{@path}"
90
+ end
91
+
92
+ def update_records
93
+ @mutex.synchronize do
94
+ records = read_records_unlocked
95
+ yield records
96
+ FileUtils.mkdir_p(File.dirname(@path))
97
+ File.write(@path, JSON.pretty_generate(sorted(records)) + "\n")
98
+ end
99
+ end
100
+
101
+ def sorted(records)
102
+ records.sort_by { |record| [record["position"].to_i, record["enqueued_at"].to_s, record["id"].to_s] }
103
+ end
104
+
105
+ def normalize_positions(records)
106
+ sorted(records).each_with_index do |record, index|
107
+ record["position"] = index + 1
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,72 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require_relative "../config_files"
4
+
5
+ module Kward
6
+ module Workers
7
+ # JSON-backed metadata store for worker records.
8
+ class Store
9
+ def initialize(path: File.join(ConfigFiles.config_dir, "workers.json"))
10
+ @path = path
11
+ @mutex = Mutex.new
12
+ end
13
+
14
+ attr_reader :path
15
+
16
+ def upsert(worker)
17
+ record = worker.respond_to?(:to_h) ? worker.to_h : worker.to_h
18
+ update_records do |records|
19
+ index = records.index { |item| item["id"] == record["id"] }
20
+ index ? records[index] = record : records << record
21
+ end
22
+ record
23
+ end
24
+
25
+ def list(include_archived: false)
26
+ records = read_records
27
+ records = records.reject { |record| record["status"] == "archived" } unless include_archived
28
+ records.sort_by { |record| record["created_at"].to_s }
29
+ end
30
+
31
+ def find(id)
32
+ read_records.find { |record| record["id"] == id.to_s }
33
+ end
34
+
35
+ def archive(id)
36
+ record = nil
37
+ update_records do |records|
38
+ index = records.index { |item| item["id"] == id.to_s }
39
+ raise ArgumentError, "Unknown worker: #{id}" unless index
40
+
41
+ record = records[index].merge("status" => "archived")
42
+ records[index] = record
43
+ end
44
+ record
45
+ end
46
+
47
+ private
48
+
49
+ def read_records
50
+ @mutex.synchronize { read_records_unlocked }
51
+ end
52
+
53
+ def read_records_unlocked
54
+ return [] unless File.exist?(@path)
55
+
56
+ data = JSON.parse(File.read(@path))
57
+ data.is_a?(Array) ? data : []
58
+ rescue JSON::ParserError
59
+ raise "Invalid worker store JSON: #{@path}"
60
+ end
61
+
62
+ def update_records
63
+ @mutex.synchronize do
64
+ records = read_records_unlocked
65
+ yield records
66
+ FileUtils.mkdir_p(File.dirname(@path))
67
+ File.write(@path, JSON.pretty_generate(records) + "\n")
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,23 @@
1
+ module Kward
2
+ module Workers
3
+ # Tool allowlists for worker roles.
4
+ module ToolPolicy
5
+ READ_ONLY_TOOLS = %w[list_directory read_file code_search summarize_file_structure retrieve_tool_output web_search fetch_content fetch_raw read_skill].freeze
6
+
7
+ module_function
8
+
9
+ def allowed_tool_names(role)
10
+ case role.to_s
11
+ when "request", "read_only"
12
+ READ_ONLY_TOOLS
13
+ else
14
+ nil
15
+ end
16
+ end
17
+
18
+ def write_capable?(role)
19
+ allowed_tool_names(role).nil?
20
+ end
21
+ end
22
+ end
23
+ end