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.
@@ -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