@zalom/plastic 1.0.0-alpha.5 → 1.0.0-alpha.6

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.0.0-alpha.5",
3
+ "version": "1.0.0-alpha.6",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,888 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # Plastic doctor — diagnostic engine that checks a Plastic installation for health issues.
6
+ # Usage: ruby ~/.plastic/scripts/doctor.rb [--agent claude|codex|hermes] [--help]
7
+ #
8
+ # Output: JSON to stdout. Warnings/errors to stderr.
9
+ # Exit codes: 0 (all pass), 1 (warnings only), 2 (failures present)
10
+ # Read-only — never modifies files.
11
+
12
+ require "json"
13
+ require "yaml"
14
+ require "time"
15
+
16
+ PLASTIC_HOME = File.join(Dir.home, ".plastic")
17
+
18
+ AGENTS = {
19
+ "claude" => { name: "Claude Code", dir: File.join(Dir.home, ".claude") },
20
+ "codex" => { name: "Codex CLI", dir: File.join(Dir.home, ".agents") },
21
+ "hermes" => { name: "Hermes", dir: File.join(Dir.home, ".hermes") },
22
+ }.freeze
23
+
24
+ REQUIRED_INDEX_SECTIONS = ["## Active", "## Future", "## Clusters", "## Abandoned", "## Completed"].freeze
25
+
26
+ REQUIRED_FRONTMATTER_FIELDS = %w[id intent sources chain created author tags].freeze
27
+
28
+ CLAUDE_HOOK_SCRIPTS = %w[
29
+ plastic-session-start
30
+ plastic-check-update
31
+ plastic-savepoint
32
+ plastic-gate-check
33
+ plastic-continue
34
+ plastic-future-intent-check
35
+ ].freeze
36
+
37
+ CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit].freeze
38
+
39
+ REQUIRED_SCRIPTS = %w[
40
+ folgezettel-id
41
+ read-config
42
+ hook-session-start
43
+ hook-continue
44
+ hook-future-intent-check
45
+ hook-gate-check
46
+ doctor.rb
47
+ ].freeze
48
+
49
+ # --- Flag parsing ---
50
+
51
+ def parse_args(argv)
52
+ agent = "claude"
53
+ help = false
54
+
55
+ i = 0
56
+ while i < argv.length
57
+ case argv[i]
58
+ when "--agent"
59
+ if argv[i + 1] && AGENTS.key?(argv[i + 1])
60
+ agent = argv[i + 1]
61
+ i += 2
62
+ else
63
+ $stderr.puts "Error: --agent requires one of: #{AGENTS.keys.join(", ")}"
64
+ exit 2
65
+ end
66
+ when "--help", "-h"
67
+ help = true
68
+ i += 1
69
+ else
70
+ i += 1
71
+ end
72
+ end
73
+
74
+ { agent: agent, help: help }
75
+ end
76
+
77
+ def show_help
78
+ $stderr.puts <<~HELP
79
+
80
+ plastic doctor — diagnose Plastic installation health
81
+
82
+ Usage:
83
+ ruby ~/.plastic/scripts/doctor.rb [options]
84
+
85
+ Options:
86
+ --agent NAME Agent to check: claude (default), codex, hermes
87
+ -h, --help Show this help
88
+
89
+ Output:
90
+ JSON to stdout with check results.
91
+ Exit 0 = all pass, 1 = warnings only, 2 = failures present.
92
+
93
+ HELP
94
+ end
95
+
96
+ # --- Utility helpers ---
97
+
98
+ def read_version
99
+ version_path = File.join(PLASTIC_HOME, "VERSION")
100
+ return nil unless File.exist?(version_path)
101
+
102
+ File.read(version_path).strip
103
+ end
104
+
105
+ def read_json_safe(path)
106
+ return nil unless File.exist?(path)
107
+
108
+ JSON.parse(File.read(path))
109
+ rescue JSON::ParserError
110
+ content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
111
+ JSON.parse(content)
112
+ rescue
113
+ nil
114
+ end
115
+
116
+ def load_yaml_safe(path)
117
+ return nil unless File.exist?(path)
118
+
119
+ YAML.safe_load(File.read(path)) || {}
120
+ rescue => e
121
+ $stderr.puts "Warning: failed to parse #{path}: #{e.message}"
122
+ nil
123
+ end
124
+
125
+ def tilde(path)
126
+ path.sub(Dir.home, "~")
127
+ end
128
+
129
+ def check(category:, name:, status:, message:, details: [], fixable: false, fix_hint: nil)
130
+ result = {
131
+ category: category,
132
+ name: name,
133
+ status: status,
134
+ message: message,
135
+ details: details,
136
+ fixable: fixable,
137
+ }
138
+ result[:fix_hint] = fix_hint if fix_hint
139
+ result
140
+ end
141
+
142
+ # --- Parse frontmatter from an intent markdown file ---
143
+
144
+ def parse_frontmatter(path)
145
+ return nil unless File.exist?(path)
146
+
147
+ content = File.read(path)
148
+ return nil unless content.start_with?("---")
149
+
150
+ parts = content.split("---", 3)
151
+ return nil if parts.length < 3
152
+
153
+ YAML.safe_load(parts[1]) || {}
154
+ rescue
155
+ nil
156
+ end
157
+
158
+ # --- Collect all intent directories (global + project stores) ---
159
+
160
+ def all_intent_dirs
161
+ dirs = []
162
+
163
+ global_store = File.join(PLASTIC_HOME, "store")
164
+ if File.directory?(global_store)
165
+ Dir.children(global_store).each do |entry|
166
+ full = File.join(global_store, entry)
167
+ dirs << { path: full, name: entry, scope: "global" } if File.directory?(full)
168
+ end
169
+ end
170
+
171
+ projects_root = File.join(PLASTIC_HOME, "projects")
172
+ if File.directory?(projects_root)
173
+ Dir.children(projects_root).each do |project|
174
+ project_store = File.join(projects_root, project, "store")
175
+ next unless File.directory?(project_store)
176
+
177
+ Dir.children(project_store).each do |entry|
178
+ full = File.join(project_store, entry)
179
+ dirs << { path: full, name: entry, scope: "project:#{project}" } if File.directory?(full)
180
+ end
181
+ end
182
+ end
183
+
184
+ dirs
185
+ end
186
+
187
+ # --- Check category 1: Global store ---
188
+
189
+ def check_global_store
190
+ checks = []
191
+
192
+ index_path = File.join(PLASTIC_HOME, "INDEX.md")
193
+
194
+ # index_exists
195
+ if File.exist?(index_path)
196
+ checks << check(
197
+ category: "global_store", name: "index_exists", status: "pass",
198
+ message: "INDEX.md exists"
199
+ )
200
+ else
201
+ checks << check(
202
+ category: "global_store", name: "index_exists", status: "fail",
203
+ message: "INDEX.md not found at #{tilde(index_path)}",
204
+ fixable: true, fix_hint: "Run the Plastic installer to bootstrap the store"
205
+ )
206
+ return checks # Can't check sections/references without INDEX.md
207
+ end
208
+
209
+ # index_sections
210
+ content = File.read(index_path)
211
+ missing_sections = REQUIRED_INDEX_SECTIONS.reject { |s| content.include?(s) }
212
+
213
+ if missing_sections.empty?
214
+ checks << check(
215
+ category: "global_store", name: "index_sections", status: "pass",
216
+ message: "INDEX.md has all 5 required sections"
217
+ )
218
+ else
219
+ checks << check(
220
+ category: "global_store", name: "index_sections", status: "fail",
221
+ message: "INDEX.md missing #{missing_sections.size} required section(s)",
222
+ details: missing_sections,
223
+ fixable: true, fix_hint: "Add missing sections to INDEX.md"
224
+ )
225
+ end
226
+
227
+ # orphaned_intents — directories in store/ not referenced in INDEX.md
228
+ store_dir = File.join(PLASTIC_HOME, "store")
229
+ if File.directory?(store_dir)
230
+ intent_dirs = Dir.children(store_dir).select { |e| File.directory?(File.join(store_dir, e)) }
231
+ orphans = intent_dirs.reject { |d| content.include?("store/#{d}") }
232
+
233
+ if orphans.empty?
234
+ checks << check(
235
+ category: "global_store", name: "orphaned_intents", status: "pass",
236
+ message: "No orphaned intent directories"
237
+ )
238
+ else
239
+ checks << check(
240
+ category: "global_store", name: "orphaned_intents", status: "warn",
241
+ message: "#{orphans.size} intent director#{orphans.size == 1 ? "y" : "ies"} not referenced in INDEX.md",
242
+ details: orphans.map { |d| "store/#{d}" },
243
+ fixable: true, fix_hint: "Add missing intents to INDEX.md or remove orphaned directories"
244
+ )
245
+ end
246
+ end
247
+
248
+ # ghost_references — paths in INDEX.md pointing to non-existent directories
249
+ store_refs = content.scan(%r{store/[\w][\w-]*(?:/[\w][\w.-]*)*/?\b}).uniq
250
+ # Normalize: extract just the store/ID--slug portion
251
+ store_paths = content.scan(%r{store/\S+}).map { |ref| ref.gsub(/[)\]>].*/, "").chomp("/") }.uniq
252
+
253
+ ghosts = store_paths.select do |ref|
254
+ full_path = File.join(PLASTIC_HOME, ref)
255
+ !File.exist?(full_path) && !File.directory?(full_path)
256
+ end
257
+
258
+ if ghosts.empty?
259
+ checks << check(
260
+ category: "global_store", name: "ghost_references", status: "pass",
261
+ message: "No ghost references in INDEX.md"
262
+ )
263
+ else
264
+ checks << check(
265
+ category: "global_store", name: "ghost_references", status: "warn",
266
+ message: "#{ghosts.size} path(s) in INDEX.md point to non-existent locations",
267
+ details: ghosts,
268
+ fixable: true, fix_hint: "Remove or fix broken references in INDEX.md"
269
+ )
270
+ end
271
+
272
+ checks
273
+ end
274
+
275
+ # --- Check category 2: Conventions ---
276
+
277
+ def check_conventions
278
+ checks = []
279
+
280
+ intent_dirs = all_intent_dirs
281
+ dirname_pattern = /^\w+--[\w-]+$/
282
+
283
+ # intent_dirname
284
+ bad_dirnames = intent_dirs.reject { |d| d[:name].match?(dirname_pattern) }
285
+
286
+ if bad_dirnames.empty?
287
+ checks << check(
288
+ category: "conventions", name: "intent_dirname", status: "pass",
289
+ message: "All #{intent_dirs.size} intent directories follow {ID}--{slug} format"
290
+ )
291
+ else
292
+ checks << check(
293
+ category: "conventions", name: "intent_dirname", status: "warn",
294
+ message: "#{bad_dirnames.size} intent director#{bad_dirnames.size == 1 ? "y doesn't" : "ies don't"} follow {ID}--{slug} format",
295
+ details: bad_dirnames.map { |d| "#{tilde(d[:path])} (#{d[:scope]})" },
296
+ fixable: true, fix_hint: "Rename directories to {ID}--{slug} format"
297
+ )
298
+ end
299
+
300
+ # intent_filename — primary file inside directory matches {ID}--{slug}.md
301
+ bad_filenames = []
302
+ intent_dirs.each do |d|
303
+ expected_file = "#{d[:name]}.md"
304
+ expected_path = File.join(d[:path], expected_file)
305
+ unless File.exist?(expected_path)
306
+ bad_filenames << { dir: d, expected: expected_file }
307
+ end
308
+ end
309
+
310
+ if bad_filenames.empty?
311
+ checks << check(
312
+ category: "conventions", name: "intent_filename", status: "pass",
313
+ message: "All intent directories have matching {ID}--{slug}.md files"
314
+ )
315
+ else
316
+ checks << check(
317
+ category: "conventions", name: "intent_filename", status: "warn",
318
+ message: "#{bad_filenames.size} intent director#{bad_filenames.size == 1 ? "y" : "ies"} missing primary .md file",
319
+ details: bad_filenames.map { |b| "#{tilde(b[:dir][:path])} — expected #{b[:expected]}" },
320
+ fixable: true, fix_hint: "Create or rename the primary .md file to match the directory name"
321
+ )
322
+ end
323
+
324
+ # frontmatter_fields
325
+ bad_frontmatter = []
326
+ intent_dirs.each do |d|
327
+ md_path = File.join(d[:path], "#{d[:name]}.md")
328
+ next unless File.exist?(md_path)
329
+
330
+ fm = parse_frontmatter(md_path)
331
+ if fm.nil?
332
+ bad_frontmatter << { dir: tilde(d[:path]), missing: ["(no frontmatter found)"] }
333
+ next
334
+ end
335
+
336
+ missing = REQUIRED_FRONTMATTER_FIELDS.reject { |f| fm.key?(f) }
337
+ bad_frontmatter << { dir: tilde(d[:path]), missing: missing } unless missing.empty?
338
+ end
339
+
340
+ if bad_frontmatter.empty?
341
+ checks << check(
342
+ category: "conventions", name: "frontmatter_fields", status: "pass",
343
+ message: "All intent files have required frontmatter fields"
344
+ )
345
+ else
346
+ checks << check(
347
+ category: "conventions", name: "frontmatter_fields", status: "warn",
348
+ message: "#{bad_frontmatter.size} intent file(s) missing required frontmatter fields",
349
+ details: bad_frontmatter.map { |b| "#{b[:dir]}: missing #{b[:missing].join(", ")}" },
350
+ fixable: false
351
+ )
352
+ end
353
+
354
+ checks
355
+ end
356
+
357
+ # --- Check category 3: Agent registration ---
358
+
359
+ def check_agent_registration(agent_key)
360
+ checks = []
361
+ config = AGENTS[agent_key]
362
+ agent_dir = config[:dir]
363
+
364
+ unless File.directory?(agent_dir)
365
+ checks << check(
366
+ category: "agent_registration", name: "agent_dir_exists", status: "fail",
367
+ message: "Agent directory #{tilde(agent_dir)} not found — #{config[:name]} may not be installed",
368
+ fixable: false
369
+ )
370
+ return checks
371
+ end
372
+
373
+ case agent_key
374
+ when "claude"
375
+ checks += check_claude_registration(agent_dir)
376
+ else
377
+ checks += check_generic_agent_registration(agent_key, agent_dir)
378
+ end
379
+
380
+ checks
381
+ end
382
+
383
+ def check_claude_registration(agent_dir)
384
+ checks = []
385
+ hooks_dir = File.join(agent_dir, "hooks")
386
+
387
+ # hooks_exist
388
+ missing_hooks = CLAUDE_HOOK_SCRIPTS.reject { |h| File.exist?(File.join(hooks_dir, h)) }
389
+
390
+ if missing_hooks.empty?
391
+ checks << check(
392
+ category: "agent_registration", name: "hooks_exist", status: "pass",
393
+ message: "All #{CLAUDE_HOOK_SCRIPTS.size} expected hook scripts exist"
394
+ )
395
+ else
396
+ checks << check(
397
+ category: "agent_registration", name: "hooks_exist", status: "fail",
398
+ message: "#{missing_hooks.size} hook script(s) missing",
399
+ details: missing_hooks.map { |h| "#{tilde(hooks_dir)}/#{h}" },
400
+ fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
401
+ )
402
+ end
403
+
404
+ # hooks_executable
405
+ existing_hooks = CLAUDE_HOOK_SCRIPTS
406
+ .map { |h| File.join(hooks_dir, h) }
407
+ .select { |p| File.exist?(p) }
408
+
409
+ non_executable = existing_hooks.reject { |p| File.executable?(p) }
410
+
411
+ if non_executable.empty?
412
+ checks << check(
413
+ category: "agent_registration", name: "hooks_executable", status: "pass",
414
+ message: "All existing hook scripts are executable"
415
+ )
416
+ else
417
+ checks << check(
418
+ category: "agent_registration", name: "hooks_executable", status: "fail",
419
+ message: "#{non_executable.size} hook script(s) not executable",
420
+ details: non_executable.map { |p| tilde(p) },
421
+ fixable: true, fix_hint: "chmod +x on the listed files"
422
+ )
423
+ end
424
+
425
+ # hooks_registered — settings.json has Plastic hooks for required events
426
+ settings_path = File.join(agent_dir, "settings.json")
427
+ settings = read_json_safe(settings_path)
428
+
429
+ if settings.nil?
430
+ checks << check(
431
+ category: "agent_registration", name: "hooks_registered", status: "fail",
432
+ message: "Cannot read #{tilde(settings_path)} — file missing or invalid",
433
+ fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
434
+ )
435
+ else
436
+ hooks = settings["hooks"] || {}
437
+ missing_events = CLAUDE_HOOK_EVENTS.reject do |event|
438
+ groups = hooks[event]
439
+ next false unless groups.is_a?(Array)
440
+
441
+ groups.any? do |group|
442
+ group.is_a?(Hash) && group["hooks"].is_a?(Array) &&
443
+ group["hooks"].any? { |h| h["command"].to_s.include?("plastic-") }
444
+ end
445
+ end
446
+
447
+ if missing_events.empty?
448
+ checks << check(
449
+ category: "agent_registration", name: "hooks_registered", status: "pass",
450
+ message: "All #{CLAUDE_HOOK_EVENTS.size} hook events registered in settings.json"
451
+ )
452
+ else
453
+ checks << check(
454
+ category: "agent_registration", name: "hooks_registered", status: "fail",
455
+ message: "#{missing_events.size} hook event(s) not registered in settings.json",
456
+ details: missing_events,
457
+ fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
458
+ )
459
+ end
460
+ end
461
+
462
+ # skills_exist
463
+ skills_dir = File.join(agent_dir, "skills", "plastic")
464
+ if File.directory?(skills_dir) && !Dir.empty?(skills_dir)
465
+ checks << check(
466
+ category: "agent_registration", name: "skills_exist", status: "pass",
467
+ message: "Skills directory exists at #{tilde(skills_dir)}"
468
+ )
469
+ else
470
+ checks << check(
471
+ category: "agent_registration", name: "skills_exist", status: "fail",
472
+ message: "Skills directory missing or empty at #{tilde(skills_dir)}",
473
+ fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
474
+ )
475
+ end
476
+
477
+ checks
478
+ end
479
+
480
+ def check_generic_agent_registration(agent_key, agent_dir)
481
+ checks = []
482
+ config = AGENTS[agent_key]
483
+
484
+ # For codex/hermes: just check skills exist (no settings.json hooks)
485
+ skills_dir = File.join(agent_dir, "skills", "plastic")
486
+ if File.directory?(skills_dir) && !Dir.empty?(skills_dir)
487
+ checks << check(
488
+ category: "agent_registration", name: "skills_exist", status: "pass",
489
+ message: "Skills directory exists at #{tilde(skills_dir)}"
490
+ )
491
+ else
492
+ checks << check(
493
+ category: "agent_registration", name: "skills_exist", status: "fail",
494
+ message: "Skills directory missing or empty at #{tilde(skills_dir)}",
495
+ fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --#{agent_key}"
496
+ )
497
+ end
498
+
499
+ checks
500
+ end
501
+
502
+ # --- Check category 4: Core files ---
503
+
504
+ def check_core_files(agent_key)
505
+ checks = []
506
+
507
+ # plastic_md
508
+ plastic_md = File.join(PLASTIC_HOME, "PLASTIC.md")
509
+ if File.exist?(plastic_md)
510
+ checks << check(
511
+ category: "core_files", name: "plastic_md", status: "pass",
512
+ message: "PLASTIC.md exists"
513
+ )
514
+ else
515
+ checks << check(
516
+ category: "core_files", name: "plastic_md", status: "fail",
517
+ message: "PLASTIC.md not found at #{tilde(plastic_md)}",
518
+ fixable: true, fix_hint: "Re-run the Plastic installer to restore core files"
519
+ )
520
+ end
521
+
522
+ # version_file
523
+ version_path = File.join(PLASTIC_HOME, "VERSION")
524
+ if File.exist?(version_path)
525
+ checks << check(
526
+ category: "core_files", name: "version_file", status: "pass",
527
+ message: "VERSION file exists"
528
+ )
529
+ else
530
+ checks << check(
531
+ category: "core_files", name: "version_file", status: "fail",
532
+ message: "VERSION file not found at #{tilde(version_path)}",
533
+ fixable: true, fix_hint: "Re-run the Plastic installer to restore core files"
534
+ )
535
+ end
536
+
537
+ # scripts_present
538
+ scripts_dir = File.join(PLASTIC_HOME, "scripts")
539
+ missing_scripts = REQUIRED_SCRIPTS.reject { |s| File.exist?(File.join(scripts_dir, s)) }
540
+
541
+ if missing_scripts.empty?
542
+ checks << check(
543
+ category: "core_files", name: "scripts_present", status: "pass",
544
+ message: "All #{REQUIRED_SCRIPTS.size} required scripts present"
545
+ )
546
+ else
547
+ checks << check(
548
+ category: "core_files", name: "scripts_present", status: "fail",
549
+ message: "#{missing_scripts.size} required script(s) missing from #{tilde(scripts_dir)}",
550
+ details: missing_scripts,
551
+ fixable: true, fix_hint: "Re-run the Plastic installer to restore scripts"
552
+ )
553
+ end
554
+
555
+ # scripts_executable
556
+ if File.directory?(scripts_dir)
557
+ script_files = Dir.children(scripts_dir)
558
+ .map { |f| File.join(scripts_dir, f) }
559
+ .select { |f| File.file?(f) }
560
+
561
+ non_executable = script_files.reject { |f| File.executable?(f) }
562
+
563
+ if non_executable.empty?
564
+ checks << check(
565
+ category: "core_files", name: "scripts_executable", status: "pass",
566
+ message: "All scripts in #{tilde(scripts_dir)} are executable"
567
+ )
568
+ else
569
+ checks << check(
570
+ category: "core_files", name: "scripts_executable", status: "fail",
571
+ message: "#{non_executable.size} script(s) not executable",
572
+ details: non_executable.map { |f| tilde(f) },
573
+ fixable: true, fix_hint: "chmod +x on the listed files"
574
+ )
575
+ end
576
+ end
577
+
578
+ # version_match — compare global VERSION with agent-side VERSION
579
+ global_version = read_version
580
+ agent_config = AGENTS[agent_key]
581
+
582
+ if global_version && agent_config
583
+ agent_version_path = case agent_key
584
+ when "claude" then File.join(agent_config[:dir], "plastic", "VERSION")
585
+ else File.join(agent_config[:dir], "plastic", "VERSION")
586
+ end
587
+
588
+ if File.exist?(agent_version_path)
589
+ agent_version = File.read(agent_version_path).strip
590
+
591
+ if global_version == agent_version
592
+ checks << check(
593
+ category: "core_files", name: "version_match", status: "pass",
594
+ message: "Global VERSION (#{global_version}) matches agent-side VERSION"
595
+ )
596
+ else
597
+ checks << check(
598
+ category: "core_files", name: "version_match", status: "warn",
599
+ message: "Version mismatch: global=#{global_version}, agent=#{agent_version}",
600
+ details: [
601
+ "#{tilde(File.join(PLASTIC_HOME, "VERSION"))}: #{global_version}",
602
+ "#{tilde(agent_version_path)}: #{agent_version}",
603
+ ],
604
+ fixable: false
605
+ )
606
+ end
607
+ else
608
+ checks << check(
609
+ category: "core_files", name: "version_match", status: "warn",
610
+ message: "Agent-side VERSION file not found at #{tilde(agent_version_path)}",
611
+ fixable: false
612
+ )
613
+ end
614
+ end
615
+
616
+ checks
617
+ end
618
+
619
+ # --- Check category 5: Project stores ---
620
+
621
+ def check_project_stores
622
+ checks = []
623
+
624
+ projects_yml_path = File.join(PLASTIC_HOME, "projects.yml")
625
+ projects_data = load_yaml_safe(projects_yml_path)
626
+
627
+ if projects_data.nil?
628
+ checks << check(
629
+ category: "project_stores", name: "projects_yml", status: "warn",
630
+ message: "projects.yml not found or invalid at #{tilde(projects_yml_path)}",
631
+ fixable: true, fix_hint: "Re-run the Plastic installer to restore projects.yml"
632
+ )
633
+ return checks
634
+ end
635
+
636
+ projects = projects_data["projects"]
637
+ unless projects.is_a?(Hash) && !projects.empty?
638
+ # No projects registered — nothing to check
639
+ checks << check(
640
+ category: "project_stores", name: "projects_yml", status: "pass",
641
+ message: "projects.yml is valid (#{projects.is_a?(Hash) ? projects.size : 0} projects registered)"
642
+ )
643
+ return checks
644
+ end
645
+
646
+ # Load INDEX.md content for cross-reference checks
647
+ index_path = File.join(PLASTIC_HOME, "INDEX.md")
648
+ index_content = File.exist?(index_path) ? File.read(index_path) : ""
649
+
650
+ projects.each do |slug, project_info|
651
+ project_dir = File.join(PLASTIC_HOME, "projects", slug)
652
+
653
+ # project_dir_exists
654
+ if File.directory?(project_dir)
655
+ checks << check(
656
+ category: "project_stores", name: "project_dir_exists", status: "pass",
657
+ message: "Project directory exists for '#{slug}'"
658
+ )
659
+ else
660
+ checks << check(
661
+ category: "project_stores", name: "project_dir_exists", status: "warn",
662
+ message: "Project directory missing for '#{slug}'",
663
+ details: [tilde(project_dir)],
664
+ fixable: true, fix_hint: "Create the project store directory: mkdir -p #{tilde(project_dir)}"
665
+ )
666
+ end
667
+
668
+ # project_index
669
+ project_index = File.join(project_dir, "INDEX.md")
670
+ if File.exist?(project_index)
671
+ checks << check(
672
+ category: "project_stores", name: "project_index", status: "pass",
673
+ message: "INDEX.md exists for project '#{slug}'"
674
+ )
675
+ else
676
+ checks << check(
677
+ category: "project_stores", name: "project_index", status: "warn",
678
+ message: "INDEX.md missing for project '#{slug}'",
679
+ details: [tilde(project_index)],
680
+ fixable: true, fix_hint: "Create INDEX.md in the project store directory"
681
+ )
682
+ end
683
+
684
+ # cross_references — if project has `parent` field, check global store intent tags
685
+ parent_id = project_info.is_a?(Hash) ? project_info["parent"] : nil
686
+ next unless parent_id
687
+
688
+ # Find the intent directory for the parent ID
689
+ store_dir = File.join(PLASTIC_HOME, "store")
690
+ parent_dir = nil
691
+ if File.directory?(store_dir)
692
+ parent_dir = Dir.children(store_dir).find { |d| d.start_with?("#{parent_id}--") }
693
+ end
694
+
695
+ if parent_dir.nil?
696
+ checks << check(
697
+ category: "project_stores", name: "cross_references", status: "warn",
698
+ message: "Parent intent '#{parent_id}' for project '#{slug}' not found in global store",
699
+ fixable: false
700
+ )
701
+ next
702
+ end
703
+
704
+ intent_md = File.join(store_dir, parent_dir, "#{parent_dir}.md")
705
+ fm = parse_frontmatter(intent_md)
706
+
707
+ if fm.nil?
708
+ checks << check(
709
+ category: "project_stores", name: "cross_references", status: "warn",
710
+ message: "Cannot read frontmatter of parent intent '#{parent_id}' for project '#{slug}'",
711
+ fixable: false
712
+ )
713
+ next
714
+ end
715
+
716
+ tags = fm["tags"]
717
+ expected_tag = "project-#{slug}"
718
+
719
+ if tags.is_a?(Array) && tags.include?(expected_tag)
720
+ checks << check(
721
+ category: "project_stores", name: "cross_references", status: "pass",
722
+ message: "Parent intent '#{parent_id}' has '#{expected_tag}' tag for project '#{slug}'"
723
+ )
724
+ else
725
+ checks << check(
726
+ category: "project_stores", name: "cross_references", status: "warn",
727
+ message: "Parent intent '#{parent_id}' missing '#{expected_tag}' tag",
728
+ details: ["Intent: store/#{parent_dir}", "Expected tag: #{expected_tag}", "Current tags: #{(tags || []).inspect}"],
729
+ fixable: false
730
+ )
731
+ end
732
+ end
733
+
734
+ checks
735
+ end
736
+
737
+ # --- Check category 6: Deprecations ---
738
+
739
+ def check_deprecations
740
+ checks = []
741
+
742
+ deprecations_path = File.join(PLASTIC_HOME, "deprecations.yml")
743
+ data = load_yaml_safe(deprecations_path)
744
+
745
+ if data.nil?
746
+ checks << check(
747
+ category: "deprecations", name: "deprecations_file", status: "pass",
748
+ message: "No deprecations.yml found (nothing to report)"
749
+ )
750
+ return checks
751
+ end
752
+
753
+ entries = data["deprecations"]
754
+ unless entries.is_a?(Array) && !entries.empty?
755
+ checks << check(
756
+ category: "deprecations", name: "active_deprecations", status: "pass",
757
+ message: "No deprecation entries found"
758
+ )
759
+ return checks
760
+ end
761
+
762
+ current_version = read_version
763
+ unless current_version
764
+ checks << check(
765
+ category: "deprecations", name: "active_deprecations", status: "warn",
766
+ message: "Cannot compare deprecation versions — VERSION file missing",
767
+ fixable: false
768
+ )
769
+ return checks
770
+ end
771
+
772
+ # Find deprecations where removal version is greater than current version
773
+ # Use simple string comparison on semver (works for well-formed versions)
774
+ active = entries.select do |entry|
775
+ removal = entry["removal"].to_s
776
+ next false if removal.empty?
777
+
778
+ compare_versions(current_version, removal) < 0
779
+ end
780
+
781
+ if active.empty?
782
+ checks << check(
783
+ category: "deprecations", name: "active_deprecations", status: "pass",
784
+ message: "No active deprecations for current version (#{current_version})"
785
+ )
786
+ else
787
+ details = active.map do |entry|
788
+ lines = ["[#{entry["severity"]}] #{entry["summary"]} (removal: #{entry["removal"]})"]
789
+ if entry["migration_steps"].is_a?(Array)
790
+ entry["migration_steps"].each { |step| lines << " - #{step}" }
791
+ end
792
+ lines.join("\n")
793
+ end
794
+
795
+ checks << check(
796
+ category: "deprecations", name: "active_deprecations", status: "warn",
797
+ message: "#{active.size} active deprecation(s) for version #{current_version}",
798
+ details: details,
799
+ fixable: false
800
+ )
801
+ end
802
+
803
+ checks
804
+ end
805
+
806
+ # Compare two semver strings. Returns -1, 0, or 1.
807
+ # Handles pre-release tags: 1.0.0-alpha.5 < 1.0.0 < 2.0.0
808
+ def compare_versions(a, b)
809
+ parse = ->(v) {
810
+ base, pre = v.split("-", 2)
811
+ segments = base.split(".").map(&:to_i)
812
+ [segments, pre]
813
+ }
814
+
815
+ a_segments, a_pre = parse.call(a)
816
+ b_segments, b_pre = parse.call(b)
817
+
818
+ # Pad to equal length
819
+ max_len = [a_segments.size, b_segments.size].max
820
+ a_segments += [0] * (max_len - a_segments.size)
821
+ b_segments += [0] * (max_len - b_segments.size)
822
+
823
+ cmp = (a_segments <=> b_segments)
824
+ return cmp unless cmp == 0
825
+
826
+ # Same base version: no pre-release > pre-release (1.0.0 > 1.0.0-alpha)
827
+ return 0 if a_pre.nil? && b_pre.nil?
828
+ return 1 if a_pre.nil? && b_pre
829
+ return -1 if a_pre && b_pre.nil?
830
+
831
+ # Both have pre-release: compare lexically
832
+ a_pre <=> b_pre
833
+ end
834
+
835
+ # --- Run all checks ---
836
+
837
+ def run_checks(agent_key)
838
+ all_checks = []
839
+ all_checks += check_global_store
840
+ all_checks += check_conventions
841
+ all_checks += check_agent_registration(agent_key)
842
+ all_checks += check_core_files(agent_key)
843
+ all_checks += check_project_stores
844
+ all_checks += check_deprecations
845
+
846
+ summary = { pass: 0, warn: 0, fail: 0, total: all_checks.size }
847
+ all_checks.each { |c| summary[c[:status].to_sym] += 1 }
848
+
849
+ overall = if summary[:fail] > 0
850
+ "fail"
851
+ elsif summary[:warn] > 0
852
+ "warn"
853
+ else
854
+ "pass"
855
+ end
856
+
857
+ {
858
+ version: read_version || "unknown",
859
+ timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
860
+ status: overall,
861
+ agent: agent_key,
862
+ checks: all_checks,
863
+ summary: summary,
864
+ }
865
+ end
866
+
867
+ # --- Main ---
868
+
869
+ def main
870
+ flags = parse_args(ARGV)
871
+
872
+ if flags[:help]
873
+ show_help
874
+ exit 0
875
+ end
876
+
877
+ result = run_checks(flags[:agent])
878
+
879
+ puts JSON.pretty_generate(result)
880
+
881
+ case result[:status]
882
+ when "fail" then exit 2
883
+ when "warn" then exit 1
884
+ else exit 0
885
+ end
886
+ end
887
+
888
+ main
@@ -153,6 +153,7 @@ def distribute(mode)
153
153
  "scripts/hook-future-intent-check" => "scripts/hook-future-intent-check",
154
154
  "scripts/hook-gate-check" => "scripts/hook-gate-check",
155
155
  "scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
156
+ "scripts/doctor.rb" => "scripts/doctor.rb",
156
157
  }
157
158
 
158
159
  core_files.each do |src, dest|
@@ -0,0 +1,112 @@
1
+ ---
2
+ name: doctor
3
+ description: Use when diagnosing Plastic installation health, after updates, or when something seems broken. Runs checks and reports findings with fix options.
4
+ ---
5
+
6
+ # Doctor — Plastic Health Check
7
+
8
+ ## When to Use
9
+
10
+ - User invokes `/plastic:doctor`
11
+ - After `plastic:update` completes (automatically)
12
+ - When hooks aren't firing, skills aren't loading, or something seems broken
13
+ - When the user says "check plastic", "diagnose", "what's wrong with plastic"
14
+
15
+ ## Procedure
16
+
17
+ ### Step 1: Run the diagnostic script
18
+
19
+ ```bash
20
+ ruby ~/.plastic/scripts/doctor.rb --agent claude
21
+ ```
22
+
23
+ Replace `claude` with the current agent type if known (`codex`, `hermes`).
24
+
25
+ Parse the JSON output from stdout. The script is read-only and never modifies
26
+ files. Errors go to stderr.
27
+
28
+ Exit codes indicate check results, not script failure:
29
+ - `0` — all checks passed
30
+ - `1` — warnings found
31
+ - `2` — failures found
32
+
33
+ All three exit codes mean the script ran successfully. Do not treat non-zero
34
+ as an error.
35
+
36
+ ### Step 2: Determine overall status
37
+
38
+ Read the `status` field from the JSON root:
39
+
40
+ | Status | Meaning |
41
+ |--------|---------|
42
+ | `pass` | Everything is healthy |
43
+ | `warn` | Warnings found but Plastic works normally |
44
+ | `fail` | Blocking issues that prevent Plastic from operating |
45
+
46
+ ### Step 3: Fill the report template
47
+
48
+ Read `report.md` from the same directory as this SKILL.md
49
+ (`~/.plastic/skills/doctor/report.md` at runtime, or the plugin source
50
+ `skills/doctor/report.md` during development).
51
+
52
+ Group checks by category. For each category, list the checks with their
53
+ status icon and message. If a check has `details`, list them as sub-items.
54
+
55
+ Present the filled template to the user.
56
+
57
+ ### Step 4: Offer fixes (if applicable)
58
+
59
+ If any checks have `fixable: true` AND status is not `pass`:
60
+
61
+ 1. Group fixable items by category
62
+ 2. Show what each fix would do (from `fix_hint`)
63
+ 3. Ask the user: **"Fix all / Select individually / Skip"**
64
+
65
+ If no fixable issues exist, skip this step.
66
+
67
+ ### Step 5: Apply fixes
68
+
69
+ Use the `fix_hint` value to determine the correct action:
70
+
71
+ | Fix hint pattern | Agent action |
72
+ |---|---|
73
+ | "chmod +x on the listed files" | Run `chmod +x` on each file listed in `details` |
74
+ | "Create missing directory" | Run `mkdir -p` on the path |
75
+ | "Create INDEX.md with required sections" | Write INDEX.md with the 5 sections: Active, Future, Clusters, Abandoned, Completed |
76
+ | "Add missing entries to INDEX.md" | Add orphaned intents to the appropriate INDEX.md section |
77
+ | "Remove stale references from INDEX.md" | Edit INDEX.md to remove ghost references |
78
+ | "Re-run installer" | Run `npx @zalom/plastic@latest --agent` |
79
+
80
+ For fixes the agent cannot handle automatically, explain what the user needs
81
+ to do manually.
82
+
83
+ ### Step 6: Verify
84
+
85
+ After applying fixes, re-run the diagnostic script:
86
+
87
+ ```bash
88
+ ruby ~/.plastic/scripts/doctor.rb --agent claude
89
+ ```
90
+
91
+ Show the updated results.
92
+
93
+ - If all checks pass: announce success.
94
+ - If issues remain: explain what is still wrong and what the user can do.
95
+
96
+ ## Post-Update Mode
97
+
98
+ When invoked from `plastic:update` (not directly by the user):
99
+
100
+ 1. Run the diagnostic script as in Step 1.
101
+ 2. If all checks pass: show a single line — **"Health check: all clear."**
102
+ 3. If issues are found: show the full report (Steps 3-6).
103
+
104
+ This keeps the update flow clean when nothing is wrong.
105
+
106
+ ## Important Notes
107
+
108
+ - The script is **read-only**. It inspects but never modifies files.
109
+ All fixes are performed by the agent using standard tools.
110
+ - The script outputs JSON to stdout. Any diagnostic errors go to stderr.
111
+ - Non-zero exit codes mean "issues found", not "script crashed".
112
+ Always parse stdout regardless of exit code.
@@ -0,0 +1,96 @@
1
+ # Plastic Doctor Report
2
+
3
+ <!-- =======================================================================
4
+ AGENT INSTRUCTIONS -- How to fill this template
5
+ =========================================================================
6
+ 1. Run the doctor script. It outputs JSON with check results.
7
+ 2. Replace every {{placeholder}} below with the corresponding JSON value.
8
+ 3. For the category sections: the template shows ONE example section.
9
+ Repeat that pattern for each unique category in the checks array.
10
+ The six known categories and their display names are:
11
+ global_store -> "Global Store"
12
+ conventions -> "Conventions"
13
+ agent_registration -> "Agent Registration"
14
+ core_files -> "Core Files"
15
+ project_stores -> "Project Stores"
16
+ deprecations -> "Deprecations"
17
+ 4. For each check within a category, emit one line with the status icon
18
+ and the check message. If the check has non-empty details, list them
19
+ as indented sub-items.
20
+ 5. The "Fixable Issues" section should ONLY appear if at least one check
21
+ has fixable=true AND status is not "pass". Omit the entire section
22
+ otherwise.
23
+ 6. Status icons (plain text, no emoji):
24
+ pass -> [PASS]
25
+ warn -> [WARN]
26
+ fail -> [FAIL]
27
+ 7. The overall status icon in the header uses the same mapping.
28
+ 8. After filling, remove all HTML comments -- they are instructions only.
29
+ ======================================================================= -->
30
+
31
+ ## {{overall_status_icon}} Overall: {{status}} -- Plastic v{{version}}
32
+
33
+ Checked at: {{timestamp}}
34
+
35
+ ### Summary
36
+
37
+ {{pass}} passed, {{warn}} warnings, {{fail}} failed -- {{total}} checks total
38
+
39
+ ---
40
+
41
+ <!-- =====================================================================
42
+ CATEGORY SECTIONS
43
+ ======================================================================
44
+ Repeat the block below ONCE PER CATEGORY present in the checks array.
45
+ Group checks by their "category" field. Use the display name mapping
46
+ above for the heading. Within each category, list every check as a
47
+ single line: status icon + message. If a check has non-empty "details",
48
+ list each detail as an indented bullet beneath.
49
+
50
+ Example category section (for agent_registration with two checks):
51
+ ===================================================================== -->
52
+
53
+ ### Agent Registration
54
+
55
+ - [PASS] Claude Code adapter registered
56
+ - [FAIL] 2 hook scripts not executable
57
+ - ~/.claude/hooks/plastic-session-start
58
+ - ~/.claude/hooks/plastic-gate-check
59
+
60
+ <!-- =====================================================================
61
+ Repeat the above pattern for each category found in the checks array.
62
+ Only include categories that have at least one check.
63
+ Order categories as they appear in the checks array.
64
+ ===================================================================== -->
65
+
66
+ ---
67
+
68
+ <!-- =====================================================================
69
+ FIXABLE ISSUES SECTION
70
+ ======================================================================
71
+ Include this section ONLY if one or more checks have fixable=true AND
72
+ status is "warn" or "fail". If no fixable issues exist, omit everything
73
+ from the "Fixable Issues" heading through the end of the horizontal
74
+ rule that follows the table.
75
+
76
+ For each fixable check that is not "pass", emit one table row:
77
+ | status_icon | check_message | fix_hint |
78
+ ===================================================================== -->
79
+
80
+ ### Fixable Issues
81
+
82
+ | Status | Issue | Fix |
83
+ |--------|-------|-----|
84
+ | [FAIL] | 2 hook scripts not executable | chmod +x on the listed files |
85
+
86
+ <!-- =====================================================================
87
+ Repeat one row per fixable non-pass check.
88
+ ===================================================================== -->
89
+
90
+ ---
91
+
92
+ <!-- =====================================================================
93
+ FOOTER -- always include this line exactly as written.
94
+ ===================================================================== -->
95
+
96
+ Run `plastic doctor --fix` to auto-fix all fixable issues, or ask me to fix them now.
@@ -54,13 +54,21 @@ Key changes in this version:
54
54
  Recommendation: run /clear for a clean session with all new conventions loaded.
55
55
  ```
56
56
 
57
- ### Step 4: Commit
57
+ ### Step 4: Run health check
58
+
59
+ Invoke `plastic:doctor` to verify the installation is healthy after the update.
60
+
61
+ If all checks pass, show: **"Health check: all clear."**
62
+
63
+ If issues are found, show the full doctor report and offer to fix any fixable items.
64
+
65
+ ### Step 5: Commit
58
66
 
59
67
  ```bash
60
68
  cd ~/.plastic && git add PLASTIC.md scripts/ AGENTS.md VERSION 2>/dev/null && git commit -m "chore: update Plastic core files" --allow-empty
61
69
  ```
62
70
 
63
- ### Step 5: Clear update cache
71
+ ### Step 6: Clear update cache
64
72
 
65
73
  ```bash
66
74
  rm -f ~/.plastic/.cache/update-check.json