clawthor 0.3.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +365 -0
- data/exe/clawthor +8 -0
- data/lib/clawthor/cli.rb +140 -0
- data/lib/clawthor/compiler.rb +1258 -0
- data/lib/clawthor/dsl.rb +95 -0
- data/lib/clawthor/orchestrator.rb +35 -0
- data/lib/clawthor/primitives/agent.rb +77 -0
- data/lib/clawthor/primitives/command.rb +20 -0
- data/lib/clawthor/primitives/hook.rb +50 -0
- data/lib/clawthor/primitives/module.rb +75 -0
- data/lib/clawthor/primitives/service.rb +80 -0
- data/lib/clawthor/primitives/skill.rb +135 -0
- data/lib/clawthor/primitives/task.rb +60 -0
- data/lib/clawthor/primitives/workspace.rb +71 -0
- data/lib/clawthor/registry.rb +38 -0
- data/lib/clawthor/version.rb +5 -0
- data/lib/clawthor.rb +38 -0
- metadata +92 -0
|
@@ -0,0 +1,1258 @@
|
|
|
1
|
+
# encoding: utf-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Clawthor
|
|
8
|
+
class Compiler
|
|
9
|
+
DEFAULT_PLUGIN_DESCRIPTION = "Plugin for Claude workflows."
|
|
10
|
+
DEFAULT_PLUGIN_AUTHOR = "James"
|
|
11
|
+
DEFAULT_MARKETPLACE_DESCRIPTION = "Claude plugins for Pants projects."
|
|
12
|
+
DEFAULT_MARKETPLACE_KEYWORDS = %w[claude plugin].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(registry, output_dir, mode: :plugin)
|
|
15
|
+
@reg = registry
|
|
16
|
+
@out = output_dir
|
|
17
|
+
@mode = mode.to_sym
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def compile!
|
|
21
|
+
ws = @reg.workspace || default_workspace
|
|
22
|
+
puts "Compiling workspace: #{ws.name}"
|
|
23
|
+
puts " Output: #{@out}"
|
|
24
|
+
|
|
25
|
+
case @mode
|
|
26
|
+
when :plugin
|
|
27
|
+
compile_plugin_bundle!(ws, @out, clean: true)
|
|
28
|
+
when :marketplace
|
|
29
|
+
compile_marketplace_bundle!(ws)
|
|
30
|
+
else
|
|
31
|
+
raise "Unknown compile mode: #{@mode}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
puts "Done. #{count_files(@out)} files generated."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def compile_plugin_bundle!(ws, output_dir, clean:)
|
|
40
|
+
previous_out = @out
|
|
41
|
+
@out = output_dir
|
|
42
|
+
FileUtils.rm_rf(@out) if clean
|
|
43
|
+
|
|
44
|
+
compile_manifest(ws)
|
|
45
|
+
compile_config(ws)
|
|
46
|
+
compile_authority(ws)
|
|
47
|
+
compile_skills
|
|
48
|
+
compile_agents
|
|
49
|
+
compile_commands
|
|
50
|
+
compile_hooks(ws)
|
|
51
|
+
compile_services
|
|
52
|
+
compile_readme(ws)
|
|
53
|
+
ensure
|
|
54
|
+
@out = previous_out
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def compile_marketplace_bundle!(ws)
|
|
58
|
+
marketplace_root = @out
|
|
59
|
+
plugin_id = ws.plugin_name
|
|
60
|
+
plugin_dir = File.join(marketplace_root, "plugins", plugin_id)
|
|
61
|
+
|
|
62
|
+
FileUtils.rm_rf(marketplace_root)
|
|
63
|
+
compile_plugin_bundle!(ws, plugin_dir, clean: false)
|
|
64
|
+
compile_marketplace_manifest(ws, marketplace_root, plugin_id)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# ─── Manifest ──────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
def compile_manifest(ws)
|
|
70
|
+
dir = File.join(@out, ".claude-plugin")
|
|
71
|
+
FileUtils.mkdir_p(dir)
|
|
72
|
+
|
|
73
|
+
manifest = {
|
|
74
|
+
"name" => ws.plugin_name,
|
|
75
|
+
"description" => plugin_description(ws.description),
|
|
76
|
+
"version" => ws.version,
|
|
77
|
+
"author" => { "name" => plugin_author(ws.author) }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
write_json(File.join(dir, "plugin.json"), manifest)
|
|
81
|
+
puts " ✓ .claude-plugin/plugin.json"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def plugin_description(description)
|
|
85
|
+
value = description.to_s.strip
|
|
86
|
+
value.empty? ? DEFAULT_PLUGIN_DESCRIPTION : value
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def plugin_author(author)
|
|
90
|
+
value = author.to_s.strip
|
|
91
|
+
value.empty? ? DEFAULT_PLUGIN_AUTHOR : value
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def compile_marketplace_manifest(ws, marketplace_root, plugin_id)
|
|
95
|
+
dir = File.join(marketplace_root, ".claude-plugin")
|
|
96
|
+
FileUtils.mkdir_p(dir)
|
|
97
|
+
|
|
98
|
+
owner = { "name" => plugin_author(ws.author) }
|
|
99
|
+
owner_url = ws.marketplace_owner_url.to_s.strip
|
|
100
|
+
owner["url"] = owner_url unless owner_url.empty?
|
|
101
|
+
|
|
102
|
+
manifest = {
|
|
103
|
+
"name" => marketplace_name(ws),
|
|
104
|
+
"description" => marketplace_description(ws),
|
|
105
|
+
"metadata" => { "description" => marketplace_description(ws) },
|
|
106
|
+
"owner" => owner,
|
|
107
|
+
"plugins" => [
|
|
108
|
+
{
|
|
109
|
+
"name" => plugin_id,
|
|
110
|
+
"source" => "./plugins/#{plugin_id}",
|
|
111
|
+
"description" => plugin_description(ws.description),
|
|
112
|
+
"version" => ws.version,
|
|
113
|
+
"keywords" => DEFAULT_MARKETPLACE_KEYWORDS
|
|
114
|
+
}
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
write_json(File.join(dir, "marketplace.json"), manifest)
|
|
119
|
+
puts " ✓ .claude-plugin/marketplace.json"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def marketplace_name(ws)
|
|
123
|
+
value = ws.marketplace_name.to_s.strip
|
|
124
|
+
value.empty? ? ws.plugin_name : value
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def marketplace_description(ws)
|
|
128
|
+
value = ws.marketplace_description.to_s.strip
|
|
129
|
+
value.empty? ? DEFAULT_MARKETPLACE_DESCRIPTION : value
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# ─── Config ────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
def compile_config(ws)
|
|
135
|
+
config = {
|
|
136
|
+
"build" => {
|
|
137
|
+
"command" => "",
|
|
138
|
+
"directory" => ".",
|
|
139
|
+
"error_threshold" => 5,
|
|
140
|
+
"_comment" => "Set 'command' to your build/typecheck command."
|
|
141
|
+
},
|
|
142
|
+
"risky_patterns" => {
|
|
143
|
+
"patterns" => %w[try catch except rescue async await exec eval subprocess],
|
|
144
|
+
"reminder" => "⚠️ Risky patterns detected in {count} file(s).\n" \
|
|
145
|
+
"❓ Are exceptions logged with context?\n" \
|
|
146
|
+
"❓ Are database operations wrapped in transactions?\n" \
|
|
147
|
+
"❓ Are async operations using timeout/retry logic?"
|
|
148
|
+
},
|
|
149
|
+
"tasks" => {
|
|
150
|
+
"active_dir" => ws.defaults[:task_dir] || "dev/active",
|
|
151
|
+
"archive_dir" => ws.defaults[:archive_dir] || "dev/completed"
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
write_json(File.join(@out, "config.json"), config)
|
|
156
|
+
puts " ✓ config.json"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# ─── Authority ──────────────────────────────────────────
|
|
160
|
+
# Generates two artefacts from the authority ladder:
|
|
161
|
+
# 1. CLAUDE.md fragment — for embedding in repo-level CLAUDE.md
|
|
162
|
+
# 2. authority skill — so Claude can resolve conflicts at runtime
|
|
163
|
+
|
|
164
|
+
def compile_authority(ws)
|
|
165
|
+
return if ws.authority_ladder.empty?
|
|
166
|
+
|
|
167
|
+
# 1. CLAUDE.md fragment
|
|
168
|
+
fragment = []
|
|
169
|
+
fragment << "## Authority Ladder"
|
|
170
|
+
fragment << ""
|
|
171
|
+
fragment << "When sources conflict, follow the higher authority."
|
|
172
|
+
fragment << "If conflict is found, fix the lower source."
|
|
173
|
+
fragment << ""
|
|
174
|
+
fragment << "| Rank | Code | Source | Description |"
|
|
175
|
+
fragment << "|------|------|--------|-------------|"
|
|
176
|
+
|
|
177
|
+
ws.authority_ladder.each do |level|
|
|
178
|
+
fragment << "| #{level[:rank]} | #{level[:code]} | #{level[:name]} | #{level[:description]} |"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
fragment << ""
|
|
182
|
+
fragment << "### Conflict Resolution Algorithm"
|
|
183
|
+
fragment << ""
|
|
184
|
+
fragment << "1. Identify which sources disagree"
|
|
185
|
+
fragment << "2. Prefer the higher-ranked authority"
|
|
186
|
+
fragment << "3. Record the conflict:"
|
|
187
|
+
fragment << " - If skills conflict with repo docs: update the skill"
|
|
188
|
+
fragment << " - If root CLAUDE.md conflicts with repo docs: add a repo exception"
|
|
189
|
+
fragment << " - If the task plan conflicts with repo invariants: revise the plan"
|
|
190
|
+
fragment << "4. Add an exception note if a lower layer intentionally differs"
|
|
191
|
+
fragment << ""
|
|
192
|
+
|
|
193
|
+
File.write(File.join(@out, "CLAUDE.md"), fragment.join("\n"))
|
|
194
|
+
puts " ✓ CLAUDE.md (authority ladder)"
|
|
195
|
+
|
|
196
|
+
# 2. Auto-generate authority skill
|
|
197
|
+
skill_dir = File.join(@out, "skills", "authority")
|
|
198
|
+
FileUtils.mkdir_p(skill_dir)
|
|
199
|
+
|
|
200
|
+
skill_md = []
|
|
201
|
+
skill_md << "---"
|
|
202
|
+
skill_md << "name: authority"
|
|
203
|
+
skill_md << "description: >"
|
|
204
|
+
skill_md << " Conflict resolution between sources. Use when you encounter"
|
|
205
|
+
skill_md << " contradictory guidance, when docs disagree with code, when"
|
|
206
|
+
skill_md << " skills conflict with repo-specific rules, or when unsure"
|
|
207
|
+
skill_md << " which source of truth to follow."
|
|
208
|
+
skill_md << "user-invocable: false"
|
|
209
|
+
skill_md << "---"
|
|
210
|
+
skill_md << ""
|
|
211
|
+
skill_md << "# Authority Ladder"
|
|
212
|
+
skill_md << ""
|
|
213
|
+
skill_md << "When sources of truth conflict, follow the higher authority."
|
|
214
|
+
skill_md << ""
|
|
215
|
+
|
|
216
|
+
ws.authority_ladder.each do |level|
|
|
217
|
+
skill_md << "## #{level[:code]}. #{level[:name]} (Rank #{level[:rank]})"
|
|
218
|
+
skill_md << ""
|
|
219
|
+
skill_md << level[:description] unless level[:description].empty?
|
|
220
|
+
unless level[:examples].empty?
|
|
221
|
+
level[:examples].each { |ex| skill_md << "- #{ex}" }
|
|
222
|
+
end
|
|
223
|
+
skill_md << ""
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
skill_md << "## Resolution Rules"
|
|
227
|
+
skill_md << ""
|
|
228
|
+
skill_md << "- Always prefer the higher-ranked source"
|
|
229
|
+
skill_md << "- If you override a lower source, note WHY"
|
|
230
|
+
skill_md << "- If a skill contradicts repo docs, the repo docs win (A3 > A5)"
|
|
231
|
+
skill_md << "- If runtime behavior contradicts docs, reality wins (A0 > everything)"
|
|
232
|
+
skill_md << "- Task-specific plans (A2) can override general skills (A5) for that task only"
|
|
233
|
+
skill_md << "- Never silently average between contradictory sources"
|
|
234
|
+
skill_md << ""
|
|
235
|
+
|
|
236
|
+
File.write(File.join(skill_dir, "SKILL.md"), skill_md.join("\n"))
|
|
237
|
+
puts " ✓ skills/authority/ (auto-generated from authority ladder)"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# ─── Skills ────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
def compile_skills
|
|
243
|
+
@reg.skills.each do |name, skill|
|
|
244
|
+
skill_dir = File.join(@out, "skills", name.to_s.tr("_", "-"))
|
|
245
|
+
FileUtils.mkdir_p(skill_dir)
|
|
246
|
+
|
|
247
|
+
# Main SKILL.md
|
|
248
|
+
md = skill_to_markdown(skill)
|
|
249
|
+
File.write(File.join(skill_dir, "SKILL.md"), md)
|
|
250
|
+
|
|
251
|
+
# Resources
|
|
252
|
+
skill.resources.each do |res|
|
|
253
|
+
res_dir = File.join(skill_dir, File.dirname(res[:path]))
|
|
254
|
+
FileUtils.mkdir_p(res_dir)
|
|
255
|
+
# Create a placeholder if the resource doesn't exist yet
|
|
256
|
+
res_path = File.join(skill_dir, res[:path])
|
|
257
|
+
unless File.exist?(res_path)
|
|
258
|
+
File.write(res_path, "# #{res[:name]}\n\nAdd detailed reference content here.\n")
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Scripts
|
|
263
|
+
skill.scripts.each do |sc|
|
|
264
|
+
sc_dir = File.join(skill_dir, "scripts")
|
|
265
|
+
FileUtils.mkdir_p(sc_dir)
|
|
266
|
+
sc_path = File.join(skill_dir, sc.file || "scripts/#{sc.name}.sh")
|
|
267
|
+
content = sc.content || "#!/bin/bash\n# #{sc.name}\n# #{sc.purpose}\n# Usage: #{sc.usage}\n\necho 'TODO: implement'\n"
|
|
268
|
+
File.write(sc_path, content)
|
|
269
|
+
FileUtils.chmod(0o755, sc_path)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
puts " ✓ skills/#{name}/ (#{skill.sections.length} sections, #{skill.resources.length} refs, #{skill.scripts.length} scripts)"
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def skill_to_markdown(skill)
|
|
277
|
+
frontmatter = {
|
|
278
|
+
"name" => skill.name.to_s.tr("_", "-"),
|
|
279
|
+
"description" => skill.description
|
|
280
|
+
}
|
|
281
|
+
frontmatter["disable-model-invocation"] = true unless skill.model_invocable
|
|
282
|
+
frontmatter["user-invocable"] = false unless skill.user_invocable
|
|
283
|
+
|
|
284
|
+
lines = ["---"]
|
|
285
|
+
frontmatter.each { |k, v| lines << "#{k}: #{yaml_value(v)}" }
|
|
286
|
+
lines << "---"
|
|
287
|
+
lines << ""
|
|
288
|
+
|
|
289
|
+
# Version header — gives Claude temporal awareness
|
|
290
|
+
if skill.skill_version || skill.since || skill.status != :active
|
|
291
|
+
lines << "| Field | Value |"
|
|
292
|
+
lines << "|-------|-------|"
|
|
293
|
+
lines << "| Version | #{skill.skill_version || '-'} |" if skill.skill_version
|
|
294
|
+
lines << "| Status | #{skill.status} |"
|
|
295
|
+
lines << "| Since | #{skill.since || '-'} |" if skill.since
|
|
296
|
+
lines << "| Applies to | #{skill.applies_to} |" if skill.applies_to
|
|
297
|
+
lines << ""
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Current standard sections
|
|
301
|
+
skill.sections.each do |section|
|
|
302
|
+
lines << "## #{section.title}"
|
|
303
|
+
lines << ""
|
|
304
|
+
section.guidelines.each do |g|
|
|
305
|
+
lines << "- #{g}"
|
|
306
|
+
end
|
|
307
|
+
section.references.each do |r|
|
|
308
|
+
lines << "- #{r[:text]}"
|
|
309
|
+
end
|
|
310
|
+
lines << ""
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Deprecated patterns — Claude should not introduce these
|
|
314
|
+
unless skill.deprecated_patterns.empty?
|
|
315
|
+
lines << "## Deprecated Patterns (do NOT introduce in new code)"
|
|
316
|
+
lines << ""
|
|
317
|
+
lines << "These patterns exist in legacy code. Do not add them to new files."
|
|
318
|
+
lines << "When editing a file that uses a deprecated pattern, migrate it"
|
|
319
|
+
lines << "opportunistically if the change is small and safe."
|
|
320
|
+
lines << ""
|
|
321
|
+
skill.deprecated_patterns.each do |d|
|
|
322
|
+
lines << "### #{d.name}"
|
|
323
|
+
d.notes.each { |n| lines << "- #{n}" }
|
|
324
|
+
lines << "- **Migrate**: #{d.migration_hint}" if d.migration_hint
|
|
325
|
+
lines << ""
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Forbidden patterns — Claude must never use these
|
|
330
|
+
unless skill.forbidden_patterns.empty?
|
|
331
|
+
lines << "## Forbidden Patterns (NEVER use)"
|
|
332
|
+
lines << ""
|
|
333
|
+
lines << "These patterns are banned. Do not introduce them under any circumstances."
|
|
334
|
+
lines << "If you encounter them in existing code, flag them for removal."
|
|
335
|
+
lines << ""
|
|
336
|
+
skill.forbidden_patterns.each do |f|
|
|
337
|
+
lines << "### #{f.name}"
|
|
338
|
+
f.notes.each { |n| lines << "- #{n}" }
|
|
339
|
+
lines << "- **Replace with**: #{f.migration_hint}" if f.migration_hint
|
|
340
|
+
lines << ""
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
lines.join("\n")
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# ─── Agents ────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
def compile_agents
|
|
350
|
+
@reg.agents.each do |name, agent|
|
|
351
|
+
dir = File.join(@out, "agents")
|
|
352
|
+
FileUtils.mkdir_p(dir)
|
|
353
|
+
|
|
354
|
+
md = agent_to_markdown(agent)
|
|
355
|
+
File.write(File.join(dir, "#{name.to_s.tr('_', '-')}.md"), md)
|
|
356
|
+
|
|
357
|
+
puts " ✓ agents/#{name}.md"
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def agent_to_markdown(agent)
|
|
362
|
+
frontmatter = {
|
|
363
|
+
"name" => agent.name.to_s.tr("_", "-"),
|
|
364
|
+
"description" => agent.description
|
|
365
|
+
}
|
|
366
|
+
frontmatter["tools"] = agent.tools if agent.tools
|
|
367
|
+
frontmatter["model"] = agent.model if agent.model
|
|
368
|
+
frontmatter["skills"] = agent.skills_list.map { |s| s.to_s.tr("_", "-") } unless agent.skills_list.empty?
|
|
369
|
+
|
|
370
|
+
lines = ["---"]
|
|
371
|
+
frontmatter.each { |k, v| lines << "#{k}: #{yaml_value(v)}" }
|
|
372
|
+
lines << "---"
|
|
373
|
+
lines << ""
|
|
374
|
+
|
|
375
|
+
# Role / description
|
|
376
|
+
lines << agent.role unless agent.role.empty?
|
|
377
|
+
lines << ""
|
|
378
|
+
|
|
379
|
+
# Prompt body if provided
|
|
380
|
+
if agent.prompt_body
|
|
381
|
+
lines << agent.prompt_body
|
|
382
|
+
lines << ""
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Input contract
|
|
386
|
+
unless agent.receives_fields.empty?
|
|
387
|
+
lines << "## Input"
|
|
388
|
+
lines << ""
|
|
389
|
+
agent.receives_fields.each do |f|
|
|
390
|
+
req = f[:required] ? "**required**" : "optional"
|
|
391
|
+
lines << "- `#{f[:name]}` (#{f[:type]}, #{req}): #{f[:desc]}"
|
|
392
|
+
end
|
|
393
|
+
lines << ""
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Output contract
|
|
397
|
+
unless agent.returns_fields.empty?
|
|
398
|
+
lines << "## Output Contract"
|
|
399
|
+
lines << ""
|
|
400
|
+
lines << "You MUST return results containing these fields:"
|
|
401
|
+
lines << ""
|
|
402
|
+
agent.returns_fields.each do |f|
|
|
403
|
+
req = f[:required] ? "**required**" : "optional"
|
|
404
|
+
lines << "- `#{f[:name]}` (#{f[:type]}, #{req}): #{f[:desc]}"
|
|
405
|
+
end
|
|
406
|
+
lines << ""
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Rules
|
|
410
|
+
unless agent.rules.empty?
|
|
411
|
+
lines << "## Rules"
|
|
412
|
+
lines << ""
|
|
413
|
+
agent.rules.each { |r| lines << "- #{r}" }
|
|
414
|
+
lines << ""
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
lines.join("\n")
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# ─── Commands ──────────────────────────────────────────
|
|
421
|
+
|
|
422
|
+
def compile_commands
|
|
423
|
+
@reg.commands.each do |name, cmd|
|
|
424
|
+
dir = File.join(@out, "commands")
|
|
425
|
+
FileUtils.mkdir_p(dir)
|
|
426
|
+
|
|
427
|
+
md = command_to_markdown(cmd)
|
|
428
|
+
File.write(File.join(dir, "#{name.to_s.tr('_', '-')}.md"), md)
|
|
429
|
+
|
|
430
|
+
puts " ✓ commands/#{name}.md"
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# Also generate task-related commands from task definitions
|
|
434
|
+
@reg.tasks.each do |name, task|
|
|
435
|
+
generate_task_commands(task)
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def command_to_markdown(cmd)
|
|
440
|
+
frontmatter = {
|
|
441
|
+
"name" => cmd.name.to_s.tr("_", "-"),
|
|
442
|
+
"description" => cmd.description
|
|
443
|
+
}
|
|
444
|
+
frontmatter["argument-hint"] = cmd.hint if cmd.hint
|
|
445
|
+
frontmatter["disable-model-invocation"] = true if cmd.disable_model_invocation
|
|
446
|
+
frontmatter["context"] = cmd.context if cmd.context
|
|
447
|
+
frontmatter["agent"] = cmd.agent_name.to_s.tr("_", "-") if cmd.agent_name
|
|
448
|
+
|
|
449
|
+
lines = ["---"]
|
|
450
|
+
frontmatter.each { |k, v| lines << "#{k}: #{yaml_value(v)}" }
|
|
451
|
+
lines << "---"
|
|
452
|
+
lines << ""
|
|
453
|
+
lines << cmd.body
|
|
454
|
+
lines << ""
|
|
455
|
+
|
|
456
|
+
lines.join("\n")
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def generate_task_commands(task)
|
|
460
|
+
dir = File.join(@out, "commands")
|
|
461
|
+
FileUtils.mkdir_p(dir)
|
|
462
|
+
|
|
463
|
+
# Generate a 'start-task' command if none exists for this task pattern
|
|
464
|
+
return if @reg.commands.key?(:plan) || @reg.commands.key?(:start)
|
|
465
|
+
|
|
466
|
+
# Auto-generate checkpoint command from task definition
|
|
467
|
+
unless @reg.commands.key?(:checkpoint)
|
|
468
|
+
checkpoint_body = "Update the active dev docs:\n\n"
|
|
469
|
+
task.files.each do |f|
|
|
470
|
+
checkpoint_body += "- Update `#{f[:filename]}`: mark completed items, add new items, update timestamps\n"
|
|
471
|
+
end
|
|
472
|
+
checkpoint_body += "\nWrite a resumption note in the context file so a fresh session can continue.\n"
|
|
473
|
+
|
|
474
|
+
lines = [
|
|
475
|
+
"---",
|
|
476
|
+
"name: checkpoint",
|
|
477
|
+
"description: Save working state to dev docs before compaction",
|
|
478
|
+
"disable-model-invocation: true",
|
|
479
|
+
"---",
|
|
480
|
+
"",
|
|
481
|
+
checkpoint_body
|
|
482
|
+
]
|
|
483
|
+
File.write(File.join(dir, "checkpoint.md"), lines.join("\n"))
|
|
484
|
+
puts " ✓ commands/checkpoint.md (auto-generated from task :#{task.name})"
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# Auto-generate resume command
|
|
488
|
+
unless @reg.commands.key?(:resume)
|
|
489
|
+
task_dir = task.directory_pattern.gsub("{task_name}", "*").gsub(/\/+$/, "")
|
|
490
|
+
lines = [
|
|
491
|
+
"---",
|
|
492
|
+
"name: resume",
|
|
493
|
+
"description: Resume work from the last checkpoint",
|
|
494
|
+
"disable-model-invocation: true",
|
|
495
|
+
"---",
|
|
496
|
+
"",
|
|
497
|
+
"Resume work from active dev docs:",
|
|
498
|
+
"",
|
|
499
|
+
"1. List directories in `#{File.dirname(task_dir)}/`",
|
|
500
|
+
"2. Read all task files",
|
|
501
|
+
"3. Check for a resumption note",
|
|
502
|
+
"4. Report progress and next steps",
|
|
503
|
+
"5. Continue implementing",
|
|
504
|
+
""
|
|
505
|
+
]
|
|
506
|
+
File.write(File.join(dir, "resume.md"), lines.join("\n"))
|
|
507
|
+
puts " ✓ commands/resume.md (auto-generated from task :#{task.name})"
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# ─── Hooks ─────────────────────────────────────────────
|
|
512
|
+
|
|
513
|
+
def compile_hooks(ws)
|
|
514
|
+
hooks_dir = File.join(@out, "hooks")
|
|
515
|
+
scripts_dir = File.join(@out, "scripts")
|
|
516
|
+
FileUtils.mkdir_p(hooks_dir)
|
|
517
|
+
FileUtils.mkdir_p(scripts_dir)
|
|
518
|
+
|
|
519
|
+
hooks_json = {}
|
|
520
|
+
|
|
521
|
+
@reg.hooks.each do |name, hook|
|
|
522
|
+
event_key = hook.event_key
|
|
523
|
+
|
|
524
|
+
hooks_json[event_key] ||= []
|
|
525
|
+
|
|
526
|
+
entry = { "hooks" => [] }
|
|
527
|
+
entry["matcher"] = hook.matcher if hook.matcher
|
|
528
|
+
|
|
529
|
+
if hook.script_content
|
|
530
|
+
# Write the script file
|
|
531
|
+
script_path = File.join(scripts_dir, "#{name}.sh")
|
|
532
|
+
File.write(script_path, hook.script_content)
|
|
533
|
+
FileUtils.chmod(0o755, script_path)
|
|
534
|
+
|
|
535
|
+
hook_def = {
|
|
536
|
+
"type" => "command",
|
|
537
|
+
"command" => "bash \"$CLAUDE_PROJECT_DIR/scripts/#{name}.sh\""
|
|
538
|
+
}
|
|
539
|
+
puts " ✓ scripts/#{name}.sh"
|
|
540
|
+
elsif hook.type == "prompt"
|
|
541
|
+
hook_def = { "type" => "prompt", "prompt" => hook.prompt }
|
|
542
|
+
hook_def["model"] = hook.model if hook.model
|
|
543
|
+
elsif hook.type == "agent"
|
|
544
|
+
hook_def = { "type" => "agent", "prompt" => hook.prompt }
|
|
545
|
+
hook_def["timeout"] = hook.timeout if hook.timeout
|
|
546
|
+
else
|
|
547
|
+
hook_def = {
|
|
548
|
+
"type" => hook.type || "command",
|
|
549
|
+
"command" => hook.command
|
|
550
|
+
}
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
hook_def["timeout"] = hook.timeout if hook.timeout && hook_def["type"] == "command"
|
|
554
|
+
entry["hooks"] << hook_def
|
|
555
|
+
hooks_json[event_key] << entry
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# Add auto-generated hooks from task definitions
|
|
559
|
+
inject_task_hooks(hooks_json)
|
|
560
|
+
|
|
561
|
+
write_json(File.join(hooks_dir, "hooks.json"), { "hooks" => hooks_json })
|
|
562
|
+
puts " ✓ hooks/hooks.json (#{@reg.hooks.length} hooks)"
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def inject_task_hooks(hooks_json)
|
|
566
|
+
return if @reg.tasks.empty?
|
|
567
|
+
|
|
568
|
+
# Auto-generate a SessionStart/compact hook to re-inject task context
|
|
569
|
+
return if @reg.hooks.values.any? { |h| h.event == :session_start && h.matcher == "compact" }
|
|
570
|
+
|
|
571
|
+
task = @reg.tasks.values.first
|
|
572
|
+
task_dir = task.directory_pattern.gsub("{task_name}", "*").gsub(/\/+$/, "")
|
|
573
|
+
base_dir = File.dirname(task_dir)
|
|
574
|
+
|
|
575
|
+
script = <<~BASH
|
|
576
|
+
#!/bin/bash
|
|
577
|
+
# Auto-generated: re-inject active task context after compaction
|
|
578
|
+
set -euo pipefail
|
|
579
|
+
|
|
580
|
+
ACTIVE_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}/#{base_dir}"
|
|
581
|
+
|
|
582
|
+
if [ ! -d "$ACTIVE_DIR" ]; then
|
|
583
|
+
exit 0
|
|
584
|
+
fi
|
|
585
|
+
|
|
586
|
+
TASKS=$(ls -d "$ACTIVE_DIR"/*/ 2>/dev/null || true)
|
|
587
|
+
if [ -z "$TASKS" ]; then
|
|
588
|
+
exit 0
|
|
589
|
+
fi
|
|
590
|
+
|
|
591
|
+
echo ""
|
|
592
|
+
echo "═══════════════════════════════════════════"
|
|
593
|
+
echo "📌 ACTIVE TASKS (restored after compaction)"
|
|
594
|
+
echo "═══════════════════════════════════════════"
|
|
595
|
+
|
|
596
|
+
for TASK_PATH in $TASKS; do
|
|
597
|
+
TASK_NAME=$(basename "$TASK_PATH")
|
|
598
|
+
echo ""
|
|
599
|
+
echo "── Task: $TASK_NAME ──"
|
|
600
|
+
|
|
601
|
+
for F in "$TASK_PATH"/*; do
|
|
602
|
+
[ -f "$F" ] || continue
|
|
603
|
+
echo ""
|
|
604
|
+
echo "### $(basename "$F")"
|
|
605
|
+
head -40 "$F"
|
|
606
|
+
LINES=$(wc -l < "$F" | tr -d ' ')
|
|
607
|
+
[ "$LINES" -gt 40 ] && echo "... ($LINES total lines)"
|
|
608
|
+
done
|
|
609
|
+
done
|
|
610
|
+
|
|
611
|
+
echo ""
|
|
612
|
+
echo "═══════════════════════════════════════════"
|
|
613
|
+
exit 0
|
|
614
|
+
BASH
|
|
615
|
+
|
|
616
|
+
scripts_dir = File.join(@out, "scripts")
|
|
617
|
+
FileUtils.mkdir_p(scripts_dir)
|
|
618
|
+
script_path = File.join(scripts_dir, "inject-context.sh")
|
|
619
|
+
File.write(script_path, script)
|
|
620
|
+
FileUtils.chmod(0o755, script_path)
|
|
621
|
+
|
|
622
|
+
hooks_json["SessionStart"] ||= []
|
|
623
|
+
hooks_json["SessionStart"] << {
|
|
624
|
+
"matcher" => "compact",
|
|
625
|
+
"hooks" => [{ "type" => "command", "command" => 'bash "$CLAUDE_PROJECT_DIR/scripts/inject-context.sh"' }]
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
puts " ✓ scripts/inject-context.sh (auto-generated from task definition)"
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# ─── Services (supervisord) ──────────────────────────────
|
|
632
|
+
|
|
633
|
+
def compile_services
|
|
634
|
+
return if @reg.services.empty?
|
|
635
|
+
|
|
636
|
+
ws = @reg.workspace || default_workspace
|
|
637
|
+
scripts_dir = File.join(@out, "scripts")
|
|
638
|
+
FileUtils.mkdir_p(scripts_dir)
|
|
639
|
+
|
|
640
|
+
compile_supervisord_conf(ws)
|
|
641
|
+
compile_svc_script(ws)
|
|
642
|
+
compile_setup_guide(ws)
|
|
643
|
+
compile_services_skill(ws)
|
|
644
|
+
compile_svc_command
|
|
645
|
+
|
|
646
|
+
puts " ✓ services/ (#{@reg.services.length} programs)"
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
# Generate the supervisord .conf with one [program:] per service
|
|
650
|
+
def compile_supervisord_conf(ws)
|
|
651
|
+
services_dir = File.join(@out, "services")
|
|
652
|
+
FileUtils.mkdir_p(services_dir)
|
|
653
|
+
|
|
654
|
+
lines = []
|
|
655
|
+
lines << "; ==================================================================="
|
|
656
|
+
lines << "; Generated by Clawthor DSL - do not edit by hand"
|
|
657
|
+
lines << "; Regenerate with: ruby generate.rb"
|
|
658
|
+
lines << "; ==================================================================="
|
|
659
|
+
lines << ""
|
|
660
|
+
|
|
661
|
+
@reg.services.each do |name, svc|
|
|
662
|
+
prog_name = name.to_s.tr("_", "-")
|
|
663
|
+
log_dir = resolve_log_dir(ws, svc)
|
|
664
|
+
|
|
665
|
+
lines << "[program:#{prog_name}]"
|
|
666
|
+
lines << "command=#{svc.command}"
|
|
667
|
+
lines << "directory=#{resolve_cwd(ws, svc)}"
|
|
668
|
+
lines << "autostart=true"
|
|
669
|
+
lines << "autorestart=#{svc.auto_restart}"
|
|
670
|
+
lines << "startretries=#{svc.start_retries}"
|
|
671
|
+
lines << "stopsignal=#{svc.stop_signal}"
|
|
672
|
+
lines << "stopwaitsecs=#{svc.stop_wait}"
|
|
673
|
+
lines << "killasgroup=#{svc.kill_as_group}"
|
|
674
|
+
lines << "stopasgroup=#{svc.kill_as_group}"
|
|
675
|
+
lines << "user=#{svc.user}" if svc.user
|
|
676
|
+
lines << "stdout_logfile=#{log_dir}/#{prog_name}.out.log"
|
|
677
|
+
lines << "stderr_logfile=#{log_dir}/#{prog_name}.err.log"
|
|
678
|
+
lines << "stdout_logfile_maxbytes=#{svc.log_config.max_bytes}"
|
|
679
|
+
lines << "stderr_logfile_maxbytes=#{svc.log_config.max_bytes}"
|
|
680
|
+
lines << "stdout_logfile_backups=#{svc.log_config.backups}"
|
|
681
|
+
lines << "stderr_logfile_backups=#{svc.log_config.backups}"
|
|
682
|
+
|
|
683
|
+
unless svc.env_vars.empty?
|
|
684
|
+
env_str = svc.env_vars.map { |k, v| "#{k}=\"#{v}\"" }.join(",")
|
|
685
|
+
lines << "environment=#{env_str}"
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
lines << ""
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
File.write(File.join(services_dir, "supervisord.conf"), lines.join("\n"))
|
|
692
|
+
puts " ✓ services/supervisord.conf"
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
# Generate the svc wrapper script that Claude (and you) call
|
|
696
|
+
def compile_svc_script(ws)
|
|
697
|
+
log_dirs = @reg.services.map { |_, svc| resolve_log_dir(ws, svc) }.uniq
|
|
698
|
+
default_log_dir = log_dirs.first || "logs"
|
|
699
|
+
|
|
700
|
+
script = <<~'BASH'
|
|
701
|
+
#!/usr/bin/env bash
|
|
702
|
+
# svc - service control wrapper for supervisord
|
|
703
|
+
# Generated by Clawthor DSL
|
|
704
|
+
#
|
|
705
|
+
# This script is how Claude interacts with your services.
|
|
706
|
+
# It wraps supervisorctl so Claude can check status, read logs,
|
|
707
|
+
# restart services, and close the observe-debug-fix loop.
|
|
708
|
+
|
|
709
|
+
set -euo pipefail
|
|
710
|
+
|
|
711
|
+
LOG_DIR="__LOG_DIR__"
|
|
712
|
+
|
|
713
|
+
usage() {
|
|
714
|
+
cat <<EOF
|
|
715
|
+
Usage: svc <command> [service] [options]
|
|
716
|
+
|
|
717
|
+
Commands:
|
|
718
|
+
status Show status of all services
|
|
719
|
+
logs <svc> [N] Show last N lines of stderr (default: 200)
|
|
720
|
+
out <svc> [N] Show last N lines of stdout (default: 200)
|
|
721
|
+
tail <svc> Follow stderr in real-time (Ctrl-C to stop)
|
|
722
|
+
restart <svc> Restart a single service
|
|
723
|
+
restart-all Restart all services
|
|
724
|
+
start Start all services
|
|
725
|
+
stop Stop all services
|
|
726
|
+
reload Reread config and update (after config changes)
|
|
727
|
+
|
|
728
|
+
Services:
|
|
729
|
+
EOF
|
|
730
|
+
# List available services from log directory
|
|
731
|
+
if [ -d "$LOG_DIR" ]; then
|
|
732
|
+
for f in "$LOG_DIR"/*.err.log; do
|
|
733
|
+
[ -f "$f" ] || continue
|
|
734
|
+
svc=$(basename "$f" .err.log)
|
|
735
|
+
echo " $svc"
|
|
736
|
+
done
|
|
737
|
+
fi
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
case "${1:-}" in
|
|
741
|
+
status)
|
|
742
|
+
supervisorctl status
|
|
743
|
+
;;
|
|
744
|
+
|
|
745
|
+
logs)
|
|
746
|
+
svc="${2:?service name required}"
|
|
747
|
+
lines="${3:-200}"
|
|
748
|
+
logfile="$LOG_DIR/${svc}.err.log"
|
|
749
|
+
if [ -f "$logfile" ]; then
|
|
750
|
+
tail -n "$lines" "$logfile"
|
|
751
|
+
else
|
|
752
|
+
echo "No log file at: $logfile" >&2
|
|
753
|
+
echo "Available:" >&2
|
|
754
|
+
ls "$LOG_DIR"/*.err.log 2>/dev/null | xargs -I{} basename {} .err.log >&2
|
|
755
|
+
exit 1
|
|
756
|
+
fi
|
|
757
|
+
;;
|
|
758
|
+
|
|
759
|
+
out)
|
|
760
|
+
svc="${2:?service name required}"
|
|
761
|
+
lines="${3:-200}"
|
|
762
|
+
logfile="$LOG_DIR/${svc}.out.log"
|
|
763
|
+
if [ -f "$logfile" ]; then
|
|
764
|
+
tail -n "$lines" "$logfile"
|
|
765
|
+
else
|
|
766
|
+
echo "No log file at: $logfile" >&2
|
|
767
|
+
exit 1
|
|
768
|
+
fi
|
|
769
|
+
;;
|
|
770
|
+
|
|
771
|
+
tail)
|
|
772
|
+
svc="${2:?service name required}"
|
|
773
|
+
logfile="$LOG_DIR/${svc}.err.log"
|
|
774
|
+
if [ -f "$logfile" ]; then
|
|
775
|
+
tail -f "$logfile"
|
|
776
|
+
else
|
|
777
|
+
echo "No log file at: $logfile" >&2
|
|
778
|
+
exit 1
|
|
779
|
+
fi
|
|
780
|
+
;;
|
|
781
|
+
|
|
782
|
+
restart)
|
|
783
|
+
svc="${2:?service name required}"
|
|
784
|
+
supervisorctl restart "$svc"
|
|
785
|
+
echo "Waiting 2s for startup..."
|
|
786
|
+
sleep 2
|
|
787
|
+
# Show last 10 lines of log so Claude can see if it came up clean
|
|
788
|
+
logfile="$LOG_DIR/${svc}.err.log"
|
|
789
|
+
[ -f "$logfile" ] && echo "--- last 10 lines of stderr ---" && tail -n 10 "$logfile"
|
|
790
|
+
;;
|
|
791
|
+
|
|
792
|
+
restart-all)
|
|
793
|
+
supervisorctl restart all
|
|
794
|
+
sleep 2
|
|
795
|
+
supervisorctl status
|
|
796
|
+
;;
|
|
797
|
+
|
|
798
|
+
start)
|
|
799
|
+
supervisorctl start all
|
|
800
|
+
sleep 2
|
|
801
|
+
supervisorctl status
|
|
802
|
+
;;
|
|
803
|
+
|
|
804
|
+
stop)
|
|
805
|
+
supervisorctl stop all
|
|
806
|
+
;;
|
|
807
|
+
|
|
808
|
+
reload)
|
|
809
|
+
supervisorctl reread
|
|
810
|
+
supervisorctl update
|
|
811
|
+
supervisorctl status
|
|
812
|
+
;;
|
|
813
|
+
|
|
814
|
+
*)
|
|
815
|
+
usage
|
|
816
|
+
exit 2
|
|
817
|
+
;;
|
|
818
|
+
esac
|
|
819
|
+
BASH
|
|
820
|
+
|
|
821
|
+
script = script.gsub("__LOG_DIR__", default_log_dir)
|
|
822
|
+
|
|
823
|
+
script_path = File.join(@out, "scripts", "svc")
|
|
824
|
+
File.write(script_path, script)
|
|
825
|
+
FileUtils.chmod(0o755, script_path)
|
|
826
|
+
puts " ✓ scripts/svc"
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
# Generate the setup guide
|
|
830
|
+
def compile_setup_guide(ws)
|
|
831
|
+
services_dir = File.join(@out, "services")
|
|
832
|
+
log_dirs = @reg.services.map { |_, svc| resolve_log_dir(ws, svc) }.uniq
|
|
833
|
+
conf_name = ws.plugin_name
|
|
834
|
+
|
|
835
|
+
guide = <<~MD
|
|
836
|
+
# Services Setup Guide
|
|
837
|
+
|
|
838
|
+
This project uses supervisord to manage background services.
|
|
839
|
+
Claude can read logs, restart services, and check status via
|
|
840
|
+
the `svc` wrapper script or the `/orchestrator:svc` command.
|
|
841
|
+
|
|
842
|
+
## Why services matter
|
|
843
|
+
|
|
844
|
+
Without managed services, Claude writes code into a black hole.
|
|
845
|
+
The service primitive closes the observability loop:
|
|
846
|
+
|
|
847
|
+
```
|
|
848
|
+
Claude edits code
|
|
849
|
+
|
|
|
850
|
+
v
|
|
851
|
+
supervisord auto-restarts the service
|
|
852
|
+
|
|
|
853
|
+
v
|
|
854
|
+
Claude reads logs (via svc or /orchestrator:svc)
|
|
855
|
+
|
|
|
856
|
+
v
|
|
857
|
+
Claude sees the error
|
|
858
|
+
|
|
|
859
|
+
v
|
|
860
|
+
Claude fixes it
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
## Prerequisites
|
|
864
|
+
|
|
865
|
+
Install supervisord:
|
|
866
|
+
|
|
867
|
+
```bash
|
|
868
|
+
# Ubuntu/Debian
|
|
869
|
+
sudo apt-get update && sudo apt-get install -y supervisor
|
|
870
|
+
|
|
871
|
+
# Fedora/RHEL
|
|
872
|
+
sudo dnf install supervisor
|
|
873
|
+
|
|
874
|
+
# macOS (via Homebrew)
|
|
875
|
+
brew install supervisor
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
Verify it's running:
|
|
879
|
+
|
|
880
|
+
```bash
|
|
881
|
+
sudo systemctl status supervisor # Linux (systemd)
|
|
882
|
+
brew services info supervisor # macOS
|
|
883
|
+
```
|
|
884
|
+
|
|
885
|
+
## Install the config
|
|
886
|
+
|
|
887
|
+
1. Create the log directory:
|
|
888
|
+
```bash
|
|
889
|
+
#{log_dirs.map { |d| "mkdir -p #{d}" }.join("\n")}
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
2. Copy the generated config to supervisord's config directory:
|
|
893
|
+
```bash
|
|
894
|
+
# Linux (typical)
|
|
895
|
+
sudo cp services/supervisord.conf /etc/supervisor/conf.d/#{conf_name}.conf
|
|
896
|
+
|
|
897
|
+
# macOS (Homebrew)
|
|
898
|
+
cp services/supervisord.conf /usr/local/etc/supervisor.d/#{conf_name}.ini
|
|
899
|
+
```
|
|
900
|
+
|
|
901
|
+
3. Load and start:
|
|
902
|
+
```bash
|
|
903
|
+
sudo supervisorctl reread
|
|
904
|
+
sudo supervisorctl update
|
|
905
|
+
sudo supervisorctl start all
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
4. Verify:
|
|
909
|
+
```bash
|
|
910
|
+
sudo supervisorctl status
|
|
911
|
+
# or
|
|
912
|
+
./scripts/svc status
|
|
913
|
+
```
|
|
914
|
+
|
|
915
|
+
## The `svc` script
|
|
916
|
+
|
|
917
|
+
The `svc` script wraps supervisorctl into a simple interface that
|
|
918
|
+
both you and Claude can use:
|
|
919
|
+
|
|
920
|
+
| Command | What it does |
|
|
921
|
+
|----------------------------|-------------------------------------------|
|
|
922
|
+
| `svc status` | Show all services and their state |
|
|
923
|
+
| `svc logs <name> [lines]` | Last N lines of stderr (default 200) |
|
|
924
|
+
| `svc out <name> [lines]` | Last N lines of stdout (default 200) |
|
|
925
|
+
| `svc tail <name>` | Follow stderr in real-time |
|
|
926
|
+
| `svc restart <name>` | Restart one service + show startup log |
|
|
927
|
+
| `svc restart-all` | Restart everything |
|
|
928
|
+
| `svc start` | Start all services |
|
|
929
|
+
| `svc stop` | Stop all services |
|
|
930
|
+
| `svc reload` | Reread config after changes |
|
|
931
|
+
|
|
932
|
+
The `restart` command deliberately waits 2 seconds then prints the
|
|
933
|
+
last 10 lines of stderr -- this way Claude immediately sees whether
|
|
934
|
+
the service came up clean or crashed.
|
|
935
|
+
|
|
936
|
+
## How Claude uses it
|
|
937
|
+
|
|
938
|
+
When Claude is implementing a backend feature, the typical loop is:
|
|
939
|
+
|
|
940
|
+
1. Claude edits a source file
|
|
941
|
+
2. The Stop hook runs `svc logs <name> 50` to check for errors
|
|
942
|
+
3. If errors are found, Claude reads the full log and fixes the issue
|
|
943
|
+
4. If clean, Claude moves to the next task
|
|
944
|
+
|
|
945
|
+
You can also ask Claude directly:
|
|
946
|
+
|
|
947
|
+
```
|
|
948
|
+
Check the api-service logs for errors
|
|
949
|
+
Restart the email-service and show me the startup log
|
|
950
|
+
What's the status of all services?
|
|
951
|
+
```
|
|
952
|
+
|
|
953
|
+
## Services defined
|
|
954
|
+
|
|
955
|
+
| Service | Command | Working Directory |
|
|
956
|
+
|---------|---------|-------------------|
|
|
957
|
+
MD
|
|
958
|
+
|
|
959
|
+
@reg.services.each do |name, svc|
|
|
960
|
+
prog_name = name.to_s.tr("_", "-")
|
|
961
|
+
guide += "| #{prog_name} | `#{svc.command}` | `#{svc.cwd}` |\n"
|
|
962
|
+
end
|
|
963
|
+
|
|
964
|
+
guide += <<~MD
|
|
965
|
+
|
|
966
|
+
## Log locations
|
|
967
|
+
|
|
968
|
+
All logs are written to `#{log_dirs.first || 'logs'}/`:
|
|
969
|
+
|
|
970
|
+
| File | Content |
|
|
971
|
+
|------|---------|
|
|
972
|
+
MD
|
|
973
|
+
|
|
974
|
+
@reg.services.each do |name, svc|
|
|
975
|
+
prog_name = name.to_s.tr("_", "-")
|
|
976
|
+
log_dir = resolve_log_dir(ws, svc)
|
|
977
|
+
guide += "| `#{log_dir}/#{prog_name}.out.log` | stdout |\n"
|
|
978
|
+
guide += "| `#{log_dir}/#{prog_name}.err.log` | stderr (errors, warnings) |\n"
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
guide += <<~MD
|
|
982
|
+
|
|
983
|
+
Logs rotate automatically at #{@reg.services.values.first&.log_config&.max_bytes || '50MB'}
|
|
984
|
+
with #{@reg.services.values.first&.log_config&.backups || 5} backups kept.
|
|
985
|
+
|
|
986
|
+
## Troubleshooting
|
|
987
|
+
|
|
988
|
+
**Service won't start**: Check `svc logs <name>` for the error.
|
|
989
|
+
The most common cause is a missing dependency or wrong working directory.
|
|
990
|
+
|
|
991
|
+
**"unix:///var/run/supervisor.sock no such file"**: supervisord isn't
|
|
992
|
+
running. Start it with `sudo systemctl start supervisor` (Linux)
|
|
993
|
+
or `brew services start supervisor` (macOS).
|
|
994
|
+
|
|
995
|
+
**Permission denied on logs**: The log directory must be writable by
|
|
996
|
+
the user running supervisord. Either `chown` the directory or run
|
|
997
|
+
services as your user (set `user=` in the conf).
|
|
998
|
+
|
|
999
|
+
**Config changes not applied**: After editing `supervisord.conf`,
|
|
1000
|
+
run `svc reload` to pick up changes.
|
|
1001
|
+
MD
|
|
1002
|
+
|
|
1003
|
+
File.write(File.join(services_dir, "SETUP.md"), guide)
|
|
1004
|
+
puts " ✓ services/SETUP.md"
|
|
1005
|
+
end
|
|
1006
|
+
|
|
1007
|
+
# Generate a skill that teaches Claude how to use the svc wrapper
|
|
1008
|
+
def compile_services_skill(ws)
|
|
1009
|
+
skill_dir = File.join(@out, "skills", "services")
|
|
1010
|
+
FileUtils.mkdir_p(skill_dir)
|
|
1011
|
+
|
|
1012
|
+
log_dirs = @reg.services.map { |_, svc| resolve_log_dir(ws, svc) }.uniq
|
|
1013
|
+
default_log_dir = log_dirs.first || "logs"
|
|
1014
|
+
|
|
1015
|
+
service_names = @reg.services.keys.map { |n| n.to_s.tr("_", "-") }
|
|
1016
|
+
|
|
1017
|
+
md = <<~SKILL
|
|
1018
|
+
---
|
|
1019
|
+
name: services
|
|
1020
|
+
description: >
|
|
1021
|
+
Service management and log inspection. Use when the user mentions
|
|
1022
|
+
services, logs, errors, crashes, restarts, or debugging backend
|
|
1023
|
+
issues. Also use when checking if code changes broke a running
|
|
1024
|
+
service, or when implementing features that affect backend services.
|
|
1025
|
+
user-invocable: false
|
|
1026
|
+
---
|
|
1027
|
+
|
|
1028
|
+
# Service Management
|
|
1029
|
+
|
|
1030
|
+
This project runs #{service_names.length} background service(s) managed by
|
|
1031
|
+
supervisord: #{service_names.join(', ')}.
|
|
1032
|
+
|
|
1033
|
+
## Checking status
|
|
1034
|
+
|
|
1035
|
+
```bash
|
|
1036
|
+
./scripts/svc status
|
|
1037
|
+
```
|
|
1038
|
+
|
|
1039
|
+
## Reading logs (most common)
|
|
1040
|
+
|
|
1041
|
+
When debugging or verifying code changes, check stderr first:
|
|
1042
|
+
|
|
1043
|
+
```bash
|
|
1044
|
+
./scripts/svc logs <service-name> 200 # last 200 lines of stderr
|
|
1045
|
+
./scripts/svc out <service-name> 200 # last 200 lines of stdout
|
|
1046
|
+
```
|
|
1047
|
+
|
|
1048
|
+
## After editing code
|
|
1049
|
+
|
|
1050
|
+
After modifying source files for a service, the service auto-restarts.
|
|
1051
|
+
Wait 2-3 seconds, then check the logs:
|
|
1052
|
+
|
|
1053
|
+
```bash
|
|
1054
|
+
sleep 2 && ./scripts/svc logs <service-name> 50
|
|
1055
|
+
```
|
|
1056
|
+
|
|
1057
|
+
If the service crashed, the last lines of stderr will show why.
|
|
1058
|
+
|
|
1059
|
+
## Restarting
|
|
1060
|
+
|
|
1061
|
+
```bash
|
|
1062
|
+
./scripts/svc restart <service-name> # restart one (shows startup log)
|
|
1063
|
+
./scripts/svc restart-all # restart everything
|
|
1064
|
+
```
|
|
1065
|
+
|
|
1066
|
+
## Available services
|
|
1067
|
+
|
|
1068
|
+
SKILL
|
|
1069
|
+
|
|
1070
|
+
@reg.services.each do |name, svc|
|
|
1071
|
+
prog_name = name.to_s.tr("_", "-")
|
|
1072
|
+
log_dir = resolve_log_dir(ws, svc)
|
|
1073
|
+
md += "- **#{prog_name}**: `#{svc.command}` (logs: `#{log_dir}/#{prog_name}.err.log`)\n"
|
|
1074
|
+
end
|
|
1075
|
+
|
|
1076
|
+
md += <<~SKILL
|
|
1077
|
+
|
|
1078
|
+
## The observe-debug-fix loop
|
|
1079
|
+
|
|
1080
|
+
1. Edit code that affects a service
|
|
1081
|
+
2. Check logs: `./scripts/svc logs <name> 50`
|
|
1082
|
+
3. If errors, read more: `./scripts/svc logs <name> 200`
|
|
1083
|
+
4. Fix the error
|
|
1084
|
+
5. Verify: `./scripts/svc logs <name> 20`
|
|
1085
|
+
6. Repeat until clean
|
|
1086
|
+
|
|
1087
|
+
Never move on to the next task without checking that affected
|
|
1088
|
+
services are running cleanly.
|
|
1089
|
+
SKILL
|
|
1090
|
+
|
|
1091
|
+
File.write(File.join(skill_dir, "SKILL.md"), md)
|
|
1092
|
+
puts " ✓ skills/services/ (auto-generated)"
|
|
1093
|
+
end
|
|
1094
|
+
|
|
1095
|
+
# Generate the /svc command for manual invocation
|
|
1096
|
+
def compile_svc_command
|
|
1097
|
+
dir = File.join(@out, "commands")
|
|
1098
|
+
FileUtils.mkdir_p(dir)
|
|
1099
|
+
|
|
1100
|
+
md = <<~CMD
|
|
1101
|
+
---
|
|
1102
|
+
name: svc
|
|
1103
|
+
description: Inspect service status and logs. Use to check if services are healthy.
|
|
1104
|
+
argument-hint: "[status|logs|restart] [service-name] [lines]"
|
|
1105
|
+
disable-model-invocation: true
|
|
1106
|
+
---
|
|
1107
|
+
|
|
1108
|
+
Run the service control command:
|
|
1109
|
+
|
|
1110
|
+
```bash
|
|
1111
|
+
./scripts/svc $ARGUMENTS
|
|
1112
|
+
```
|
|
1113
|
+
|
|
1114
|
+
If no arguments provided, show status of all services:
|
|
1115
|
+
|
|
1116
|
+
```bash
|
|
1117
|
+
./scripts/svc status
|
|
1118
|
+
```
|
|
1119
|
+
|
|
1120
|
+
After any restart, check the logs to verify the service came up clean.
|
|
1121
|
+
If you see errors, read more log lines and diagnose the issue.
|
|
1122
|
+
CMD
|
|
1123
|
+
|
|
1124
|
+
File.write(File.join(dir, "svc.md"), md)
|
|
1125
|
+
puts " ✓ commands/svc.md"
|
|
1126
|
+
end
|
|
1127
|
+
|
|
1128
|
+
def resolve_log_dir(ws, svc)
|
|
1129
|
+
base = svc.log_config.dir
|
|
1130
|
+
return base if base.start_with?("/")
|
|
1131
|
+
base
|
|
1132
|
+
end
|
|
1133
|
+
|
|
1134
|
+
def resolve_cwd(ws, svc)
|
|
1135
|
+
svc.cwd
|
|
1136
|
+
end
|
|
1137
|
+
|
|
1138
|
+
# ─── README ────────────────────────────────────────────
|
|
1139
|
+
|
|
1140
|
+
def compile_readme(ws)
|
|
1141
|
+
lines = []
|
|
1142
|
+
lines << "# #{ws.plugin_name}"
|
|
1143
|
+
lines << ""
|
|
1144
|
+
lines << ws.description unless ws.description.empty?
|
|
1145
|
+
lines << ""
|
|
1146
|
+
lines << "Generated by Clawthor DSL."
|
|
1147
|
+
lines << ""
|
|
1148
|
+
|
|
1149
|
+
lines << "## Skills"
|
|
1150
|
+
@reg.skills.each do |name, s|
|
|
1151
|
+
lines << "- **#{name}**: #{s.description}"
|
|
1152
|
+
end
|
|
1153
|
+
lines << ""
|
|
1154
|
+
|
|
1155
|
+
unless @reg.agents.empty?
|
|
1156
|
+
lines << "## Agents"
|
|
1157
|
+
@reg.agents.each do |name, a|
|
|
1158
|
+
lines << "- **#{name}**: #{a.description}"
|
|
1159
|
+
end
|
|
1160
|
+
lines << ""
|
|
1161
|
+
end
|
|
1162
|
+
|
|
1163
|
+
unless @reg.commands.empty?
|
|
1164
|
+
lines << "## Commands"
|
|
1165
|
+
@reg.commands.each do |name, c|
|
|
1166
|
+
ns = ws.plugin_name
|
|
1167
|
+
lines << "- `/#{ns}:#{name.to_s.tr('_', '-')}`: #{c.description}"
|
|
1168
|
+
end
|
|
1169
|
+
lines << ""
|
|
1170
|
+
end
|
|
1171
|
+
|
|
1172
|
+
unless @reg.hooks.empty?
|
|
1173
|
+
lines << "## Hooks"
|
|
1174
|
+
@reg.hooks.each do |name, h|
|
|
1175
|
+
lines << "- **#{name}** (#{h.event_key}): #{h.matcher || 'all'}"
|
|
1176
|
+
end
|
|
1177
|
+
lines << ""
|
|
1178
|
+
end
|
|
1179
|
+
|
|
1180
|
+
unless @reg.services.empty?
|
|
1181
|
+
lines << "## Services"
|
|
1182
|
+
lines << ""
|
|
1183
|
+
lines << "Managed by supervisord. See `services/SETUP.md` for installation."
|
|
1184
|
+
lines << ""
|
|
1185
|
+
lines << "Quick start:"
|
|
1186
|
+
lines << "```bash"
|
|
1187
|
+
lines << "sudo cp services/supervisord.conf /etc/supervisor/conf.d/#{ws.plugin_name}.conf"
|
|
1188
|
+
lines << "sudo supervisorctl reread && sudo supervisorctl update && sudo supervisorctl start all"
|
|
1189
|
+
lines << "```"
|
|
1190
|
+
lines << ""
|
|
1191
|
+
lines << "| Service | Command | Logs |"
|
|
1192
|
+
lines << "|---------|---------|------|"
|
|
1193
|
+
@reg.services.each do |name, s|
|
|
1194
|
+
prog = name.to_s.tr("_", "-")
|
|
1195
|
+
log_dir = s.log_config.dir
|
|
1196
|
+
lines << "| #{prog} | `#{s.command}` | `./scripts/svc logs #{prog}` |"
|
|
1197
|
+
end
|
|
1198
|
+
lines << ""
|
|
1199
|
+
lines << "Control: `./scripts/svc status`, `./scripts/svc restart <name>`, `./scripts/svc logs <name> [lines]`"
|
|
1200
|
+
lines << ""
|
|
1201
|
+
end
|
|
1202
|
+
|
|
1203
|
+
# File tree
|
|
1204
|
+
lines << "## File Structure"
|
|
1205
|
+
lines << ""
|
|
1206
|
+
lines << "```"
|
|
1207
|
+
lines.concat(generate_tree(@out, ""))
|
|
1208
|
+
lines << "```"
|
|
1209
|
+
|
|
1210
|
+
File.write(File.join(@out, "README.md"), lines.join("\n"))
|
|
1211
|
+
puts " ✓ README.md"
|
|
1212
|
+
end
|
|
1213
|
+
|
|
1214
|
+
# ─── Helpers ───────────────────────────────────────────
|
|
1215
|
+
|
|
1216
|
+
def yaml_value(v)
|
|
1217
|
+
case v
|
|
1218
|
+
when true, false then v.to_s
|
|
1219
|
+
when Array then v.inspect.tr('"', "'")
|
|
1220
|
+
# Simple inline for short strings, block for multiline
|
|
1221
|
+
when String
|
|
1222
|
+
v.include?("\n") ? ">\n #{v.gsub("\n", "\n ")}" : v
|
|
1223
|
+
else v.to_s
|
|
1224
|
+
end
|
|
1225
|
+
end
|
|
1226
|
+
|
|
1227
|
+
def write_json(path, data)
|
|
1228
|
+
File.write(path, JSON.pretty_generate(data) + "\n")
|
|
1229
|
+
end
|
|
1230
|
+
|
|
1231
|
+
def count_files(path = @out)
|
|
1232
|
+
Dir.glob(File.join(path, "**", "*")).count { |f| File.file?(f) }
|
|
1233
|
+
end
|
|
1234
|
+
|
|
1235
|
+
def generate_tree(dir, prefix)
|
|
1236
|
+
lines = []
|
|
1237
|
+
entries = Dir.entries(dir).reject { |e| e.start_with?(".") }.sort
|
|
1238
|
+
entries.each_with_index do |entry, idx|
|
|
1239
|
+
path = File.join(dir, entry)
|
|
1240
|
+
is_last = idx == entries.length - 1
|
|
1241
|
+
connector = is_last ? "└── " : "├── "
|
|
1242
|
+
lines << "#{prefix}#{connector}#{entry}#{'/' if File.directory?(path)}"
|
|
1243
|
+
if File.directory?(path)
|
|
1244
|
+
extension = is_last ? " " : "│ "
|
|
1245
|
+
lines.concat(generate_tree(path, prefix + extension))
|
|
1246
|
+
end
|
|
1247
|
+
end
|
|
1248
|
+
lines
|
|
1249
|
+
end
|
|
1250
|
+
|
|
1251
|
+
def default_workspace
|
|
1252
|
+
ws = Workspace.new(:default)
|
|
1253
|
+
ws.plugin_name = "orchestrator"
|
|
1254
|
+
ws.description = DEFAULT_PLUGIN_DESCRIPTION
|
|
1255
|
+
ws
|
|
1256
|
+
end
|
|
1257
|
+
end
|
|
1258
|
+
end
|