ask-coding-harness 0.1.0 → 0.2.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb36f1a55f6b263fdbffd97383c1a744d9b4864a74d04d3003270134437bc3fd
4
- data.tar.gz: f2a6fd12ffb36d5fff6baf1d59151c913dd5596db22a5d6abd366494d57be328
3
+ metadata.gz: 69387c402be4baf7feac38128c4ba8c8e5ef9b45990fb1e2ac6c6c88ae2475a4
4
+ data.tar.gz: 195abd511e806b952d02a1b17da3c3373371d6e7c54b294a571390684e74e6e3
5
5
  SHA512:
6
- metadata.gz: 37ba3d010ae2ca1f044e919b2e82f3fc55e82bf96d792c65896e5e4141a225cecbcdc8e82c07d06eca24b12089d4e624b679b2a8fc5bd42d149b7e3047e37333
7
- data.tar.gz: 3cd3c97b80c739cd6b9a6182241083856d514a2aee9f1ee6c8758f48671847377efe19f67a3a232c4eb412c725b51b3b3acd0b9f2df59381177bdf21e211643b
6
+ metadata.gz: 6631d79089e2d2c94d78431f6c2b831d63a76dd9e03e76ba1976ffe58153b48c09fb88c1976ee1699e47ae32a29952a3457a85ba986c1a05749828ef971e7030
7
+ data.tar.gz: 75c4e27cf9e1ed89cfae4f0db8973aeb5ce18c29ad553e8d875eac7b05f7aa6372d7f6dc4208ff781a7c7c1aa734caba15991c269cbc328118ab47cc8e8dea92
data/CHANGELOG.md CHANGED
@@ -1,6 +1,50 @@
1
1
  # Changelog
2
2
 
3
- ## [0.2.0] - 2026-08-10
3
+ ## [0.2.1] - 2026-08-10
4
+
5
+ ### Added
6
+
7
+ - **Declarative agents (ask-agent `agents/` convention).** Each workspace
8
+ can carry an `agents/<name>/` directory (`agent.rb` +
9
+ `instructions.md`): the harness lists them per workspace
10
+ (`GET /api/workspaces/:path/agents`), the composer has an agent picker,
11
+ and conversations remember their agent. When an agent is selected, the
12
+ session is built via `Ask::Agent.new` — the definition's tools, skills,
13
+ and model apply — and the agent's `instructions.md` becomes the system
14
+ prompt base (project context, guidelines, append, and footer still
15
+ apply, pi-style). Requires ask-coding-providers >= 0.3.2.
16
+
17
+
18
+
19
+ ### Added
20
+
21
+ - **Universal workspaces.** The harness is no longer bound to one
22
+ directory: open any project from the UI (workspace switcher + Open
23
+ workspace dialog), switch between them, and every conversation runs
24
+ inside its own workspace with its own system prompt.
25
+ - `POST /api/workspaces` (open/register), `GET /api/workspaces`
26
+ (list with name/branch/counts), `GET /api/workspaces/:path/info`.
27
+ - `POST /api/chat` accepts a `workspace` param; conversations are
28
+ created and grouped per workspace.
29
+ - Turns execute inside their workspace directory (serialized via a
30
+ turn mutex, since the shell tools default to `Dir.pwd`).
31
+ - **Pi-style system prompts.** `SystemPrompt` builds a composable prompt:
32
+ default or custom base, guidelines, `<project_context>` from
33
+ AGENTS.md/CLAUDE.md (walked from the workspace up to the root, like the
34
+ pi coding agent), an append section, and a `Current working directory:`
35
+ footer. Config: `system_prompt`, `system_prompt_append`,
36
+ `system_prompt_guidelines` (env `ACH_SYSTEM_PROMPT` still appends).
37
+ - **ask-ui-kit sidebar/shell components** extracted from the popular
38
+ coding agents' UIs (openchamber, openwebui, t3code): `ask-dialog`,
39
+ `ask-menu`, `ask-search-input`, `ask-conversation-item`,
40
+ `ask-conversation-group`. The harness frontend now builds its switcher,
41
+ sidebar (search + grouped conversation items), and dialogs on them.
42
+
43
+ ### Fixed
44
+
45
+ - `DemoAdapter#create_session` accepts the `system_prompt` keyword (the
46
+ universal runner passes it to every adapter).
47
+
4
48
 
5
49
  ### Added
6
50
 
data/README.md CHANGED
@@ -64,7 +64,7 @@ ach version
64
64
  | `ACH_PLAN_MODE` | off | plan mode (research first, then execute) |
65
65
  | `ACH_TODOS` | on | todo list tool |
66
66
  | `ACH_DB_PATH` | `./data/ask-coding-harness.db` | conversation database |
67
- | `ACH_SYSTEM_PROMPT` | — | extra system prompt lines |
67
+ | `ACH_SYSTEM_PROMPT` | — | extra system prompt lines (append section) |
68
68
 
69
69
  Programmatic use:
70
70
 
@@ -41,6 +41,7 @@ module Ask
41
41
  @adapter = nil
42
42
  @mutex = Mutex.new
43
43
  @adapter_mutex = Mutex.new
44
+ @turn_mutex = Mutex.new
44
45
  @sessions = {}
45
46
  @turns = {}
46
47
  end
@@ -124,10 +125,24 @@ module Ask
124
125
  end
125
126
  end
126
127
 
128
+ # The system prompt for a workspace. When a declarative agent is
129
+ # selected, its instructions.md become the custom base prompt
130
+ # (project context, guidelines, append, and footer still apply).
131
+ # Adapters that don't accept a per-session prompt (external ACP
132
+ # agents) ignore the extra keywords.
133
+ def system_prompt_for(workspace, agent = nil)
134
+ Ask::CodingHarness::SystemPrompt.build(
135
+ workspace: workspace,
136
+ custom: @config.system_prompt || (agent_instructions(workspace, agent) if agent),
137
+ append: @config.system_prompt_append,
138
+ guidelines: @config.system_prompt_guidelines
139
+ )
140
+ end
127
141
  private
128
142
 
129
143
  def run_turn(conversation, prompt, model: nil, &on_event)
130
- sid = session_for(conversation, model: model)
144
+ workspace = conversation["directory"] || @config.workspace
145
+ sid = session_for(conversation, workspace, model: model)
131
146
 
132
147
  conv = @store.load(conversation["id"]) || conversation
133
148
  conv["messages"] << { "role" => "user", "content" => prompt, "created_at" => Time.now.iso8601 }
@@ -135,24 +150,37 @@ module Ask
135
150
 
136
151
  outcome = :completed
137
152
  accumulated = +""
138
- adapter.send_and_stream(sid, prompt, turn_timeout: @config.turn_timeout) do |event|
139
- translated = @translator.translate(event)
140
- next unless translated
141
-
142
- case translated[:type]
143
- when "message.delta"
144
- accumulated << translated[:data][:delta].to_s
145
- when "turn.failed"
146
- outcome = :failed
147
- when "turn.aborted"
148
- outcome = :aborted
153
+ with_workspace(workspace) do
154
+ adapter.send_and_stream(sid, prompt, turn_timeout: @config.turn_timeout) do |event|
155
+ translated = @translator.translate(event)
156
+ next unless translated
157
+
158
+ case translated[:type]
159
+ when "message.delta"
160
+ accumulated << translated[:data][:delta].to_s
161
+ when "turn.failed"
162
+ outcome = :failed
163
+ when "turn.aborted"
164
+ outcome = :aborted
165
+ end
166
+ emit(on_event, translated)
149
167
  end
150
- emit(on_event, translated)
151
168
  end
152
169
 
153
170
  persist_outcome(conversation["id"], accumulated, outcome)
154
171
  end
155
172
 
173
+ # Run the block with the workspace as the process working directory.
174
+ # The shell tools default to Dir.pwd, so the turn must execute inside
175
+ # its workspace; a turn mutex keeps concurrent workspaces from racing
176
+ # on the process-global directory. Self-hosted single-user: turns
177
+ # serialize, which is both safe and simple.
178
+ def with_workspace(workspace)
179
+ @turn_mutex.synchronize do
180
+ Dir.chdir(workspace) { yield }
181
+ end
182
+ end
183
+
156
184
  # Persist the assistant turn outcome (response, error, or abort) so
157
185
  # the conversation reads correctly when reopened. Best-effort; the
158
186
  # streamed events are the source of truth for the live view.
@@ -175,16 +203,58 @@ module Ask
175
203
  nil
176
204
  end
177
205
 
178
- # One adapter session per conversation, created on first use.
179
- def session_for(conversation, model: nil)
206
+ # One adapter session per conversation, created on first use. Each
207
+ # session gets its workspace's system prompt (pi-style: default or
208
+ # custom base + guidelines + AGENTS.md/CLAUDE.md project context),
209
+ # plus the conversation's declarative agent when one is selected.
210
+ # Runs inside the workspace (the mutex makes the chdir safe) so the
211
+ # ask-agent agents/ discovery finds this workspace's definitions.
212
+ def session_for(conversation, workspace = nil, model: nil)
213
+ workspace ||= conversation["directory"] || @config.workspace
180
214
  @mutex.synchronize do
181
- @sessions[conversation["id"]] ||= adapter.create_session(
182
- conversation["directory"] || @config.workspace,
183
- model: model || @config.model
184
- )
215
+ Dir.chdir(workspace) do
216
+ @sessions[conversation["id"]] ||= adapter.create_session(
217
+ workspace,
218
+ model: model || @config.model,
219
+ system_prompt: system_prompt_for(workspace, conversation["agent"]),
220
+ agent: conversation["agent"]
221
+ )
222
+ end
185
223
  end
186
224
  end
187
225
 
226
+
227
+ # Declarative agents in a workspace (ask-agent convention):
228
+ # [{name:, instructions:}] discovered without loading the
229
+ # definitions. Safe for listing in the UI.
230
+ def agent_definitions(workspace)
231
+ Dir.chdir(workspace) do
232
+ Ask::Agent.default_agent_paths.filter_map do |base|
233
+ next unless File.directory?(base)
234
+ Dir["#{base}/*/agent.rb"].sort.filter_map do |file|
235
+ dir = File.dirname(file)
236
+ name = File.basename(dir)
237
+ next if name == "shared"
238
+ instructions = File.file?(File.join(dir, "instructions.md")) ? File.read(File.join(dir, "instructions.md")) : nil
239
+ { "name" => name, "instructions" => instructions }
240
+ end
241
+ end.flatten
242
+ end
243
+ end
244
+
245
+ # The instructions.md content of a named agent, loaded through the
246
+ # ask-agent registry (which also validates the definition).
247
+ def agent_instructions(workspace, agent)
248
+ Dir.chdir(workspace) do
249
+ Ask::Agent.rediscover!
250
+ klass = Ask::Agent.definitions[agent.to_s]&.first
251
+ klass&.instructions_content
252
+ end
253
+ end
254
+
255
+ # The server lists agents per workspace.
256
+ public :agent_definitions, :agent_instructions
257
+
188
258
  # Dispatch a control to the adapter, returning nil when the adapter
189
259
  # doesn't support it (e.g. external ACP agents without approvals).
190
260
  def send_adapter(conversation_id, method_name, *args)
@@ -217,8 +287,7 @@ module Ask
217
287
  approval: @config.approval,
218
288
  approval_required: @config.approval_policy_tools,
219
289
  plan_mode: @config.plan_mode,
220
- todos: @config.todos,
221
- system_prompt: default_system_prompt
290
+ todos: @config.todos
222
291
  )
223
292
  adapter.start
224
293
  # Tools operate relative to the process working directory; the
@@ -247,14 +316,6 @@ module Ask
247
316
  end
248
317
  end
249
318
 
250
- def default_system_prompt
251
- base = "You are the coding agent for the workspace \"#{File.basename(@config.workspace)}\" " \
252
- "at #{@config.workspace}. You can read, write, and edit files, search the codebase, " \
253
- "and run shell commands to inspect and modify the project. Work autonomously: " \
254
- "investigate, make changes, and verify your work."
255
- extra = ENV["ACH_SYSTEM_PROMPT"]
256
- extra ? "#{base}\n\n#{extra}" : base
257
- end
258
319
  end
259
320
  end
260
321
  end
@@ -30,7 +30,8 @@ module Ask
30
30
 
31
31
  attr_accessor :host, :workspace, :db_path, :model,
32
32
  :max_turns, :turn_timeout, :approval_required,
33
- :plan_mode, :todos, :tools, :adapter, :adapter_opts
33
+ :plan_mode, :todos, :tools, :adapter, :adapter_opts,
34
+ :system_prompt, :system_prompt_append, :system_prompt_guidelines
34
35
 
35
36
  def initialize
36
37
  @host = ENV.fetch("ACH_HOST", "0.0.0.0")
@@ -48,6 +49,9 @@ module Ask
48
49
  @tools = DEFAULT_TOOLS.dup
49
50
  @adapter = ENV["ACH_ADAPTER"] || ENV["CODING_PROVIDER"] || "ask_agent"
50
51
  @adapter_opts = {}
52
+ @system_prompt = nil
53
+ @system_prompt_append = ENV["ACH_SYSTEM_PROMPT"]
54
+ @system_prompt_guidelines = []
51
55
  validate!
52
56
  end
53
57
 
@@ -39,7 +39,7 @@ module Ask
39
39
  @mutex.synchronize { @aborted = true; @cv.broadcast }
40
40
  end
41
41
 
42
- def create_session(workspace_path, mode: nil, model: nil)
42
+ def create_session(workspace_path, mode: nil, model: nil, system_prompt: nil, **)
43
43
  sid = "demo_#{@sessions.size + 1}"
44
44
  @sessions[sid] = workspace_path
45
45
  sid
@@ -88,6 +88,56 @@ module Ask
88
88
  workspace_info.to_json
89
89
  end
90
90
 
91
+ # GET /api/workspaces — known workspaces (registered + with
92
+ # conversations), enriched with name, branch, and counts
93
+ r.get "workspaces" do
94
+ store = harness_store
95
+ dirs = (store.workspaces + store.projects.map { |p| p["directory"] }).uniq
96
+ dirs.filter_map { |dir| workspace_info(dir, store) }.to_json
97
+ end
98
+
99
+ # POST /api/workspaces — open a workspace by path
100
+ r.post "workspaces" do
101
+ body = JSON.parse(r.body.read)
102
+ path = body["path"].to_s.strip
103
+ if path.empty?
104
+ response.status = 400
105
+ next { error: "path is required" }.to_json
106
+ end
107
+ unless File.directory?(path)
108
+ response.status = 404
109
+ next { error: "Not a directory: #{path}" }.to_json
110
+ end
111
+ dir = harness_store.register_workspace(path)
112
+ workspace_info(dir, harness_store).to_json
113
+ end
114
+
115
+ # GET /api/workspaces/:encoded_path/info
116
+ r.on "workspaces", String do |encoded|
117
+ r.get "info" do
118
+ dir = URI.decode_www_form_component(encoded)
119
+ info = workspace_info(dir, harness_store)
120
+ if info
121
+ info.to_json
122
+ else
123
+ response.status = 404
124
+ { error: "Not a directory: #{dir}" }.to_json
125
+ end
126
+ end
127
+
128
+ # GET /api/workspaces/:encoded_path/agents — declarative agents
129
+ # (ask-agent convention: agents/<name>/agent.rb +
130
+ # instructions.md), listed without loading them.
131
+ r.get "agents" do
132
+ dir = URI.decode_www_form_component(encoded)
133
+ unless File.directory?(dir)
134
+ response.status = 404
135
+ next { error: "Not a directory: #{dir}" }.to_json
136
+ end
137
+ harness_runner.agent_definitions(dir).to_json
138
+ end
139
+ end
140
+
91
141
  # POST /api/chat — streaming turn
92
142
  r.post "chat" do
93
143
  body = JSON.parse(r.body.read)
@@ -101,8 +151,22 @@ module Ask
101
151
  end
102
152
 
103
153
  store = harness_store
154
+ workspace = body["workspace"].to_s.strip
155
+ workspace = harness_config.workspace if workspace.empty?
156
+ if !File.directory?(workspace)
157
+ response.status = 404
158
+ next { error: "Not a directory: #{workspace}" }.to_json
159
+ end
160
+ store.register_workspace(workspace)
161
+
104
162
  existing = conversation_id && store.load(conversation_id)
105
- conversation = existing || store.build(directory: harness_config.workspace)
163
+ conversation = existing || store.build(directory: workspace, agent: body["agent"])
164
+ # An explicit agent param updates the conversation (an agent
165
+ # picker can switch the persona for the next turn).
166
+ if existing && body["agent"]
167
+ conversation["agent"] = body["agent"]
168
+ conversation = store.save(conversation)
169
+ end
106
170
  new_conversation = existing.nil?
107
171
  conversation = store.save(conversation) if new_conversation
108
172
 
@@ -292,18 +356,23 @@ module Ask
292
356
  raise NotImplementedError, "built via Server.build"
293
357
  end
294
358
 
295
- def workspace_info
296
- cfg = harness_config
297
- root = cfg.workspace
359
+ def workspace_info(root = nil, store = nil)
360
+ root ||= harness_config.workspace
361
+ return nil unless File.directory?(root)
298
362
  branch = nil
299
363
  head = File.join(root, ".git", "HEAD")
300
364
  if File.file?(head)
301
365
  ref = File.read(head).strip
302
366
  branch = ref.split("/").last if ref.start_with?("ref:")
303
367
  end
304
- { name: File.basename(root), root: root, gitBranch: branch }
368
+ count = 0
369
+ if store
370
+ conv = store.list_for_directory(root)
371
+ count = conv.length
372
+ end
373
+ { name: File.basename(root), root: root, gitBranch: branch, conversation_count: count }
305
374
  rescue SystemCallError
306
- { name: File.basename(root), root: root, gitBranch: nil }
375
+ nil
307
376
  end
308
377
 
309
378
  def models
@@ -15,7 +15,9 @@ module Ask
15
15
  # conversation records; the server and runner only read/write through it.
16
16
  class Store
17
17
  CONVERSATIONS_KEY = "__conversations__"
18
+ WORKSPACES_KEY = "__workspaces__"
18
19
  MAX_CONVERSATIONS = 500
20
+ MAX_WORKSPACES = 100
19
21
 
20
22
  # @param db_path [String] path to the SQLite database file
21
23
  def initialize(db_path:)
@@ -25,12 +27,15 @@ module Ask
25
27
  end
26
28
 
27
29
  # A fresh conversation record. Not persisted until {#save}.
28
- def build(directory: nil, title: "New conversation")
30
+ # @param agent [String, nil] declarative agent name (ask-agent
31
+ # convention) or nil for the default agent
32
+ def build(directory: nil, title: "New conversation", agent: nil)
29
33
  now = Time.now.iso8601
30
34
  {
31
35
  "id" => SecureRandom.uuid,
32
36
  "title" => title,
33
37
  "directory" => directory,
38
+ "agent" => agent,
34
39
  "archived" => false,
35
40
  "messages" => [],
36
41
  "created_at" => now,
@@ -86,6 +91,22 @@ module Ask
86
91
  end.map { |dir, count| { "directory" => dir, "name" => File.basename(dir), "conversation_count" => count } }
87
92
  end
88
93
 
94
+ # ── Workspace registry ──
95
+
96
+ # Register a workspace path (idempotent). Workspaces the server has
97
+ # explicitly opened, even before any conversation exists.
98
+ def register_workspace(path)
99
+ dir = File.expand_path(path)
100
+ existing = db.list_range(WORKSPACES_KEY, 0, -1)
101
+ db.list_append(WORKSPACES_KEY, dir, max_length: MAX_WORKSPACES) unless existing.include?(dir)
102
+ dir
103
+ end
104
+
105
+ # Explicitly registered workspace paths.
106
+ def workspaces
107
+ db.list_range(WORKSPACES_KEY, 0, -1)
108
+ end
109
+
89
110
  def close
90
111
  @db&.close
91
112
  @db = nil
@@ -102,6 +123,7 @@ module Ask
102
123
  "id" => data["id"],
103
124
  "title" => data["title"] || "New conversation",
104
125
  "directory" => data["directory"],
126
+ "agent" => data["agent"],
105
127
  "archived" => data["archived"] == true,
106
128
  "message_count" => data["messages"]&.length || 0,
107
129
  "created_at" => data["created_at"],
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Ask
6
+ module CodingHarness
7
+ # Builds the agent system prompt, in the style of the pi coding agent:
8
+ # a composable base (custom or default), tool-aware guidelines, project
9
+ # context files injected as <project_context> (AGENTS.md / CLAUDE.md
10
+ # walked from the workspace up to the filesystem root), an append
11
+ # section, and the working directory as a closing footer.
12
+ #
13
+ # SystemPrompt.build(workspace: "/path/to/project")
14
+ # SystemPrompt.build(workspace: "/p", custom: "...", append: "...",
15
+ # guidelines: ["Always run tests"])
16
+ class SystemPrompt
17
+ # Files picked up per directory when building project context.
18
+ CONTEXT_FILE_CANDIDATES = %w[AGENTS.md AGENTS.MD CLAUDE.md CLAUDE.MD].freeze
19
+
20
+ # Context files larger than this are skipped (token hygiene).
21
+ MAX_CONTEXT_FILE_SIZE = 64 * 1024
22
+
23
+ # Guidelines always present, regardless of configuration.
24
+ DEFAULT_GUIDELINES = [
25
+ "Be concise in your responses",
26
+ "Show file paths clearly when working with files",
27
+ "Use absolute paths rooted at your working directory for all file and command operations",
28
+ "Investigate before you act: read the relevant files before editing them",
29
+ "Verify your work: run tests or checks after changing code",
30
+ "Never claim an action you did not take"
31
+ ].freeze
32
+
33
+ DEFAULT_PROMPT = <<~PROMPT.chomp
34
+ You are an expert coding assistant operating inside ask-coding-harness, a coding agent
35
+ harness for the ask-rb ecosystem. You help users by reading files, executing commands,
36
+ editing code, and writing new files. Work autonomously: investigate the project, make
37
+ changes, and verify your work.
38
+ PROMPT
39
+
40
+ # Build the full system prompt for a workspace.
41
+ #
42
+ # @param workspace [String] the working directory (footer + context walk)
43
+ # @param custom [String, nil] custom base prompt (replaces the default;
44
+ # project context, append, and footer are still added)
45
+ # @param append [String, nil] extra text appended after project context
46
+ # @param guidelines [Array<String>] extra guideline bullets
47
+ # @param context_files [Array<Hash>, :auto, nil] explicit project context
48
+ # files ({path:, content:}), auto-discovered from the workspace walk,
49
+ # or none
50
+ # @return [String]
51
+ def self.build(workspace:, custom: nil, append: nil, guidelines: [],
52
+ context_files: :auto)
53
+ files =
54
+ case context_files
55
+ when :auto then load_project_context(workspace)
56
+ when nil then []
57
+ else context_files
58
+ end
59
+
60
+ parts = []
61
+ parts << (custom.nil? || custom.empty? ? DEFAULT_PROMPT : custom)
62
+ parts << render_guidelines(guidelines)
63
+ parts << render_project_context(files) unless files.empty?
64
+ parts << append unless append.nil? || append.empty?
65
+ parts << "Current working directory: #{workspace.to_s.tr("\\", "/")}"
66
+ parts.reject { |p| p.nil? || p.empty? }.join("\n\n")
67
+ end
68
+
69
+ # Walk from the workspace up to the filesystem root, collecting the
70
+ # nearest AGENTS.md / CLAUDE.md in each directory (nearest first,
71
+ # deduplicated by realpath). Mirrors the pi coding agent's project
72
+ # context discovery.
73
+ #
74
+ # @param workspace [String]
75
+ # @return [Array<Hash>] [{path:, content:}]
76
+ def self.load_project_context(workspace)
77
+ files = []
78
+ seen = {}
79
+
80
+ dir = File.expand_path(workspace)
81
+ loop do
82
+ candidate = context_file_in(dir)
83
+ if candidate && !seen.key?(candidate[:realpath])
84
+ seen[candidate[:realpath]] = true
85
+ files << candidate[:file]
86
+ end
87
+
88
+ parent = File.dirname(dir)
89
+ break if parent == dir
90
+ dir = parent
91
+ end
92
+
93
+ # Nearest first: the walk starts at the workspace.
94
+ files
95
+ end
96
+
97
+ # The first existing context file in a directory, or nil.
98
+ def self.context_file_in(dir)
99
+ CONTEXT_FILE_CANDIDATES.each do |name|
100
+ path = File.join(dir, name)
101
+ next unless File.file?(path)
102
+ size = File.size(path)
103
+ next if size > MAX_CONTEXT_FILE_SIZE
104
+ content = File.read(path, encoding: "UTF-8")
105
+ return { file: { path: path, content: content }, realpath: File.realpath(path) }
106
+ rescue SystemCallError, EncodingError
107
+ next
108
+ end
109
+ nil
110
+ end
111
+
112
+ def self.render_guidelines(extra)
113
+ list = DEFAULT_GUIDELINES.dup
114
+ Array(extra).each do |g|
115
+ normalized = g.to_s.strip
116
+ list << normalized unless normalized.empty? || list.include?(normalized)
117
+ end
118
+ "Guidelines:\n" + list.map { |g| "- #{g}" }.join("\n")
119
+ end
120
+
121
+ def self.render_project_context(files)
122
+ body = files.map do |f|
123
+ "<project_instructions path=\"#{f[:path]}\">\n#{f[:content]}\n</project_instructions>"
124
+ end.join("\n\n")
125
+ "<project_context>\n\nProject-specific instructions and guidelines:\n\n#{body}\n</project_context>"
126
+ end
127
+ end
128
+ end
129
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module CodingHarness
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.1"
6
6
  end
7
7
  end
@@ -58,5 +58,6 @@ require "ask-state-providers"
58
58
  require "ask/coding_harness/config"
59
59
  require "ask/coding_harness/store"
60
60
  require "ask/coding_harness/event_translator"
61
+ require "ask/coding_harness/system_prompt"
61
62
  require "ask/coding_harness/agent_runner"
62
63
  require "ask/coding_harness/runner"
@@ -0,0 +1 @@
1
+ :root{color-scheme:dark;--bg: #0b0b0c;--fg: #e5e5e5;--surface: #131316;--surface-muted: #161618;--border: #26262a;--border-strong: #3f3f46;--text-muted: #a3a3a3;--text-faint: #737373;--accent: #2f6feb;--danger: #dc2626;--font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif}html,body{margin:0;height:100%;background:var(--bg);color:var(--fg);font-family:var(--font);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}::selection{background:#2f6feb59}*{scrollbar-width:thin;scrollbar-color:#3f3f46 transparent}*::-webkit-scrollbar{width:8px;height:8px}*::-webkit-scrollbar-thumb{background:#3f3f46;border-radius:4px}*::-webkit-scrollbar-track{background:transparent}.tool-stream.svelte-1l486hy{display:flex;flex-direction:column;gap:.75rem;margin-top:.75rem}.block.svelte-1l486hy{display:flex;flex-direction:column;gap:.5rem}.tool.svelte-1l486hy{border:1px solid #26262a;border-radius:.625rem;background:#131316;overflow:hidden}.tool-head.svelte-1l486hy{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;font-size:.8125rem}.tool-icon.svelte-1l486hy{font-size:.875rem}.tool-name.svelte-1l486hy{font-weight:600;color:#d4d4d4;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.75rem}.tool-status.svelte-1l486hy{margin-left:auto;font-size:.6875rem;text-transform:uppercase;letter-spacing:.04em}.tool-status.running.svelte-1l486hy{color:#fbbf24;animation:svelte-1l486hy-pulse 1.2s infinite}.tool-status.done.svelte-1l486hy{color:#4ade80}.tool-status.failed.svelte-1l486hy{color:#f87171}@keyframes svelte-1l486hy-pulse{0%,to{opacity:1}50%{opacity:.4}}.tool-args.svelte-1l486hy{margin:0;padding:.375rem .75rem;border-top:1px solid #1f1f22;background:#0d0d0f;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.6875rem;line-height:1.6;color:#a3a3a3;white-space:pre-wrap;word-break:break-word;max-height:8rem;overflow-y:auto}.approvals.svelte-1l486hy{gap:.5rem}.approve-all.svelte-1l486hy{align-self:flex-start;font:inherit;font-size:.75rem;font-weight:600;padding:.375rem .75rem;border-radius:.5rem;border:1px solid #ea580c;background:#ea580c;color:#fff;cursor:pointer}.assistant.svelte-kpbn6d{max-width:46rem;margin:0 auto 1.5rem}.thinking.svelte-kpbn6d{margin-bottom:.75rem;border:1px solid #26262a;border-radius:.625rem;background:#131316}.thinking.svelte-kpbn6d summary:where(.svelte-kpbn6d){display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;cursor:pointer;list-style:none;font-size:.75rem}.thinking.svelte-kpbn6d summary:where(.svelte-kpbn6d)::-webkit-details-marker{display:none}.thinking-label.svelte-kpbn6d{color:#a3a3a3}.thinking-toggle.svelte-kpbn6d{margin-left:auto;font-size:.6875rem;color:#737373}.thinking-body.svelte-kpbn6d{padding:0 .75rem .75rem;color:#a3a3a3;font-size:.8125rem;line-height:1.6;white-space:pre-wrap}.markdown.svelte-kpbn6d{font-size:.9375rem;line-height:1.7;color:#e5e5e5;overflow-wrap:break-word}.markdown.svelte-kpbn6d p{margin:0 0 .75rem}.markdown.svelte-kpbn6d p:last-child{margin-bottom:0}.markdown.svelte-kpbn6d code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.8125rem;background:#1a1a1d;padding:.125rem .375rem;border-radius:.375rem;border:1px solid #26262a}.markdown.svelte-kpbn6d .code-block{margin:.75rem 0;padding:.75rem;border-radius:.625rem;background:#0d0d0f;border:1px solid #26262a;overflow-x:auto;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.8125rem;line-height:1.6;white-space:pre}.typing.svelte-kpbn6d{display:flex;gap:.25rem;padding:.5rem 0}.typing.svelte-kpbn6d span:where(.svelte-kpbn6d){width:.375rem;height:.375rem;border-radius:50%;background:#52525b;animation:svelte-kpbn6d-blink 1.2s infinite}.typing.svelte-kpbn6d span:where(.svelte-kpbn6d):nth-child(2){animation-delay:.2s}.typing.svelte-kpbn6d span:where(.svelte-kpbn6d):nth-child(3){animation-delay:.4s}@keyframes svelte-kpbn6d-blink{0%,to{opacity:.25}50%{opacity:1}}.user.svelte-10oznxe{display:flex;justify-content:flex-end;max-width:46rem;margin:0 auto 1.5rem}.bubble.svelte-10oznxe{max-width:85%;padding:.625rem .9375rem;border-radius:1rem 1rem .25rem;background:#1f4f8a;color:#f5f9ff;font-size:.9375rem;line-height:1.6;overflow-wrap:break-word}.bubble.svelte-10oznxe code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.8125rem;background:#ffffff26;padding:.125rem .375rem;border-radius:.375rem}.chat.svelte-xdaci2{flex:1;overflow-y:auto;padding:1.5rem 1rem 2rem;scroll-behavior:smooth;-webkit-overflow-scrolling:touch}.empty.svelte-xdaci2{max-width:30rem;margin:12vh auto 0;text-align:center;color:#a3a3a3;padding:0 1rem}.empty-logo.svelte-xdaci2{font-size:2.5rem;margin-bottom:.75rem}.empty.svelte-xdaci2 h1:where(.svelte-xdaci2){font-size:1.375rem;margin:0 0 .5rem;color:#e5e5e5}.empty.svelte-xdaci2 p:where(.svelte-xdaci2){font-size:.875rem;line-height:1.6;margin:0}.sidebar.svelte-181dlmc{display:flex;flex-direction:column;height:100%}.head.svelte-181dlmc{display:flex;align-items:center;gap:.5rem;padding:.75rem;border-bottom:1px solid #1f1f22}.ws-name.svelte-181dlmc{font-size:.6875rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:#737373;padding:0 .125rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.new.svelte-181dlmc{flex:1;display:inline-flex;align-items:center;justify-content:center;gap:.5rem;font:inherit;font-size:.8125rem;font-weight:600;padding:.5rem;border-radius:.625rem;border:1px solid #2a2a2e;background:#161618;color:#e5e5e5;cursor:pointer}.new.svelte-181dlmc:hover{background:#1f1f22}.new.svelte-181dlmc:disabled{opacity:.5;cursor:default}.close.svelte-181dlmc{display:none;width:2.25rem;height:2.25rem;border:none;border-radius:.5rem;background:transparent;color:#a3a3a3;cursor:pointer}@media(max-width:767px){.close.svelte-181dlmc{display:inline-flex;align-items:center;justify-content:center}}.search.svelte-181dlmc{padding:.625rem .75rem .25rem}.list.svelte-181dlmc{flex:1;overflow-y:auto;padding:.375rem .5rem .75rem;display:flex;flex-direction:column}.empty.svelte-181dlmc{padding:1rem .5rem;font-size:.8125rem;color:#737373;text-align:center}.rename-row.svelte-181dlmc{padding:.375rem .75rem}.rename-row.svelte-181dlmc input:where(.svelte-181dlmc){width:100%;font:inherit;font-size:.8125rem;padding:.25rem .5rem;border-radius:.375rem;border:1px solid #3f3f46;background:#0d0d0f;color:#e5e5e5;outline:none}.composer.svelte-1n8df3y{flex-shrink:0;padding:.75rem 1rem calc(.75rem + env(safe-area-inset-bottom));background:linear-gradient(transparent,#0b0b0c 40%)}.box.svelte-1n8df3y{display:flex;align-items:flex-end;gap:.5rem;max-width:46rem;margin:0 auto;padding:.5rem;background:#161618;border:1px solid #2a2a2e;border-radius:1rem;box-shadow:0 4px 24px #0006}.box.svelte-1n8df3y:focus-within{border-color:#3f3f46}textarea.svelte-1n8df3y{flex:1;resize:none;border:none;outline:none;background:transparent;color:#e5e5e5;font:inherit;font-size:.9375rem;line-height:1.5;padding:.375rem .5rem;max-height:180px}textarea.svelte-1n8df3y::placeholder{color:#737373}.send.svelte-1n8df3y{display:inline-flex;align-items:center;justify-content:center;width:2.25rem;height:2.25rem;border:none;border-radius:.75rem;background:#2f6feb;color:#fff;cursor:pointer;flex-shrink:0}.send.svelte-1n8df3y:disabled{background:#26262a;color:#737373;cursor:default}.send.stop.svelte-1n8df3y{background:#b91c1c}.hint.svelte-1n8df3y{display:flex;align-items:center;gap:.5rem;max-width:46rem;margin:.5rem auto 0;padding:0 .25rem;font-size:.6875rem;color:#737373}.model-chip.svelte-1n8df3y,.agent-chip.svelte-1n8df3y{color:#a3a3a3;border:1px solid #26262a;border-radius:999px;padding:.125rem .5rem;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.agent-chip.svelte-1n8df3y .trigger{padding:0;border:none;font-family:inherit;font-size:inherit}.agent-chip.svelte-1n8df3y .trigger:hover{background:transparent}.shortcut.svelte-1n8df3y{margin-left:auto}section.svelte-1u3w06f{padding:.75rem 1.25rem 1rem;border-top:1px solid #1f1f22}section.svelte-1u3w06f h3:where(.svelte-1u3w06f){margin:0 0 .5rem;font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:#a3a3a3}.row.svelte-1u3w06f{display:flex;gap:.75rem;padding:.25rem 0;font-size:.8125rem}.label.svelte-1u3w06f{color:#737373;width:6rem;flex-shrink:0}.value.svelte-1u3w06f{color:#e5e5e5;overflow-wrap:anywhere}.mono.svelte-1u3w06f{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.75rem}.models.svelte-1u3w06f{display:flex;flex-wrap:wrap;gap:.375rem}.model.svelte-1u3w06f{font:inherit;font-size:.75rem;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;padding:.375rem .625rem;border-radius:.5rem;border:1px solid #2a2a2e;background:#161618;color:#d4d4d4;cursor:pointer}.model.active.svelte-1u3w06f{border-color:#2f6feb;background:#1d3a63;color:#fff}.hint.svelte-1u3w06f{font-size:.75rem;color:#737373;margin:.5rem 0 0}.switcher.svelte-1xx308a{min-width:0;flex:1}.trigger-name.svelte-1xx308a{font-weight:600;font-size:.9375rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.menu-footer.svelte-1xx308a{padding:.25rem .125rem 0}.open-ws.svelte-1xx308a{width:100%;text-align:left;font:inherit;font-size:.8125rem;padding:.5rem .625rem;border:none;border-radius:.5rem;background:transparent;color:inherit;cursor:pointer}.open-ws.svelte-1xx308a:hover{background:var(--ask-surface-hover, #f5f5f5)}.hint.svelte-1xx308a{margin:0 0 .75rem;font-size:.75rem;line-height:1.5;color:var(--ask-text-muted, #a3a3a3)}.path-input.svelte-1xx308a{width:100%;font:inherit;font-size:.875rem;padding:.5rem .75rem;border-radius:.5rem;border:1px solid var(--ask-border-strong, #d4d4d4);background:var(--ask-surface-muted, #fafafa);color:inherit;outline:none}.path-input.svelte-1xx308a:focus{border-color:var(--ask-focus, #a3a3a3)}.error.svelte-1xx308a{margin-top:.5rem;font-size:.75rem;color:var(--ask-danger, #dc2626)}.btn.svelte-1xx308a{font:inherit;font-size:.8125rem;font-weight:600;padding:.5rem .875rem;border-radius:.5rem;cursor:pointer}.cancel.svelte-1xx308a{border:1px solid var(--ask-border-strong, #d4d4d4);background:transparent;color:inherit}.go.svelte-1xx308a{border:1px solid var(--ask-accent, #c2410c);background:var(--ask-accent, #c2410c);color:var(--ask-accent-text, #fafafa)}.go.svelte-1xx308a:disabled{opacity:.5;cursor:default}*{box-sizing:border-box}html,body{margin:0;height:100%;background:var(--bg, #0b0b0c);color:var(--fg, #e5e5e5);font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;-webkit-font-smoothing:antialiased}#app{height:100%}.app.svelte-1n46o8q{display:flex;flex-direction:column;height:100dvh}.topbar.svelte-1n46o8q{display:flex;align-items:center;gap:.75rem;padding:.625rem 1rem;padding-top:calc(.625rem + env(safe-area-inset-top));background:#111113;border-bottom:1px solid #1f1f22;flex-shrink:0;-webkit-user-select:none;user-select:none}.workspace.svelte-1n46o8q{display:flex;align-items:center;gap:.5rem;min-width:0;flex:1}.topbar-actions.svelte-1n46o8q{display:flex;align-items:center;gap:.25rem}.icon-btn.svelte-1n46o8q{display:inline-flex;align-items:center;justify-content:center;width:2.25rem;height:2.25rem;border:none;border-radius:.5rem;background:transparent;color:#d4d4d4;cursor:pointer}.icon-btn.svelte-1n46o8q:hover{background:#1f1f22}.icon-btn.stop.svelte-1n46o8q{color:#f87171}.body.svelte-1n46o8q{display:flex;flex:1;min-height:0}.sidebar-wrap.svelte-1n46o8q{width:min(85vw,19rem);flex-shrink:0;border-right:1px solid #1f1f22;background:#0e0e10;z-index:20}@media(max-width:767px){.sidebar-wrap.svelte-1n46o8q{position:fixed;inset:0 auto 0 0;height:100dvh;box-shadow:0 0 40px #00000080}}.main.svelte-1n46o8q{flex:1;display:flex;flex-direction:column;min-width:0;position:relative}.banner-error.svelte-1n46o8q{margin:.75rem 1rem 0;padding:.625rem .875rem;border-radius:.625rem;background:#3f0d0d;color:#fca5a5;font-size:.8125rem;border:1px solid #7f1d1d}