openclacky 1.3.7 → 1.3.8
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 +4 -4
- data/CHANGELOG.md +24 -0
- data/lib/clacky/agent/hook_manager.rb +10 -1
- data/lib/clacky/agent/system_prompt_builder.rb +1 -1
- data/lib/clacky/agent.rb +9 -2
- data/lib/clacky/agent_profile.rb +7 -4
- data/lib/clacky/billing/billing_store.rb +3 -2
- data/lib/clacky/default_extensions/coding/agents/coding/avatar.png +0 -0
- data/lib/clacky/default_extensions/coding/ext.yml +4 -3
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/avatar.png +0 -0
- data/lib/clacky/default_extensions/ext-studio/ext.yml +1 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +24 -5
- data/lib/clacky/default_extensions/general/agents/general/avatar.png +0 -0
- data/lib/clacky/default_extensions/general/ext.yml +4 -3
- data/lib/clacky/default_extensions/git/panels/git/view.js +3 -3
- data/lib/clacky/default_extensions/meeting/panels/meeting/view.js +1 -2
- data/lib/clacky/extension/loader.rb +6 -0
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/server/http_server.rb +129 -5
- data/lib/clacky/shell_hook_loader.rb +266 -22
- data/lib/clacky/ui2/components/welcome_banner.rb +1 -1
- data/lib/clacky/utils/workspace_rules.rb +2 -2
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +230 -46
- data/lib/clacky/web/core/ext.js +130 -24
- data/lib/clacky/web/features/extensions/view.js +4 -3
- data/lib/clacky/web/features/new-session/store.js +13 -0
- data/lib/clacky/web/features/new-session/view.js +314 -14
- data/lib/clacky/web/features/trash/view.js +4 -5
- data/lib/clacky/web/i18n.js +34 -0
- data/lib/clacky/web/index.html +43 -11
- data/lib/clacky/web/sessions.js +3 -3
- data/lib/clacky/web/skills.js +86 -48
- data/lib/clacky/web/theme.js +3 -0
- data/lib/clacky/web/ws-dispatcher.js +11 -2
- metadata +7 -7
|
@@ -14,28 +14,48 @@ module Clacky
|
|
|
14
14
|
# hooks.yml format:
|
|
15
15
|
# hooks:
|
|
16
16
|
# before_tool_use:
|
|
17
|
+
# # Simple protocol — no `type` (or `type: command`). exit-code driven.
|
|
17
18
|
# - name: guard # optional label for logs
|
|
18
19
|
# command: "~/.clacky/hook-scripts/guard.sh"
|
|
19
20
|
# timeout: 10 # optional, seconds (default 10)
|
|
21
|
+
#
|
|
22
|
+
# # Rewrite protocol — `type: rewrite`. Rich JSON stdin/stdout, supports
|
|
23
|
+
# # matcher, updatedInput rewrite. (before_tool_use only)
|
|
24
|
+
# - type: rewrite
|
|
25
|
+
# # matcher must be the CANONICAL tool name the agent dispatches
|
|
26
|
+
# # (e.g. `terminal`), NOT an alias (`bash`/`shell`/`exec`): the
|
|
27
|
+
# # agent resolves aliases to canonical names BEFORE firing hooks,
|
|
28
|
+
# # so `matcher: bash` would never match. "*" or omitted = all tools.
|
|
29
|
+
# matcher: terminal
|
|
30
|
+
# command: "~/.clacky/hook-scripts/rewrite.sh"
|
|
31
|
+
# timeout: 30 # optional, seconds (default 60)
|
|
20
32
|
# on_complete:
|
|
21
33
|
# - command: "notify-send done"
|
|
22
34
|
#
|
|
23
35
|
# Runtime contract (per invocation):
|
|
24
36
|
# - The event payload is passed to the command as JSON on STDIN.
|
|
25
|
-
# - exit 0 → allow (default).
|
|
26
|
-
# - exit 2 → deny; STDOUT
|
|
27
|
-
# before_tool_use
|
|
37
|
+
# - exit 0 → allow (default). Rewrite hooks parse STDOUT as hookSpecificOutput.
|
|
38
|
+
# - exit 2 → deny; STDOUT (simple) or stderr/stdout (rewrite) is the reason.
|
|
39
|
+
# Only before_tool_use is checked for {action: :deny}.
|
|
28
40
|
# - any other exit / timeout / crash → logged, treated as allow (a broken
|
|
29
41
|
# hook must never wedge the agent).
|
|
42
|
+
#
|
|
43
|
+
# Rewrite entries chain in config order: each applies its updatedInput IN
|
|
44
|
+
# PLACE on the tool call (complete replacement — no merge); the first deny
|
|
45
|
+
# stops the chain.
|
|
30
46
|
class ShellHookLoader
|
|
31
|
-
DEFAULT_PATH
|
|
32
|
-
DEFAULT_TIMEOUT
|
|
33
|
-
|
|
47
|
+
DEFAULT_PATH = File.expand_path("~/.clacky/hooks.yml")
|
|
48
|
+
DEFAULT_TIMEOUT = 10
|
|
49
|
+
REWRITE_DEFAULT_TIMEOUT = 60
|
|
50
|
+
DENY_EXIT_CODE = 2
|
|
34
51
|
|
|
35
52
|
Result = Struct.new(:registered, :skipped, keyword_init: true)
|
|
36
53
|
|
|
37
|
-
|
|
38
|
-
|
|
54
|
+
# The context procs supply rewrite-protocol context at run time; optional so
|
|
55
|
+
# simple-only callers (e.g. `clacky hook_verify`) can omit them.
|
|
56
|
+
def self.load_into(hook_manager, session_id_fn: nil, cwd_fn: nil, permission_mode_fn: nil, path: DEFAULT_PATH)
|
|
57
|
+
new(path: path, session_id_fn: session_id_fn, cwd_fn: cwd_fn, permission_mode_fn: permission_mode_fn)
|
|
58
|
+
.load_into(hook_manager)
|
|
39
59
|
end
|
|
40
60
|
|
|
41
61
|
# Create a starter hooks.yml plus an example guard script. Idempotent-ish:
|
|
@@ -67,12 +87,18 @@ module Clacky
|
|
|
67
87
|
File.write(path, <<~YAML)
|
|
68
88
|
# Declarative shell hooks. Each command receives the event payload as JSON
|
|
69
89
|
# on STDIN. For before_tool_use: exit 2 = deny (STDOUT = reason), exit 0 = allow.
|
|
90
|
+
# Add `type: rewrite` to a before_tool_use entry to use the rich JSON
|
|
91
|
+
# protocol (updatedInput rewrite, matcher).
|
|
70
92
|
# Events: #{HookManager::HOOK_EVENTS.join(", ")}
|
|
71
93
|
hooks:
|
|
72
94
|
before_tool_use:
|
|
73
95
|
- name: deny-example
|
|
74
96
|
command: "#{guard}"
|
|
75
97
|
timeout: 10
|
|
98
|
+
# - type: rewrite
|
|
99
|
+
# matcher: terminal
|
|
100
|
+
# command: "~/.clacky/hook-scripts/rewrite.sh"
|
|
101
|
+
# timeout: 30
|
|
76
102
|
# on_complete:
|
|
77
103
|
# - command: "echo task finished"
|
|
78
104
|
YAML
|
|
@@ -80,8 +106,11 @@ module Clacky
|
|
|
80
106
|
path
|
|
81
107
|
end
|
|
82
108
|
|
|
83
|
-
def initialize(path: DEFAULT_PATH)
|
|
84
|
-
@path
|
|
109
|
+
def initialize(path: DEFAULT_PATH, session_id_fn: nil, cwd_fn: nil, permission_mode_fn: nil)
|
|
110
|
+
@path = path
|
|
111
|
+
@session_id_fn = session_id_fn
|
|
112
|
+
@cwd_fn = cwd_fn
|
|
113
|
+
@permission_mode_fn = permission_mode_fn
|
|
85
114
|
end
|
|
86
115
|
|
|
87
116
|
# @return [Result] counts of registered hooks and skipped (with reasons)
|
|
@@ -106,14 +135,37 @@ module Clacky
|
|
|
106
135
|
end
|
|
107
136
|
|
|
108
137
|
private def register_one(hook_manager, event, spec, result)
|
|
138
|
+
if spec["type"] == "rewrite" && event != :before_tool_use
|
|
139
|
+
# type: rewrite only makes sense under before_tool_use (it rewrites tool
|
|
140
|
+
# input); elsewhere matcher/updatedInput would be silently ignored —
|
|
141
|
+
# skip + warn so the misconfiguration is visible.
|
|
142
|
+
name = (spec["name"] || spec["command"] || "rewrite hook").to_s
|
|
143
|
+
result.skipped << [name, "type: rewrite is only valid under before_tool_use"]
|
|
144
|
+
elsif spec["type"] == "rewrite"
|
|
145
|
+
register_rewrite(hook_manager, spec, result)
|
|
146
|
+
else
|
|
147
|
+
register_simple(hook_manager, event, spec, result)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Returns [name, command] for a spec, or nil (after recording a skip) when
|
|
152
|
+
# the command is missing. Shared by the simple and rewrite registrars.
|
|
153
|
+
private def resolve_command(spec, result)
|
|
109
154
|
command = spec["command"].to_s.strip
|
|
110
155
|
name = spec["name"] || command
|
|
111
|
-
timeout = (spec["timeout"] || DEFAULT_TIMEOUT).to_i
|
|
112
|
-
|
|
113
156
|
if command.empty?
|
|
114
157
|
result.skipped << [name, "missing command"]
|
|
115
|
-
|
|
158
|
+
nil
|
|
159
|
+
else
|
|
160
|
+
[name, command]
|
|
116
161
|
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private def register_simple(hook_manager, event, spec, result)
|
|
165
|
+
resolved = resolve_command(spec, result)
|
|
166
|
+
return unless resolved
|
|
167
|
+
name, command = resolved
|
|
168
|
+
timeout = (spec["timeout"] || DEFAULT_TIMEOUT).to_i
|
|
117
169
|
|
|
118
170
|
unless HookManager::HOOK_EVENTS.include?(event)
|
|
119
171
|
result.skipped << [name, "unknown event: #{event}"]
|
|
@@ -126,25 +178,123 @@ module Clacky
|
|
|
126
178
|
result.registered << [event, name]
|
|
127
179
|
end
|
|
128
180
|
|
|
129
|
-
|
|
130
|
-
|
|
181
|
+
# Rewrite protocol (before_tool_use only): rich JSON stdin/stdout.
|
|
182
|
+
private def register_rewrite(hook_manager, spec, result)
|
|
183
|
+
resolved = resolve_command(spec, result)
|
|
184
|
+
return unless resolved
|
|
185
|
+
name, command = resolved
|
|
186
|
+
timeout = (spec["timeout"] || REWRITE_DEFAULT_TIMEOUT).to_i
|
|
187
|
+
matcher = spec["matcher"]
|
|
131
188
|
|
|
132
|
-
|
|
189
|
+
hook_manager.add(:before_tool_use) do |call|
|
|
190
|
+
tool_name = (call[:name] || call["name"]).to_s
|
|
191
|
+
next { action: :allow } unless matcher_applies?(matcher, tool_name)
|
|
192
|
+
run_rewrite(command, timeout, call, tool_name)
|
|
193
|
+
end
|
|
194
|
+
result.registered << [:before_tool_use, name]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# "*" or omitted matches every tool; otherwise the matcher must equal the
|
|
198
|
+
# canonical tool name exactly (no aliases, regex, or lists).
|
|
199
|
+
private def matcher_applies?(matcher, tool_name)
|
|
200
|
+
matcher.nil? || matcher == "*" || matcher == tool_name
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Cap on bytes buffered per stream: before_tool_use fires on every tool
|
|
204
|
+
# call, so a runaway/malicious hook could otherwise OOM the agent.
|
|
205
|
+
MAX_OUTPUT_BYTES = 8 * 1024 * 1024
|
|
206
|
+
|
|
207
|
+
# Spawn `command` in its own process group, feed `payload` on STDIN, and
|
|
208
|
+
# return [stdout, stderr, status]. stdout and stderr are drained on parallel
|
|
209
|
+
# reader threads — a single sequential reader deadlocks once a child fills
|
|
210
|
+
# the 64KB stderr pipe while keeping stdout open. The dedicated process
|
|
211
|
+
# group lets us SIGKILL the whole tree on timeout (including grandchildren
|
|
212
|
+
# that inherit the fds), so a hook that traps TERM can't hang the agent.
|
|
213
|
+
# Output is scrubbed to valid UTF-8 so a stray non-UTF-8 byte can't turn a
|
|
214
|
+
# deny into an allow via a .strip / JSON.parse raise.
|
|
215
|
+
private def capture_streams(command, payload, timeout)
|
|
216
|
+
stdout = +""
|
|
217
|
+
stderr = +""
|
|
218
|
+
# Declared here so the block assigns this var, not a block-local one
|
|
219
|
+
# (else every deny silently becomes an allow).
|
|
133
220
|
status = nil
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
221
|
+
|
|
222
|
+
Open3.popen3(command, pgroup: true) do |stdin, out, err, wait_thr|
|
|
223
|
+
pgid = wait_thr.pid
|
|
224
|
+
|
|
225
|
+
out_reader = Thread.new { drain_stream(out, stdout) }
|
|
226
|
+
err_reader = Thread.new { drain_stream(err, stderr) }
|
|
227
|
+
|
|
228
|
+
# Write stdin on its own thread: a child that doesn't read stdin (or a
|
|
229
|
+
# payload larger than the ~64KB pipe buffer) would otherwise block the
|
|
230
|
+
# main thread before it reaches wait_thr.join(timeout) — the only thing
|
|
231
|
+
# enforcing the timeout — and wedge capture_streams forever.
|
|
232
|
+
writer = Thread.new do
|
|
233
|
+
begin
|
|
234
|
+
stdin.write(payload)
|
|
235
|
+
rescue Errno::EPIPE
|
|
236
|
+
# Child already exited / stopped reading; its stdout may still be readable.
|
|
237
|
+
rescue IOError
|
|
238
|
+
# Pipes closed (timeout path) — nothing more to write.
|
|
239
|
+
ensure
|
|
240
|
+
stdin.close rescue nil
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
137
244
|
if wait_thr.join(timeout)
|
|
138
|
-
out = stdout.read
|
|
139
245
|
status = wait_thr.value
|
|
246
|
+
# Child exited; drain the readers (bounded join — a grandchild
|
|
247
|
+
# holding the pipe fd can keep them from EOF; see drain_stream).
|
|
248
|
+
out_reader.join(2)
|
|
249
|
+
err_reader.join(2)
|
|
140
250
|
else
|
|
141
|
-
|
|
251
|
+
# SIGTERM, then SIGKILL the whole group if it won't die. popen3's
|
|
252
|
+
# ensure joins wait_thr without a timeout, so the child MUST be dead
|
|
253
|
+
# before we leave the block — a hook that traps/ignores TERM can't be
|
|
254
|
+
# allowed to hang here.
|
|
255
|
+
Process.kill("TERM", -pgid) rescue nil
|
|
256
|
+
unless wait_thr.join(1)
|
|
257
|
+
Process.kill("KILL", -pgid) rescue nil
|
|
258
|
+
wait_thr.join
|
|
259
|
+
end
|
|
260
|
+
out_reader.join(1)
|
|
261
|
+
err_reader.join(1)
|
|
262
|
+
writer.kill rescue nil
|
|
142
263
|
raise Timeout::Error
|
|
143
264
|
end
|
|
144
265
|
end
|
|
145
266
|
|
|
267
|
+
[stdout.scrub!, stderr.scrub!, status]
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Read `io` into `buf` in readpartial chunks until EOF, instead of a single
|
|
271
|
+
# IO#read. readpartial returns whatever is currently available, so a
|
|
272
|
+
# grandchild that inherited the pipe fd (and thus keeps it from EOF) can no
|
|
273
|
+
# longer block us forever — already-read bytes are preserved in `buf`.
|
|
274
|
+
# Appending stops at MAX_OUTPUT_BYTES so a runaway hook can't OOM the agent;
|
|
275
|
+
# we keep draining past the cap so the child doesn't block on a full pipe.
|
|
276
|
+
private def drain_stream(io, buf)
|
|
277
|
+
loop do
|
|
278
|
+
chunk = io.readpartial(4096)
|
|
279
|
+
buf << chunk if buf.bytesize < MAX_OUTPUT_BYTES
|
|
280
|
+
end
|
|
281
|
+
rescue EOFError, IOError
|
|
282
|
+
# EOFError: pipe closed (normal end). IOError: pipes closed on block exit.
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Strip + default, so a deny never has a blank reason.
|
|
286
|
+
private def deny_reason(raw)
|
|
287
|
+
s = raw.to_s.strip
|
|
288
|
+
s.empty? ? "Denied by hook" : s
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
private def run_command(event, command, timeout, args)
|
|
292
|
+
payload = JSON.generate(build_payload(event, args))
|
|
293
|
+
|
|
294
|
+
out, _err, status = capture_streams(command, payload, timeout)
|
|
295
|
+
|
|
146
296
|
if status&.exitstatus == DENY_EXIT_CODE
|
|
147
|
-
{ action: :deny, reason: out
|
|
297
|
+
{ action: :deny, reason: deny_reason(out) }
|
|
148
298
|
else
|
|
149
299
|
{ action: :allow }
|
|
150
300
|
end
|
|
@@ -177,5 +327,99 @@ module Clacky
|
|
|
177
327
|
|
|
178
328
|
base
|
|
179
329
|
end
|
|
330
|
+
|
|
331
|
+
# --- Rewrite protocol (type: rewrite) -------------------------------------
|
|
332
|
+
|
|
333
|
+
private def run_rewrite(command, timeout, call, tool_name)
|
|
334
|
+
tool_input = parse_tool_args(call)
|
|
335
|
+
payload = build_rewrite_payload(tool_name, tool_input)
|
|
336
|
+
|
|
337
|
+
stdout, stderr, status = capture_streams(command, payload, timeout)
|
|
338
|
+
|
|
339
|
+
# nil exitstatus = killed by signal (SIGKILL/OOM) — a crash, not a clean
|
|
340
|
+
# exit. Treat as allow so a partial deny payload can't deny from garbage.
|
|
341
|
+
if status&.exitstatus.nil?
|
|
342
|
+
Clacky::Logger.warn("[ShellHookLoader] Rewrite hook '#{command}' died (signal) — allowing")
|
|
343
|
+
return { action: :allow }
|
|
344
|
+
end
|
|
345
|
+
exit_code = status.exitstatus
|
|
346
|
+
|
|
347
|
+
if exit_code == DENY_EXIT_CODE
|
|
348
|
+
source = stderr.strip.empty? ? stdout : stderr
|
|
349
|
+
return { action: :deny, reason: deny_reason(source) }
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
if exit_code != 0
|
|
353
|
+
Clacky::Logger.warn("[ShellHookLoader] Rewrite hook '#{command}' exited #{exit_code} (non-blocking) — allowing")
|
|
354
|
+
return { action: :allow }
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
result = parse_hook_output(stdout)
|
|
358
|
+
# Chained rewrite: apply updatedInput IN PLACE on `call` so the next
|
|
359
|
+
# rewrite sees it. updatedInput is a complete replacement (no merge);
|
|
360
|
+
# empty/absent means "no rewrite this time" — leave `call` untouched.
|
|
361
|
+
updated = result.delete(:updated_input)
|
|
362
|
+
call[:arguments] = JSON.generate(updated) if updated.is_a?(Hash) && !updated.empty?
|
|
363
|
+
result
|
|
364
|
+
rescue Timeout::Error
|
|
365
|
+
Clacky::Logger.warn("[ShellHookLoader] Rewrite hook '#{command}' timed out after #{timeout}s — allowing")
|
|
366
|
+
{ action: :allow }
|
|
367
|
+
rescue StandardError => e
|
|
368
|
+
Clacky::Logger.warn("[ShellHookLoader] Rewrite hook '#{command}' failed: #{e.message} — allowing")
|
|
369
|
+
{ action: :allow }
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
private def build_rewrite_payload(tool_name, tool_input)
|
|
373
|
+
JSON.generate({
|
|
374
|
+
session_id: @session_id_fn&.call,
|
|
375
|
+
cwd: @cwd_fn&.call || Dir.pwd,
|
|
376
|
+
permission_mode: @permission_mode_fn&.call || "default",
|
|
377
|
+
hook_event_name: "PreToolUse",
|
|
378
|
+
tool_name: tool_name,
|
|
379
|
+
tool_input: tool_input
|
|
380
|
+
})
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Parse the tool arguments the hook should see. Unparseable JSON is logged
|
|
384
|
+
# (not silently swallowed) but defers to an empty Hash so a broken payload
|
|
385
|
+
# doesn't wedge the agent.
|
|
386
|
+
private def parse_tool_args(call)
|
|
387
|
+
args = call[:arguments] || call["arguments"]
|
|
388
|
+
case args
|
|
389
|
+
when String then JSON.parse(args)
|
|
390
|
+
when Hash then args
|
|
391
|
+
else {}
|
|
392
|
+
end
|
|
393
|
+
rescue JSON::ParserError => e
|
|
394
|
+
Clacky::Logger.warn("[ShellHookLoader] Unparseable tool arguments (#{e.message}); hook sees empty input")
|
|
395
|
+
{}
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Missing or unparseable hookSpecificOutput defers to allow.
|
|
399
|
+
private def parse_hook_output(stdout)
|
|
400
|
+
return { action: :allow } if stdout.nil? || stdout.strip.empty?
|
|
401
|
+
|
|
402
|
+
parsed = JSON.parse(stdout.strip)
|
|
403
|
+
specific = parsed.is_a?(Hash) && parsed["hookSpecificOutput"]
|
|
404
|
+
return { action: :allow } unless specific.is_a?(Hash)
|
|
405
|
+
|
|
406
|
+
decision = specific["permissionDecision"]
|
|
407
|
+
reason = specific["permissionDecisionReason"]
|
|
408
|
+
updated_input = specific["updatedInput"]
|
|
409
|
+
|
|
410
|
+
# Permission decision other than "deny" is treated as allow: the agent's
|
|
411
|
+
# own permission_mode governs confirmation, not the hook.
|
|
412
|
+
result = case decision
|
|
413
|
+
when "deny"
|
|
414
|
+
{ action: :deny, reason: deny_reason(reason) }
|
|
415
|
+
else
|
|
416
|
+
{ action: :allow } # "allow", "ask", "defer", unknown
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
result[:updated_input] = updated_input if updated_input.is_a?(Hash)
|
|
420
|
+
result
|
|
421
|
+
rescue JSON::ParserError
|
|
422
|
+
{ action: :allow }
|
|
423
|
+
end
|
|
180
424
|
end
|
|
181
425
|
end
|
|
@@ -28,7 +28,7 @@ module Clacky
|
|
|
28
28
|
TIPS = [
|
|
29
29
|
"[*] Ask questions, edit files, or run commands",
|
|
30
30
|
"[*] Be specific for the best results",
|
|
31
|
-
"[*] Create .clackyrules to customize interactions",
|
|
31
|
+
"[*] Create .clackyrules or AGENTS.md to customize interactions",
|
|
32
32
|
"[*] Type /help for more commands"
|
|
33
33
|
].freeze
|
|
34
34
|
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module Clacky
|
|
4
4
|
module Utils
|
|
5
|
-
# Discovers
|
|
5
|
+
# Discovers project rules files in the workspace.
|
|
6
6
|
# Used by both SystemPromptBuilder (rules injection) and WelcomeBanner (UI display).
|
|
7
7
|
module WorkspaceRules
|
|
8
|
-
RULES_FILENAMES = [".clackyrules", ".cursorrules", "CLAUDE.md"].freeze
|
|
8
|
+
RULES_FILENAMES = [".clackyrules", "AGENTS.md", ".cursorrules", "CLAUDE.md"].freeze
|
|
9
9
|
SUB_PROJECT_SUMMARY_LINES = 5
|
|
10
10
|
|
|
11
11
|
# Find the main rules file in the given directory.
|
data/lib/clacky/version.rb
CHANGED