@zalom/plastic 1.0.0-alpha.1 → 1.0.0-alpha.11

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.
Files changed (41) hide show
  1. package/PLASTIC.md +106 -475
  2. package/hooks/hooks.json +0 -11
  3. package/hooks/statusline +50 -10
  4. package/package.json +1 -1
  5. package/scripts/doctor.rb +931 -0
  6. package/scripts/hook-session-start +47 -10
  7. package/scripts/install.rb +218 -30
  8. package/scripts/lib/bridge.rb +34 -0
  9. package/skills/_active-intent-gate.md +26 -0
  10. package/skills/auto/SKILL.md +22 -4
  11. package/skills/auto/references/agent-architecture.md +60 -0
  12. package/skills/brainstorming/SKILL.md +143 -0
  13. package/skills/continuing/SKILL.md +4 -0
  14. package/skills/continuing/references/context-management.md +32 -0
  15. package/skills/creating-intent/SKILL.md +5 -0
  16. package/skills/creating-intent/references/lifecycle.md +74 -0
  17. package/skills/creating-intent/references/wikilinks.md +8 -0
  18. package/skills/creating-project/SKILL.md +4 -0
  19. package/skills/creating-project/references/hubs-projects.md +55 -0
  20. package/skills/doctor/SKILL.md +116 -0
  21. package/skills/doctor/references/gates-stuck-detection.md +38 -0
  22. package/skills/doctor/report.md +96 -0
  23. package/skills/evaluating-skills/SKILL.md +140 -0
  24. package/skills/evaluating-skills/assets/eval-template.json +12 -0
  25. package/skills/evaluating-skills/evals/evals.json +75 -0
  26. package/skills/evaluating-skills/references/convention-checks.md +76 -0
  27. package/skills/evaluating-skills/references/eval-methodology.md +154 -0
  28. package/skills/linking-intents/SKILL.md +4 -0
  29. package/skills/linking-intents/references/zettelkasten.md +33 -0
  30. package/skills/managing-index/SKILL.md +4 -0
  31. package/skills/releasing/SKILL.md +92 -17
  32. package/skills/releasing/references/deprecations.md +44 -0
  33. package/skills/research/SKILL.md +114 -0
  34. package/skills/savepoint/SKILL.md +4 -0
  35. package/skills/savepoint/references/context-management.md +32 -0
  36. package/skills/update/SKILL.md +10 -2
  37. package/skills/writing-instructions/SKILL.md +159 -0
  38. package/skills/writing-instructions/references/agentskills-spec.md +135 -0
  39. package/skills/writing-plans/SKILL.md +183 -0
  40. package/templates/agents.md +16 -0
  41. package/templates/project.yml +5 -0
@@ -0,0 +1,931 @@
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
+ # project_yml_exists
685
+ project_yml_path = File.join(PLASTIC_HOME, "projects", slug, "project.yml")
686
+ project_yml_data = nil
687
+
688
+ if File.exist?(project_yml_path)
689
+ checks << check(
690
+ category: "project_stores", name: "project_yml_exists", status: "pass",
691
+ message: "project.yml exists for project '#{slug}'"
692
+ )
693
+ project_yml_data = load_yaml_safe(project_yml_path)
694
+ else
695
+ checks << check(
696
+ category: "project_stores", name: "project_yml_exists", status: "warn",
697
+ message: "project.yml missing for project '#{slug}'",
698
+ fixable: true, fix_hint: "Create project.yml from template — see plastic:creating-project"
699
+ )
700
+ end
701
+
702
+ # governing_docs_exist
703
+ if project_yml_data.is_a?(Hash) && project_yml_data["governing_docs"].is_a?(Array) && !project_yml_data["governing_docs"].empty?
704
+ project_path = project_info.is_a?(Hash) ? project_info["path"] : nil
705
+
706
+ if project_path
707
+ missing_docs = project_yml_data["governing_docs"].reject do |doc_path|
708
+ File.exist?(File.join(project_path, doc_path))
709
+ end
710
+
711
+ if missing_docs.empty?
712
+ checks << check(
713
+ category: "project_stores", name: "governing_docs_exist", status: "pass",
714
+ message: "All governing docs exist for project '#{slug}'"
715
+ )
716
+ else
717
+ checks << check(
718
+ category: "project_stores", name: "governing_docs_exist", status: "warn",
719
+ message: "#{missing_docs.size} governing doc(s) missing for project '#{slug}'",
720
+ details: missing_docs,
721
+ fixable: false
722
+ )
723
+ end
724
+ end
725
+ end
726
+
727
+ # cross_references — if project has `parent` field, check global store intent tags
728
+ parent_id = project_info.is_a?(Hash) ? project_info["parent"] : nil
729
+ next unless parent_id
730
+
731
+ # Find the intent directory for the parent ID
732
+ store_dir = File.join(PLASTIC_HOME, "store")
733
+ parent_dir = nil
734
+ if File.directory?(store_dir)
735
+ parent_dir = Dir.children(store_dir).find { |d| d.start_with?("#{parent_id}--") }
736
+ end
737
+
738
+ if parent_dir.nil?
739
+ checks << check(
740
+ category: "project_stores", name: "cross_references", status: "warn",
741
+ message: "Parent intent '#{parent_id}' for project '#{slug}' not found in global store",
742
+ fixable: false
743
+ )
744
+ next
745
+ end
746
+
747
+ intent_md = File.join(store_dir, parent_dir, "#{parent_dir}.md")
748
+ fm = parse_frontmatter(intent_md)
749
+
750
+ if fm.nil?
751
+ checks << check(
752
+ category: "project_stores", name: "cross_references", status: "warn",
753
+ message: "Cannot read frontmatter of parent intent '#{parent_id}' for project '#{slug}'",
754
+ fixable: false
755
+ )
756
+ next
757
+ end
758
+
759
+ tags = fm["tags"]
760
+ expected_tag = "project-#{slug}"
761
+
762
+ if tags.is_a?(Array) && tags.include?(expected_tag)
763
+ checks << check(
764
+ category: "project_stores", name: "cross_references", status: "pass",
765
+ message: "Parent intent '#{parent_id}' has '#{expected_tag}' tag for project '#{slug}'"
766
+ )
767
+ else
768
+ checks << check(
769
+ category: "project_stores", name: "cross_references", status: "warn",
770
+ message: "Parent intent '#{parent_id}' missing '#{expected_tag}' tag",
771
+ details: ["Intent: store/#{parent_dir}", "Expected tag: #{expected_tag}", "Current tags: #{(tags || []).inspect}"],
772
+ fixable: false
773
+ )
774
+ end
775
+ end
776
+
777
+ checks
778
+ end
779
+
780
+ # --- Check category 6: Deprecations ---
781
+
782
+ def check_deprecations
783
+ checks = []
784
+
785
+ deprecations_path = File.join(PLASTIC_HOME, "deprecations.yml")
786
+ data = load_yaml_safe(deprecations_path)
787
+
788
+ if data.nil?
789
+ checks << check(
790
+ category: "deprecations", name: "deprecations_file", status: "pass",
791
+ message: "No deprecations.yml found (nothing to report)"
792
+ )
793
+ return checks
794
+ end
795
+
796
+ entries = data["deprecations"]
797
+ unless entries.is_a?(Array) && !entries.empty?
798
+ checks << check(
799
+ category: "deprecations", name: "active_deprecations", status: "pass",
800
+ message: "No deprecation entries found"
801
+ )
802
+ return checks
803
+ end
804
+
805
+ current_version = read_version
806
+ unless current_version
807
+ checks << check(
808
+ category: "deprecations", name: "active_deprecations", status: "warn",
809
+ message: "Cannot compare deprecation versions — VERSION file missing",
810
+ fixable: false
811
+ )
812
+ return checks
813
+ end
814
+
815
+ # Find deprecations where removal version is greater than current version
816
+ # Use simple string comparison on semver (works for well-formed versions)
817
+ active = entries.select do |entry|
818
+ removal = entry["removal"].to_s
819
+ next false if removal.empty?
820
+
821
+ compare_versions(current_version, removal) < 0
822
+ end
823
+
824
+ if active.empty?
825
+ checks << check(
826
+ category: "deprecations", name: "active_deprecations", status: "pass",
827
+ message: "No active deprecations for current version (#{current_version})"
828
+ )
829
+ else
830
+ details = active.map do |entry|
831
+ lines = ["[#{entry["severity"]}] #{entry["summary"]} (removal: #{entry["removal"]})"]
832
+ if entry["migration_steps"].is_a?(Array)
833
+ entry["migration_steps"].each { |step| lines << " - #{step}" }
834
+ end
835
+ lines.join("\n")
836
+ end
837
+
838
+ checks << check(
839
+ category: "deprecations", name: "active_deprecations", status: "warn",
840
+ message: "#{active.size} active deprecation(s) for version #{current_version}",
841
+ details: details,
842
+ fixable: false
843
+ )
844
+ end
845
+
846
+ checks
847
+ end
848
+
849
+ # Compare two semver strings. Returns -1, 0, or 1.
850
+ # Handles pre-release tags: 1.0.0-alpha.5 < 1.0.0 < 2.0.0
851
+ def compare_versions(a, b)
852
+ parse = ->(v) {
853
+ base, pre = v.split("-", 2)
854
+ segments = base.split(".").map(&:to_i)
855
+ [segments, pre]
856
+ }
857
+
858
+ a_segments, a_pre = parse.call(a)
859
+ b_segments, b_pre = parse.call(b)
860
+
861
+ # Pad to equal length
862
+ max_len = [a_segments.size, b_segments.size].max
863
+ a_segments += [0] * (max_len - a_segments.size)
864
+ b_segments += [0] * (max_len - b_segments.size)
865
+
866
+ cmp = (a_segments <=> b_segments)
867
+ return cmp unless cmp == 0
868
+
869
+ # Same base version: no pre-release > pre-release (1.0.0 > 1.0.0-alpha)
870
+ return 0 if a_pre.nil? && b_pre.nil?
871
+ return 1 if a_pre.nil? && b_pre
872
+ return -1 if a_pre && b_pre.nil?
873
+
874
+ # Both have pre-release: compare lexically
875
+ a_pre <=> b_pre
876
+ end
877
+
878
+ # --- Run all checks ---
879
+
880
+ def run_checks(agent_key)
881
+ all_checks = []
882
+ all_checks += check_global_store
883
+ all_checks += check_conventions
884
+ all_checks += check_agent_registration(agent_key)
885
+ all_checks += check_core_files(agent_key)
886
+ all_checks += check_project_stores
887
+ all_checks += check_deprecations
888
+
889
+ summary = { pass: 0, warn: 0, fail: 0, total: all_checks.size }
890
+ all_checks.each { |c| summary[c[:status].to_sym] += 1 }
891
+
892
+ overall = if summary[:fail] > 0
893
+ "fail"
894
+ elsif summary[:warn] > 0
895
+ "warn"
896
+ else
897
+ "pass"
898
+ end
899
+
900
+ {
901
+ version: read_version || "unknown",
902
+ timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
903
+ status: overall,
904
+ agent: agent_key,
905
+ checks: all_checks,
906
+ summary: summary,
907
+ }
908
+ end
909
+
910
+ # --- Main ---
911
+
912
+ def main
913
+ flags = parse_args(ARGV)
914
+
915
+ if flags[:help]
916
+ show_help
917
+ exit 0
918
+ end
919
+
920
+ result = run_checks(flags[:agent])
921
+
922
+ puts JSON.pretty_generate(result)
923
+
924
+ case result[:status]
925
+ when "fail" then exit 2
926
+ when "warn" then exit 1
927
+ else exit 0
928
+ end
929
+ end
930
+
931
+ main