@zalom/plastic 1.0.0-alpha.2 → 1.0.0-alpha.20

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 (75) hide show
  1. package/PLASTIC.md +128 -473
  2. package/README.md +90 -58
  3. package/agents/future-intent-researcher.md +1 -1
  4. package/agents/intent-curator.md +1 -1
  5. package/bin/plastic.js +57 -0
  6. package/bin/test +28 -0
  7. package/deprecations.yml +7 -6
  8. package/hooks/auto-arm +5 -0
  9. package/hooks/bash-gate +3 -0
  10. package/hooks/check-update +12 -8
  11. package/hooks/code-gate +10 -0
  12. package/hooks/hooks.json +25 -4
  13. package/hooks/statusline +50 -10
  14. package/package.json +2 -2
  15. package/scripts/dashboard.rb +480 -0
  16. package/scripts/doctor.rb +950 -0
  17. package/scripts/hook-auto-arm +52 -0
  18. package/scripts/hook-bash-gate +53 -0
  19. package/scripts/hook-code-gate +39 -0
  20. package/scripts/hook-continue +15 -114
  21. package/scripts/hook-gate-check +19 -4
  22. package/scripts/hook-session-start +76 -31
  23. package/scripts/install.rb +91 -480
  24. package/scripts/lib/bridge.rb +255 -0
  25. package/scripts/lib/installer_core.rb +760 -0
  26. package/scripts/migrate-to-global +1 -1
  27. package/scripts/select-update-target +93 -0
  28. package/scripts/uninstall.rb +53 -0
  29. package/scripts/update.rb +142 -0
  30. package/scripts/versions.rb +141 -0
  31. package/skills/_active-intent-gate.md +26 -0
  32. package/skills/auto/SKILL.md +62 -9
  33. package/skills/auto/evals/evals.json +92 -0
  34. package/skills/auto/references/agent-architecture.md +60 -0
  35. package/skills/brainstorming/SKILL.md +143 -0
  36. package/skills/brainstorming-grill-me/SKILL.md +5 -5
  37. package/skills/continuing/SKILL.md +20 -4
  38. package/skills/continuing/references/context-management.md +32 -0
  39. package/skills/creating-intent/SKILL.md +16 -1
  40. package/skills/creating-intent/references/lifecycle.md +74 -0
  41. package/skills/creating-intent/references/wikilinks.md +8 -0
  42. package/skills/creating-project/SKILL.md +8 -4
  43. package/skills/creating-project/references/hubs-projects.md +55 -0
  44. package/skills/dashboard/SKILL.md +92 -0
  45. package/skills/doctor/SKILL.md +116 -0
  46. package/skills/doctor/references/gates-stuck-detection.md +38 -0
  47. package/skills/doctor/report.md +96 -0
  48. package/skills/evaluating-skills/SKILL.md +140 -0
  49. package/skills/evaluating-skills/assets/eval-template.json +12 -0
  50. package/skills/evaluating-skills/evals/evals.json +75 -0
  51. package/skills/evaluating-skills/references/convention-checks.md +76 -0
  52. package/skills/evaluating-skills/references/eval-methodology.md +154 -0
  53. package/skills/executing-plan/SKILL.md +3 -3
  54. package/skills/install/SKILL.md +56 -8
  55. package/skills/intent-curator/SKILL.md +3 -3
  56. package/skills/linking-intents/SKILL.md +5 -1
  57. package/skills/linking-intents/references/zettelkasten.md +33 -0
  58. package/skills/managing-index/SKILL.md +5 -1
  59. package/skills/releasing/SKILL.md +119 -18
  60. package/skills/releasing/references/deprecations.md +44 -0
  61. package/skills/research/SKILL.md +114 -0
  62. package/skills/savepoint/SKILL.md +46 -37
  63. package/skills/savepoint/references/context-management.md +32 -0
  64. package/skills/uninstall/SKILL.md +39 -28
  65. package/skills/update/SKILL.md +41 -36
  66. package/skills/versions/SKILL.md +65 -0
  67. package/skills/writing-instructions/SKILL.md +159 -0
  68. package/skills/writing-instructions/references/agentskills-spec.md +135 -0
  69. package/skills/writing-plans/SKILL.md +183 -0
  70. package/templates/agents.md +16 -0
  71. package/templates/outcome.md +13 -0
  72. package/templates/project.yml +5 -0
  73. package/templates/savepoint.md +14 -13
  74. package/templates/spec.md +25 -0
  75. package/bin/install.js +0 -29
@@ -0,0 +1,950 @@
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
+ require "date"
16
+
17
+ # Diagnostic engine, instantiable with an injected store/agent map so tests can
18
+ # run it hermetically (no eval, no global-constant rewriting).
19
+ class Doctor
20
+ DEFAULT_PLASTIC_HOME = File.join(Dir.home, ".plastic")
21
+
22
+ DEFAULT_AGENTS = {
23
+ "claude" => { name: "Claude Code", dir: File.join(Dir.home, ".claude") },
24
+ "codex" => { name: "Codex CLI", dir: File.join(Dir.home, ".agents") },
25
+ "hermes" => { name: "Hermes", dir: File.join(Dir.home, ".hermes") },
26
+ }.freeze
27
+
28
+ REQUIRED_INDEX_SECTIONS = ["## Active", "## Future", "## Clusters", "## Abandoned", "## Completed"].freeze
29
+
30
+ REQUIRED_FRONTMATTER_FIELDS = %w[id intent sources chain created author tags].freeze
31
+
32
+ CLAUDE_HOOK_SCRIPTS = %w[
33
+ plastic-session-start
34
+ plastic-check-update
35
+ plastic-savepoint
36
+ plastic-gate-check
37
+ plastic-continue
38
+ plastic-future-intent-check
39
+ ].freeze
40
+
41
+ CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit].freeze
42
+
43
+ REQUIRED_SCRIPTS = %w[
44
+ folgezettel-id
45
+ read-config
46
+ hook-session-start
47
+ hook-continue
48
+ hook-future-intent-check
49
+ hook-gate-check
50
+ doctor.rb
51
+ ].freeze
52
+
53
+ attr_reader :plastic_home, :agents
54
+
55
+ def initialize(plastic_home: DEFAULT_PLASTIC_HOME, agents: DEFAULT_AGENTS)
56
+ @plastic_home = plastic_home
57
+ @agents = agents
58
+ end
59
+
60
+ # --- Flag parsing ---
61
+
62
+ def parse_args(argv)
63
+ agent = "claude"
64
+ help = false
65
+
66
+ i = 0
67
+ while i < argv.length
68
+ case argv[i]
69
+ when "--agent"
70
+ if argv[i + 1] && agents.key?(argv[i + 1])
71
+ agent = argv[i + 1]
72
+ i += 2
73
+ else
74
+ $stderr.puts "Error: --agent requires one of: #{agents.keys.join(", ")}"
75
+ exit 2
76
+ end
77
+ when "--help", "-h"
78
+ help = true
79
+ i += 1
80
+ else
81
+ i += 1
82
+ end
83
+ end
84
+
85
+ { agent: agent, help: help }
86
+ end
87
+
88
+ def show_help
89
+ $stderr.puts <<~HELP
90
+
91
+ plastic doctor — diagnose Plastic installation health
92
+
93
+ Usage:
94
+ ruby ~/.plastic/scripts/doctor.rb [options]
95
+
96
+ Options:
97
+ --agent NAME Agent to check: claude (default), codex, hermes
98
+ -h, --help Show this help
99
+
100
+ Output:
101
+ JSON to stdout with check results.
102
+ Exit 0 = all pass, 1 = warnings only, 2 = failures present.
103
+
104
+ HELP
105
+ end
106
+
107
+ # --- Utility helpers ---
108
+
109
+ def read_version
110
+ version_path = File.join(plastic_home, "VERSION")
111
+ return nil unless File.exist?(version_path)
112
+
113
+ File.read(version_path).strip
114
+ end
115
+
116
+ def read_json_safe(path)
117
+ return nil unless File.exist?(path)
118
+
119
+ JSON.parse(File.read(path))
120
+ rescue JSON::ParserError
121
+ content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
122
+ JSON.parse(content)
123
+ rescue
124
+ nil
125
+ end
126
+
127
+ def load_yaml_safe(path)
128
+ return nil unless File.exist?(path)
129
+
130
+ YAML.safe_load(File.read(path)) || {}
131
+ rescue => e
132
+ $stderr.puts "Warning: failed to parse #{path}: #{e.message}"
133
+ nil
134
+ end
135
+
136
+ def tilde(path)
137
+ path.sub(Dir.home, "~")
138
+ end
139
+
140
+ def check(category:, name:, status:, message:, details: [], fixable: false, fix_hint: nil)
141
+ result = {
142
+ category: category,
143
+ name: name,
144
+ status: status,
145
+ message: message,
146
+ details: details,
147
+ fixable: fixable,
148
+ }
149
+ result[:fix_hint] = fix_hint if fix_hint
150
+ result
151
+ end
152
+
153
+ # --- Parse frontmatter from an intent markdown file ---
154
+
155
+ def parse_frontmatter(path)
156
+ return nil unless File.exist?(path)
157
+
158
+ content = File.read(path)
159
+ return nil unless content.start_with?("---")
160
+
161
+ parts = content.split("---", 3)
162
+ return nil if parts.length < 3
163
+
164
+ # created: dates parse as Date objects, which safe_load rejects by default —
165
+ # permit Date/Time so valid frontmatter isn't misreported as missing.
166
+ YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
167
+ rescue
168
+ nil
169
+ end
170
+
171
+ # --- Collect all intent directories (global + project stores) ---
172
+
173
+ # Child directories of a store, excluding dotfiles/dot-directories
174
+ # (e.g. .obsidian, .git) which are tooling artifacts, not intents.
175
+ def store_intent_dirs(store)
176
+ Dir.children(store).reject { |e| e.start_with?(".") }.select do |e|
177
+ File.directory?(File.join(store, e))
178
+ end
179
+ end
180
+
181
+ def all_intent_dirs
182
+ dirs = []
183
+
184
+ global_store = File.join(plastic_home, "store")
185
+ if File.directory?(global_store)
186
+ store_intent_dirs(global_store).each do |entry|
187
+ full = File.join(global_store, entry)
188
+ dirs << { path: full, name: entry, scope: "global" }
189
+ end
190
+ end
191
+
192
+ projects_root = File.join(plastic_home, "projects")
193
+ if File.directory?(projects_root)
194
+ Dir.children(projects_root).each do |project|
195
+ project_store = File.join(projects_root, project, "store")
196
+ next unless File.directory?(project_store)
197
+
198
+ store_intent_dirs(project_store).each do |entry|
199
+ full = File.join(project_store, entry)
200
+ dirs << { path: full, name: entry, scope: "project:#{project}" }
201
+ end
202
+ end
203
+ end
204
+
205
+ dirs
206
+ end
207
+
208
+ # --- Check category 1: Global store ---
209
+
210
+ def check_global_store
211
+ checks = []
212
+
213
+ index_path = File.join(plastic_home, "INDEX.md")
214
+
215
+ # index_exists
216
+ if File.exist?(index_path)
217
+ checks << check(
218
+ category: "global_store", name: "index_exists", status: "pass",
219
+ message: "INDEX.md exists"
220
+ )
221
+ else
222
+ checks << check(
223
+ category: "global_store", name: "index_exists", status: "fail",
224
+ message: "INDEX.md not found at #{tilde(index_path)}",
225
+ fixable: true, fix_hint: "Run the Plastic installer to bootstrap the store"
226
+ )
227
+ return checks # Can't check sections/references without INDEX.md
228
+ end
229
+
230
+ # index_sections
231
+ content = File.read(index_path)
232
+ missing_sections = REQUIRED_INDEX_SECTIONS.reject { |s| content.include?(s) }
233
+
234
+ if missing_sections.empty?
235
+ checks << check(
236
+ category: "global_store", name: "index_sections", status: "pass",
237
+ message: "INDEX.md has all 5 required sections"
238
+ )
239
+ else
240
+ checks << check(
241
+ category: "global_store", name: "index_sections", status: "fail",
242
+ message: "INDEX.md missing #{missing_sections.size} required section(s)",
243
+ details: missing_sections,
244
+ fixable: true, fix_hint: "Add missing sections to INDEX.md"
245
+ )
246
+ end
247
+
248
+ # orphaned_intents — directories in store/ not referenced in INDEX.md
249
+ store_dir = File.join(plastic_home, "store")
250
+ if File.directory?(store_dir)
251
+ intent_dirs = store_intent_dirs(store_dir)
252
+ orphans = intent_dirs.reject { |d| content.include?("store/#{d}") }
253
+
254
+ if orphans.empty?
255
+ checks << check(
256
+ category: "global_store", name: "orphaned_intents", status: "pass",
257
+ message: "No orphaned intent directories"
258
+ )
259
+ else
260
+ checks << check(
261
+ category: "global_store", name: "orphaned_intents", status: "warn",
262
+ message: "#{orphans.size} intent director#{orphans.size == 1 ? "y" : "ies"} not referenced in INDEX.md",
263
+ details: orphans.map { |d| "store/#{d}" },
264
+ fixable: true, fix_hint: "Add missing intents to INDEX.md or remove orphaned directories"
265
+ )
266
+ end
267
+ end
268
+
269
+ # ghost_references — paths in INDEX.md pointing to non-existent directories
270
+ store_refs = content.scan(%r{store/[\w][\w-]*(?:/[\w][\w.-]*)*/?\b}).uniq
271
+ # Normalize: extract just the store/ID--slug portion
272
+ store_paths = content.scan(%r{store/\S+}).map { |ref| ref.gsub(/[)\]>].*/, "").chomp("/") }.uniq
273
+
274
+ ghosts = store_paths.select do |ref|
275
+ full_path = File.join(plastic_home, ref)
276
+ !File.exist?(full_path) && !File.directory?(full_path)
277
+ end
278
+
279
+ if ghosts.empty?
280
+ checks << check(
281
+ category: "global_store", name: "ghost_references", status: "pass",
282
+ message: "No ghost references in INDEX.md"
283
+ )
284
+ else
285
+ checks << check(
286
+ category: "global_store", name: "ghost_references", status: "warn",
287
+ message: "#{ghosts.size} path(s) in INDEX.md point to non-existent locations",
288
+ details: ghosts,
289
+ fixable: true, fix_hint: "Remove or fix broken references in INDEX.md"
290
+ )
291
+ end
292
+
293
+ checks
294
+ end
295
+
296
+ # --- Check category 2: Conventions ---
297
+
298
+ def check_conventions
299
+ checks = []
300
+
301
+ intent_dirs = all_intent_dirs
302
+ dirname_pattern = /^\w+--[\w-]+$/
303
+
304
+ # intent_dirname
305
+ bad_dirnames = intent_dirs.reject { |d| d[:name].match?(dirname_pattern) }
306
+
307
+ if bad_dirnames.empty?
308
+ checks << check(
309
+ category: "conventions", name: "intent_dirname", status: "pass",
310
+ message: "All #{intent_dirs.size} intent directories follow {ID}--{slug} format"
311
+ )
312
+ else
313
+ checks << check(
314
+ category: "conventions", name: "intent_dirname", status: "warn",
315
+ message: "#{bad_dirnames.size} intent director#{bad_dirnames.size == 1 ? "y doesn't" : "ies don't"} follow {ID}--{slug} format",
316
+ details: bad_dirnames.map { |d| "#{tilde(d[:path])} (#{d[:scope]})" },
317
+ fixable: true, fix_hint: "Rename directories to {ID}--{slug} format"
318
+ )
319
+ end
320
+
321
+ # intent_filename — primary file inside directory matches {ID}--{slug}.md
322
+ bad_filenames = []
323
+ intent_dirs.each do |d|
324
+ expected_file = "#{d[:name]}.md"
325
+ expected_path = File.join(d[:path], expected_file)
326
+ unless File.exist?(expected_path)
327
+ bad_filenames << { dir: d, expected: expected_file }
328
+ end
329
+ end
330
+
331
+ if bad_filenames.empty?
332
+ checks << check(
333
+ category: "conventions", name: "intent_filename", status: "pass",
334
+ message: "All intent directories have matching {ID}--{slug}.md files"
335
+ )
336
+ else
337
+ checks << check(
338
+ category: "conventions", name: "intent_filename", status: "warn",
339
+ message: "#{bad_filenames.size} intent director#{bad_filenames.size == 1 ? "y" : "ies"} missing primary .md file",
340
+ details: bad_filenames.map { |b| "#{tilde(b[:dir][:path])} — expected #{b[:expected]}" },
341
+ fixable: true, fix_hint: "Create or rename the primary .md file to match the directory name"
342
+ )
343
+ end
344
+
345
+ # frontmatter_fields
346
+ bad_frontmatter = []
347
+ intent_dirs.each do |d|
348
+ md_path = File.join(d[:path], "#{d[:name]}.md")
349
+ next unless File.exist?(md_path)
350
+
351
+ fm = parse_frontmatter(md_path)
352
+ if fm.nil?
353
+ bad_frontmatter << { dir: tilde(d[:path]), missing: ["(no frontmatter found)"] }
354
+ next
355
+ end
356
+
357
+ missing = REQUIRED_FRONTMATTER_FIELDS.reject { |f| fm.key?(f) }
358
+ bad_frontmatter << { dir: tilde(d[:path]), missing: missing } unless missing.empty?
359
+ end
360
+
361
+ if bad_frontmatter.empty?
362
+ checks << check(
363
+ category: "conventions", name: "frontmatter_fields", status: "pass",
364
+ message: "All intent files have required frontmatter fields"
365
+ )
366
+ else
367
+ checks << check(
368
+ category: "conventions", name: "frontmatter_fields", status: "warn",
369
+ message: "#{bad_frontmatter.size} intent file(s) missing required frontmatter fields",
370
+ details: bad_frontmatter.map { |b| "#{b[:dir]}: missing #{b[:missing].join(", ")}" },
371
+ fixable: false
372
+ )
373
+ end
374
+
375
+ checks
376
+ end
377
+
378
+ # --- Check category 3: Agent registration ---
379
+
380
+ def check_agent_registration(agent_key)
381
+ checks = []
382
+ config = agents[agent_key]
383
+ agent_dir = config[:dir]
384
+
385
+ unless File.directory?(agent_dir)
386
+ checks << check(
387
+ category: "agent_registration", name: "agent_dir_exists", status: "fail",
388
+ message: "Agent directory #{tilde(agent_dir)} not found — #{config[:name]} may not be installed",
389
+ fixable: false
390
+ )
391
+ return checks
392
+ end
393
+
394
+ case agent_key
395
+ when "claude"
396
+ checks += check_claude_registration(agent_dir)
397
+ else
398
+ checks += check_generic_agent_registration(agent_key, agent_dir)
399
+ end
400
+
401
+ checks
402
+ end
403
+
404
+ def check_claude_registration(agent_dir)
405
+ checks = []
406
+ hooks_dir = File.join(agent_dir, "hooks")
407
+
408
+ # hooks_exist
409
+ missing_hooks = CLAUDE_HOOK_SCRIPTS.reject { |h| File.exist?(File.join(hooks_dir, h)) }
410
+
411
+ if missing_hooks.empty?
412
+ checks << check(
413
+ category: "agent_registration", name: "hooks_exist", status: "pass",
414
+ message: "All #{CLAUDE_HOOK_SCRIPTS.size} expected hook scripts exist"
415
+ )
416
+ else
417
+ checks << check(
418
+ category: "agent_registration", name: "hooks_exist", status: "fail",
419
+ message: "#{missing_hooks.size} hook script(s) missing",
420
+ details: missing_hooks.map { |h| "#{tilde(hooks_dir)}/#{h}" },
421
+ fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
422
+ )
423
+ end
424
+
425
+ # hooks_executable
426
+ existing_hooks = CLAUDE_HOOK_SCRIPTS
427
+ .map { |h| File.join(hooks_dir, h) }
428
+ .select { |p| File.exist?(p) }
429
+
430
+ non_executable = existing_hooks.reject { |p| File.executable?(p) }
431
+
432
+ if non_executable.empty?
433
+ checks << check(
434
+ category: "agent_registration", name: "hooks_executable", status: "pass",
435
+ message: "All existing hook scripts are executable"
436
+ )
437
+ else
438
+ checks << check(
439
+ category: "agent_registration", name: "hooks_executable", status: "fail",
440
+ message: "#{non_executable.size} hook script(s) not executable",
441
+ details: non_executable.map { |p| tilde(p) },
442
+ fixable: true, fix_hint: "chmod +x on the listed files"
443
+ )
444
+ end
445
+
446
+ # hooks_registered — settings.json has Plastic hooks for required events
447
+ settings_path = File.join(agent_dir, "settings.json")
448
+ settings = read_json_safe(settings_path)
449
+
450
+ if settings.nil?
451
+ checks << check(
452
+ category: "agent_registration", name: "hooks_registered", status: "fail",
453
+ message: "Cannot read #{tilde(settings_path)} — file missing or invalid",
454
+ fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
455
+ )
456
+ else
457
+ hooks = settings["hooks"] || {}
458
+ missing_events = CLAUDE_HOOK_EVENTS.reject do |event|
459
+ groups = hooks[event]
460
+ next false unless groups.is_a?(Array)
461
+
462
+ groups.any? do |group|
463
+ group.is_a?(Hash) && group["hooks"].is_a?(Array) &&
464
+ group["hooks"].any? { |h| h["command"].to_s.include?("plastic-") }
465
+ end
466
+ end
467
+
468
+ if missing_events.empty?
469
+ checks << check(
470
+ category: "agent_registration", name: "hooks_registered", status: "pass",
471
+ message: "All #{CLAUDE_HOOK_EVENTS.size} hook events registered in settings.json"
472
+ )
473
+ else
474
+ checks << check(
475
+ category: "agent_registration", name: "hooks_registered", status: "fail",
476
+ message: "#{missing_events.size} hook event(s) not registered in settings.json",
477
+ details: missing_events,
478
+ fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
479
+ )
480
+ end
481
+ end
482
+
483
+ # skills_exist — flat, hyphen-namespaced personal skills (plastic-<name>/)
484
+ checks << flat_skills_check(agent_dir, "--claude")
485
+
486
+ checks
487
+ end
488
+
489
+ # Plastic skills install as ~/.claude/skills/plastic-<name>/SKILL.md. Pass if at
490
+ # least one such skill is present.
491
+ def flat_skills_check(agent_dir, installer_flag)
492
+ skills_root = File.join(agent_dir, "skills")
493
+ found = Dir.glob(File.join(skills_root, "plastic-*", "SKILL.md"))
494
+
495
+ if !found.empty?
496
+ check(
497
+ category: "agent_registration", name: "skills_exist", status: "pass",
498
+ message: "#{found.size} plastic-* skill(s) installed in #{tilde(skills_root)}"
499
+ )
500
+ else
501
+ check(
502
+ category: "agent_registration", name: "skills_exist", status: "fail",
503
+ message: "No plastic-* skills found in #{tilde(skills_root)}",
504
+ fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest #{installer_flag}"
505
+ )
506
+ end
507
+ end
508
+
509
+ def check_generic_agent_registration(agent_key, agent_dir)
510
+ checks = []
511
+ config = agents[agent_key]
512
+
513
+ # For codex/hermes: just check skills exist (no settings.json hooks)
514
+ checks << flat_skills_check(agent_dir, "--#{agent_key}")
515
+
516
+ checks
517
+ end
518
+
519
+ # --- Check category 4: Core files ---
520
+
521
+ def check_core_files(agent_key)
522
+ checks = []
523
+
524
+ # plastic_md
525
+ plastic_md = File.join(plastic_home, "PLASTIC.md")
526
+ if File.exist?(plastic_md)
527
+ checks << check(
528
+ category: "core_files", name: "plastic_md", status: "pass",
529
+ message: "PLASTIC.md exists"
530
+ )
531
+ else
532
+ checks << check(
533
+ category: "core_files", name: "plastic_md", status: "fail",
534
+ message: "PLASTIC.md not found at #{tilde(plastic_md)}",
535
+ fixable: true, fix_hint: "Re-run the Plastic installer to restore core files"
536
+ )
537
+ end
538
+
539
+ # version_file
540
+ version_path = File.join(plastic_home, "VERSION")
541
+ if File.exist?(version_path)
542
+ checks << check(
543
+ category: "core_files", name: "version_file", status: "pass",
544
+ message: "VERSION file exists"
545
+ )
546
+ else
547
+ checks << check(
548
+ category: "core_files", name: "version_file", status: "fail",
549
+ message: "VERSION file not found at #{tilde(version_path)}",
550
+ fixable: true, fix_hint: "Re-run the Plastic installer to restore core files"
551
+ )
552
+ end
553
+
554
+ # scripts_present
555
+ scripts_dir = File.join(plastic_home, "scripts")
556
+ missing_scripts = REQUIRED_SCRIPTS.reject { |s| File.exist?(File.join(scripts_dir, s)) }
557
+
558
+ if missing_scripts.empty?
559
+ checks << check(
560
+ category: "core_files", name: "scripts_present", status: "pass",
561
+ message: "All #{REQUIRED_SCRIPTS.size} required scripts present"
562
+ )
563
+ else
564
+ checks << check(
565
+ category: "core_files", name: "scripts_present", status: "fail",
566
+ message: "#{missing_scripts.size} required script(s) missing from #{tilde(scripts_dir)}",
567
+ details: missing_scripts,
568
+ fixable: true, fix_hint: "Re-run the Plastic installer to restore scripts"
569
+ )
570
+ end
571
+
572
+ # scripts_executable
573
+ if File.directory?(scripts_dir)
574
+ script_files = Dir.children(scripts_dir)
575
+ .map { |f| File.join(scripts_dir, f) }
576
+ .select { |f| File.file?(f) }
577
+
578
+ non_executable = script_files.reject { |f| File.executable?(f) }
579
+
580
+ if non_executable.empty?
581
+ checks << check(
582
+ category: "core_files", name: "scripts_executable", status: "pass",
583
+ message: "All scripts in #{tilde(scripts_dir)} are executable"
584
+ )
585
+ else
586
+ checks << check(
587
+ category: "core_files", name: "scripts_executable", status: "fail",
588
+ message: "#{non_executable.size} script(s) not executable",
589
+ details: non_executable.map { |f| tilde(f) },
590
+ fixable: true, fix_hint: "chmod +x on the listed files"
591
+ )
592
+ end
593
+ end
594
+
595
+ # version_match — compare global VERSION with agent-side VERSION
596
+ global_version = read_version
597
+ agent_config = agents[agent_key]
598
+
599
+ if global_version && agent_config
600
+ agent_version_path = case agent_key
601
+ when "claude" then File.join(agent_config[:dir], "plastic", "VERSION")
602
+ else File.join(agent_config[:dir], "plastic", "VERSION")
603
+ end
604
+
605
+ if File.exist?(agent_version_path)
606
+ agent_version = File.read(agent_version_path).strip
607
+
608
+ if global_version == agent_version
609
+ checks << check(
610
+ category: "core_files", name: "version_match", status: "pass",
611
+ message: "Global VERSION (#{global_version}) matches agent-side VERSION"
612
+ )
613
+ else
614
+ checks << check(
615
+ category: "core_files", name: "version_match", status: "warn",
616
+ message: "Version mismatch: global=#{global_version}, agent=#{agent_version}",
617
+ details: [
618
+ "#{tilde(File.join(plastic_home, "VERSION"))}: #{global_version}",
619
+ "#{tilde(agent_version_path)}: #{agent_version}",
620
+ ],
621
+ fixable: false
622
+ )
623
+ end
624
+ else
625
+ checks << check(
626
+ category: "core_files", name: "version_match", status: "warn",
627
+ message: "Agent-side VERSION file not found at #{tilde(agent_version_path)}",
628
+ fixable: false
629
+ )
630
+ end
631
+ end
632
+
633
+ checks
634
+ end
635
+
636
+ # --- Check category 5: Project stores ---
637
+
638
+ def check_project_stores
639
+ checks = []
640
+
641
+ projects_yml_path = File.join(plastic_home, "projects.yml")
642
+ projects_data = load_yaml_safe(projects_yml_path)
643
+
644
+ if projects_data.nil?
645
+ checks << check(
646
+ category: "project_stores", name: "projects_yml", status: "warn",
647
+ message: "projects.yml not found or invalid at #{tilde(projects_yml_path)}",
648
+ fixable: true, fix_hint: "Re-run the Plastic installer to restore projects.yml"
649
+ )
650
+ return checks
651
+ end
652
+
653
+ projects = projects_data["projects"]
654
+ unless projects.is_a?(Hash) && !projects.empty?
655
+ # No projects registered — nothing to check
656
+ checks << check(
657
+ category: "project_stores", name: "projects_yml", status: "pass",
658
+ message: "projects.yml is valid (#{projects.is_a?(Hash) ? projects.size : 0} projects registered)"
659
+ )
660
+ return checks
661
+ end
662
+
663
+ # Load INDEX.md content for cross-reference checks
664
+ index_path = File.join(plastic_home, "INDEX.md")
665
+ index_content = File.exist?(index_path) ? File.read(index_path) : ""
666
+
667
+ projects.each do |slug, project_info|
668
+ project_dir = File.join(plastic_home, "projects", slug)
669
+
670
+ # project_dir_exists
671
+ if File.directory?(project_dir)
672
+ checks << check(
673
+ category: "project_stores", name: "project_dir_exists", status: "pass",
674
+ message: "Project directory exists for '#{slug}'"
675
+ )
676
+ else
677
+ checks << check(
678
+ category: "project_stores", name: "project_dir_exists", status: "warn",
679
+ message: "Project directory missing for '#{slug}'",
680
+ details: [tilde(project_dir)],
681
+ fixable: true, fix_hint: "Create the project store directory: mkdir -p #{tilde(project_dir)}"
682
+ )
683
+ end
684
+
685
+ # project_index
686
+ project_index = File.join(project_dir, "INDEX.md")
687
+ if File.exist?(project_index)
688
+ checks << check(
689
+ category: "project_stores", name: "project_index", status: "pass",
690
+ message: "INDEX.md exists for project '#{slug}'"
691
+ )
692
+ else
693
+ checks << check(
694
+ category: "project_stores", name: "project_index", status: "warn",
695
+ message: "INDEX.md missing for project '#{slug}'",
696
+ details: [tilde(project_index)],
697
+ fixable: true, fix_hint: "Create INDEX.md in the project store directory"
698
+ )
699
+ end
700
+
701
+ # project_yml_exists
702
+ project_yml_path = File.join(plastic_home, "projects", slug, "project.yml")
703
+ project_yml_data = nil
704
+
705
+ if File.exist?(project_yml_path)
706
+ checks << check(
707
+ category: "project_stores", name: "project_yml_exists", status: "pass",
708
+ message: "project.yml exists for project '#{slug}'"
709
+ )
710
+ project_yml_data = load_yaml_safe(project_yml_path)
711
+ else
712
+ checks << check(
713
+ category: "project_stores", name: "project_yml_exists", status: "warn",
714
+ message: "project.yml missing for project '#{slug}'",
715
+ fixable: true, fix_hint: "Create project.yml from template — see plastic-creating-project"
716
+ )
717
+ end
718
+
719
+ # governing_docs_exist
720
+ if project_yml_data.is_a?(Hash) && project_yml_data["governing_docs"].is_a?(Array) && !project_yml_data["governing_docs"].empty?
721
+ project_path = project_info.is_a?(Hash) ? project_info["path"] : nil
722
+
723
+ if project_path
724
+ missing_docs = project_yml_data["governing_docs"].reject do |doc_path|
725
+ File.exist?(File.join(project_path, doc_path))
726
+ end
727
+
728
+ if missing_docs.empty?
729
+ checks << check(
730
+ category: "project_stores", name: "governing_docs_exist", status: "pass",
731
+ message: "All governing docs exist for project '#{slug}'"
732
+ )
733
+ else
734
+ checks << check(
735
+ category: "project_stores", name: "governing_docs_exist", status: "warn",
736
+ message: "#{missing_docs.size} governing doc(s) missing for project '#{slug}'",
737
+ details: missing_docs,
738
+ fixable: false
739
+ )
740
+ end
741
+ end
742
+ end
743
+
744
+ # cross_references — if project has `parent` field, check global store intent tags
745
+ parent_id = project_info.is_a?(Hash) ? project_info["parent"] : nil
746
+ next unless parent_id
747
+
748
+ # Find the intent directory for the parent ID
749
+ store_dir = File.join(plastic_home, "store")
750
+ parent_dir = nil
751
+ if File.directory?(store_dir)
752
+ parent_dir = Dir.children(store_dir).find { |d| d.start_with?("#{parent_id}--") }
753
+ end
754
+
755
+ if parent_dir.nil?
756
+ checks << check(
757
+ category: "project_stores", name: "cross_references", status: "warn",
758
+ message: "Parent intent '#{parent_id}' for project '#{slug}' not found in global store",
759
+ fixable: false
760
+ )
761
+ next
762
+ end
763
+
764
+ intent_md = File.join(store_dir, parent_dir, "#{parent_dir}.md")
765
+ fm = parse_frontmatter(intent_md)
766
+
767
+ if fm.nil?
768
+ checks << check(
769
+ category: "project_stores", name: "cross_references", status: "warn",
770
+ message: "Cannot read frontmatter of parent intent '#{parent_id}' for project '#{slug}'",
771
+ fixable: false
772
+ )
773
+ next
774
+ end
775
+
776
+ tags = fm["tags"]
777
+ expected_tag = "project-#{slug}"
778
+
779
+ if tags.is_a?(Array) && tags.include?(expected_tag)
780
+ checks << check(
781
+ category: "project_stores", name: "cross_references", status: "pass",
782
+ message: "Parent intent '#{parent_id}' has '#{expected_tag}' tag for project '#{slug}'"
783
+ )
784
+ else
785
+ checks << check(
786
+ category: "project_stores", name: "cross_references", status: "warn",
787
+ message: "Parent intent '#{parent_id}' missing '#{expected_tag}' tag",
788
+ details: ["Intent: store/#{parent_dir}", "Expected tag: #{expected_tag}", "Current tags: #{(tags || []).inspect}"],
789
+ fixable: false
790
+ )
791
+ end
792
+ end
793
+
794
+ checks
795
+ end
796
+
797
+ # --- Check category 6: Deprecations ---
798
+
799
+ def check_deprecations
800
+ checks = []
801
+
802
+ deprecations_path = File.join(plastic_home, "deprecations.yml")
803
+ data = load_yaml_safe(deprecations_path)
804
+
805
+ if data.nil?
806
+ checks << check(
807
+ category: "deprecations", name: "deprecations_file", status: "pass",
808
+ message: "No deprecations.yml found (nothing to report)"
809
+ )
810
+ return checks
811
+ end
812
+
813
+ entries = data["deprecations"]
814
+ unless entries.is_a?(Array) && !entries.empty?
815
+ checks << check(
816
+ category: "deprecations", name: "active_deprecations", status: "pass",
817
+ message: "No deprecation entries found"
818
+ )
819
+ return checks
820
+ end
821
+
822
+ current_version = read_version
823
+ unless current_version
824
+ checks << check(
825
+ category: "deprecations", name: "active_deprecations", status: "warn",
826
+ message: "Cannot compare deprecation versions — VERSION file missing",
827
+ fixable: false
828
+ )
829
+ return checks
830
+ end
831
+
832
+ # Find deprecations where removal version is greater than current version
833
+ # Use simple string comparison on semver (works for well-formed versions)
834
+ active = entries.select do |entry|
835
+ removal = entry["removal"].to_s
836
+ next false if removal.empty?
837
+
838
+ compare_versions(current_version, removal) < 0
839
+ end
840
+
841
+ if active.empty?
842
+ checks << check(
843
+ category: "deprecations", name: "active_deprecations", status: "pass",
844
+ message: "No active deprecations for current version (#{current_version})"
845
+ )
846
+ else
847
+ details = active.map do |entry|
848
+ lines = ["[#{entry["severity"]}] #{entry["summary"]} (removal: #{entry["removal"]})"]
849
+ if entry["migration_steps"].is_a?(Array)
850
+ entry["migration_steps"].each { |step| lines << " - #{step}" }
851
+ end
852
+ lines.join("\n")
853
+ end
854
+
855
+ checks << check(
856
+ category: "deprecations", name: "active_deprecations", status: "warn",
857
+ message: "#{active.size} active deprecation(s) for version #{current_version}",
858
+ details: details,
859
+ fixable: false
860
+ )
861
+ end
862
+
863
+ checks
864
+ end
865
+
866
+ # Compare two semver strings. Returns -1, 0, or 1.
867
+ # Handles pre-release tags: 1.0.0-alpha.5 < 1.0.0 < 2.0.0
868
+ def compare_versions(a, b)
869
+ parse = ->(v) {
870
+ base, pre = v.split("-", 2)
871
+ segments = base.split(".").map(&:to_i)
872
+ [segments, pre]
873
+ }
874
+
875
+ a_segments, a_pre = parse.call(a)
876
+ b_segments, b_pre = parse.call(b)
877
+
878
+ # Pad to equal length
879
+ max_len = [a_segments.size, b_segments.size].max
880
+ a_segments += [0] * (max_len - a_segments.size)
881
+ b_segments += [0] * (max_len - b_segments.size)
882
+
883
+ cmp = (a_segments <=> b_segments)
884
+ return cmp unless cmp == 0
885
+
886
+ # Same base version: no pre-release > pre-release (1.0.0 > 1.0.0-alpha)
887
+ return 0 if a_pre.nil? && b_pre.nil?
888
+ return 1 if a_pre.nil? && b_pre
889
+ return -1 if a_pre && b_pre.nil?
890
+
891
+ # Both have pre-release: compare lexically
892
+ a_pre <=> b_pre
893
+ end
894
+
895
+ # --- Run all checks ---
896
+
897
+ def run_checks(agent_key)
898
+ all_checks = []
899
+ all_checks += check_global_store
900
+ all_checks += check_conventions
901
+ all_checks += check_agent_registration(agent_key)
902
+ all_checks += check_core_files(agent_key)
903
+ all_checks += check_project_stores
904
+ all_checks += check_deprecations
905
+
906
+ summary = { pass: 0, warn: 0, fail: 0, total: all_checks.size }
907
+ all_checks.each { |c| summary[c[:status].to_sym] += 1 }
908
+
909
+ overall = if summary[:fail] > 0
910
+ "fail"
911
+ elsif summary[:warn] > 0
912
+ "warn"
913
+ else
914
+ "pass"
915
+ end
916
+
917
+ {
918
+ version: read_version || "unknown",
919
+ timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
920
+ status: overall,
921
+ agent: agent_key,
922
+ checks: all_checks,
923
+ summary: summary,
924
+ }
925
+ end
926
+
927
+ # --- Main ---
928
+
929
+ def cli(argv = ARGV)
930
+ flags = parse_args(argv)
931
+
932
+ if flags[:help]
933
+ show_help
934
+ exit 0
935
+ end
936
+
937
+ result = run_checks(flags[:agent])
938
+
939
+ puts JSON.pretty_generate(result)
940
+
941
+ case result[:status]
942
+ when "fail" then exit 2
943
+ when "warn" then exit 1
944
+ else exit 0
945
+ end
946
+ end
947
+
948
+ end
949
+
950
+ Doctor.new.cli(ARGV) if $PROGRAM_NAME == __FILE__