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

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