@zalom/plastic 1.0.0-alpha.3 → 1.0.0-alpha.31

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 (87) hide show
  1. package/PLASTIC.md +134 -469
  2. package/README.md +95 -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 +1 -10
  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 +12 -0
  12. package/hooks/gate-check +3 -1
  13. package/hooks/hooks.json +25 -4
  14. package/hooks/statusline +50 -10
  15. package/package.json +2 -2
  16. package/scripts/dashboard.rb +680 -0
  17. package/scripts/doctor.rb +1227 -0
  18. package/scripts/hook-auto-arm +51 -0
  19. package/scripts/hook-bash-gate +41 -0
  20. package/scripts/hook-code-gate +27 -0
  21. package/scripts/hook-continue +15 -114
  22. package/scripts/hook-gate-check +30 -34
  23. package/scripts/hook-session-start +121 -31
  24. package/scripts/install.rb +91 -487
  25. package/scripts/lib/boot_banner.rb +28 -0
  26. package/scripts/lib/bridge.rb +358 -10
  27. package/scripts/lib/installer_core.rb +747 -0
  28. package/scripts/lib/intent_validator.rb +90 -0
  29. package/scripts/lib/qmd_sync.rb +159 -0
  30. package/scripts/lib/store_provisioning.rb +100 -0
  31. package/scripts/migrate-to-global +1 -1
  32. package/scripts/provision-project-store +53 -0
  33. package/scripts/qmd-sync +92 -0
  34. package/scripts/select-update-target +93 -0
  35. package/scripts/uninstall.rb +53 -0
  36. package/scripts/update.rb +164 -0
  37. package/scripts/validate-intent +54 -0
  38. package/scripts/versions.rb +141 -0
  39. package/skills/_active-intent-gate.md +26 -0
  40. package/skills/add-project-store/SKILL.md +54 -0
  41. package/skills/auto/SKILL.md +69 -9
  42. package/skills/auto/evals/evals.json +92 -0
  43. package/skills/auto/references/agent-architecture.md +60 -0
  44. package/skills/brainstorming/SKILL.md +143 -0
  45. package/skills/brainstorming-grill-me/SKILL.md +5 -5
  46. package/skills/continuing/SKILL.md +92 -82
  47. package/skills/continuing/evals/evals.json +136 -0
  48. package/skills/continuing/references/context-management.md +32 -0
  49. package/skills/creating-intent/SKILL.md +38 -9
  50. package/skills/creating-intent/references/lifecycle.md +74 -0
  51. package/skills/creating-intent/references/wikilinks.md +8 -0
  52. package/skills/creating-project/SKILL.md +40 -8
  53. package/skills/creating-project/references/hubs-projects.md +55 -0
  54. package/skills/dashboard/SKILL.md +121 -0
  55. package/skills/dashboard/templates/dashboard-global.md +31 -0
  56. package/skills/dashboard/templates/dashboard-project.md +40 -0
  57. package/skills/doctor/SKILL.md +159 -0
  58. package/skills/doctor/references/gates-stuck-detection.md +38 -0
  59. package/skills/doctor/report.md +100 -0
  60. package/skills/evaluating-skills/SKILL.md +140 -0
  61. package/skills/evaluating-skills/assets/eval-template.json +12 -0
  62. package/skills/evaluating-skills/evals/evals.json +75 -0
  63. package/skills/evaluating-skills/references/convention-checks.md +76 -0
  64. package/skills/evaluating-skills/references/eval-methodology.md +154 -0
  65. package/skills/executing-plan/SKILL.md +5 -3
  66. package/skills/install/SKILL.md +69 -8
  67. package/skills/intent-curator/SKILL.md +3 -3
  68. package/skills/linking-intents/SKILL.md +5 -1
  69. package/skills/linking-intents/references/zettelkasten.md +33 -0
  70. package/skills/managing-index/SKILL.md +5 -1
  71. package/skills/releasing/SKILL.md +119 -18
  72. package/skills/releasing/references/deprecations.md +60 -0
  73. package/skills/research/SKILL.md +114 -0
  74. package/skills/savepoint/SKILL.md +46 -37
  75. package/skills/savepoint/references/context-management.md +32 -0
  76. package/skills/uninstall/SKILL.md +39 -28
  77. package/skills/update/SKILL.md +41 -36
  78. package/skills/versions/SKILL.md +65 -0
  79. package/skills/writing-instructions/SKILL.md +159 -0
  80. package/skills/writing-instructions/references/agentskills-spec.md +135 -0
  81. package/skills/writing-plans/SKILL.md +183 -0
  82. package/templates/agents.md +16 -0
  83. package/templates/outcome.md +13 -0
  84. package/templates/project.yml +5 -0
  85. package/templates/savepoint.md +14 -13
  86. package/templates/spec.md +25 -0
  87. package/bin/install.js +0 -29
@@ -0,0 +1,1227 @@
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
+ require "digest"
17
+
18
+ require_relative "lib/qmd_sync"
19
+ require_relative "lib/intent_validator"
20
+
21
+ # Diagnostic engine, instantiable with an injected store/agent map so tests can
22
+ # run it hermetically (no eval, no global-constant rewriting).
23
+ class Doctor
24
+ DEFAULT_PLASTIC_HOME = File.join(Dir.home, ".plastic")
25
+
26
+ DEFAULT_AGENTS = {
27
+ "claude" => { name: "Claude Code", dir: File.join(Dir.home, ".claude") },
28
+ "codex" => { name: "Codex CLI", dir: File.join(Dir.home, ".agents") },
29
+ "hermes" => { name: "Hermes", dir: File.join(Dir.home, ".hermes") },
30
+ }.freeze
31
+
32
+ REQUIRED_INDEX_SECTIONS = ["## Active", "## Future", "## Clusters", "## Abandoned", "## Completed"].freeze
33
+
34
+ # Single source of truth for the required-field list lives in IntentValidator
35
+ # (intent 60). Alias it here so the two can never drift.
36
+ REQUIRED_FRONTMATTER_FIELDS = IntentValidator::REQUIRED_FIELDS
37
+
38
+ CLAUDE_HOOK_SCRIPTS = %w[
39
+ plastic-session-start
40
+ plastic-check-update
41
+ plastic-savepoint
42
+ plastic-gate-check
43
+ plastic-continue
44
+ plastic-future-intent-check
45
+ ].freeze
46
+
47
+ CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit].freeze
48
+
49
+ REQUIRED_SCRIPTS = %w[
50
+ folgezettel-id
51
+ read-config
52
+ hook-session-start
53
+ hook-continue
54
+ hook-future-intent-check
55
+ hook-gate-check
56
+ validate-intent
57
+ doctor.rb
58
+ ].freeze
59
+
60
+ attr_reader :plastic_home, :agents
61
+
62
+ def initialize(plastic_home: DEFAULT_PLASTIC_HOME, agents: DEFAULT_AGENTS)
63
+ @plastic_home = plastic_home
64
+ @agents = agents
65
+ end
66
+
67
+ # --- Flag parsing ---
68
+
69
+ def parse_args(argv)
70
+ agent = "claude"
71
+ help = false
72
+ core = false
73
+ # store flag representation:
74
+ # nil — --store not given
75
+ # :all — --store with no value (check every store)
76
+ # :global — --store global
77
+ # "<slug>" — --store <slug> (a single project)
78
+ store = nil
79
+
80
+ i = 0
81
+ while i < argv.length
82
+ case argv[i]
83
+ when "--agent"
84
+ if argv[i + 1] && agents.key?(argv[i + 1])
85
+ agent = argv[i + 1]
86
+ i += 2
87
+ else
88
+ $stderr.puts "Error: --agent requires one of: #{agents.keys.join(", ")}"
89
+ exit 2
90
+ end
91
+ when "--core"
92
+ core = true
93
+ i += 1
94
+ when "--store"
95
+ nxt = argv[i + 1]
96
+ if nxt && !nxt.start_with?("-")
97
+ store = (nxt == "global") ? :global : nxt
98
+ i += 2
99
+ else
100
+ store = :all
101
+ i += 1
102
+ end
103
+ when "--help", "-h"
104
+ help = true
105
+ i += 1
106
+ else
107
+ i += 1
108
+ end
109
+ end
110
+
111
+ { agent: agent, help: help, core: core, store: store }
112
+ end
113
+
114
+ def show_help
115
+ $stderr.puts <<~HELP
116
+
117
+ plastic doctor — diagnose Plastic installation health
118
+
119
+ Usage:
120
+ ruby ~/.plastic/scripts/doctor.rb [options]
121
+
122
+ Options:
123
+ --agent NAME Agent to check: claude (default), codex, hermes
124
+ --core Binary core sync check: verifies agent registration, core
125
+ files, and that every manifest-tracked file matches its
126
+ recorded SHA256. Exits 0 (pass) or 2 (fail); never warn.
127
+ --store [WHICH] Run only the store/conventions checks. WHICH may be:
128
+ global (global store only), a project slug (that project
129
+ only), or omitted (all stores). 3-state pass/warn/fail.
130
+ -h, --help Show this help
131
+
132
+ Output:
133
+ JSON to stdout with check results.
134
+ Exit 0 = all pass, 1 = warnings only, 2 = failures present.
135
+
136
+ HELP
137
+ end
138
+
139
+ # --- Utility helpers ---
140
+
141
+ def read_version
142
+ version_path = File.join(plastic_home, "VERSION")
143
+ return nil unless File.exist?(version_path)
144
+
145
+ File.read(version_path).strip
146
+ end
147
+
148
+ def read_json_safe(path)
149
+ return nil unless File.exist?(path)
150
+
151
+ JSON.parse(File.read(path))
152
+ rescue JSON::ParserError
153
+ content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
154
+ JSON.parse(content)
155
+ rescue
156
+ nil
157
+ end
158
+
159
+ def load_yaml_safe(path)
160
+ return nil unless File.exist?(path)
161
+
162
+ YAML.safe_load(File.read(path)) || {}
163
+ rescue => e
164
+ $stderr.puts "Warning: failed to parse #{path}: #{e.message}"
165
+ nil
166
+ end
167
+
168
+ def tilde(path)
169
+ path.sub(Dir.home, "~")
170
+ end
171
+
172
+ def check(category:, name:, status:, message:, details: [], fixable: false, fix_hint: nil)
173
+ result = {
174
+ category: category,
175
+ name: name,
176
+ status: status,
177
+ message: message,
178
+ details: details,
179
+ fixable: fixable,
180
+ }
181
+ result[:fix_hint] = fix_hint if fix_hint
182
+ result
183
+ end
184
+
185
+ # --- Parse frontmatter from an intent markdown file ---
186
+
187
+ def parse_frontmatter(path)
188
+ return nil unless File.exist?(path)
189
+
190
+ content = File.read(path)
191
+ return nil unless content.start_with?("---")
192
+
193
+ parts = content.split("---", 3)
194
+ return nil if parts.length < 3
195
+
196
+ # created: dates parse as Date objects, which safe_load rejects by default —
197
+ # permit Date/Time so valid frontmatter isn't misreported as missing.
198
+ YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
199
+ rescue
200
+ nil
201
+ end
202
+
203
+ # --- Collect all intent directories (global + project stores) ---
204
+
205
+ # Child directories of a store, excluding dotfiles/dot-directories
206
+ # (e.g. .obsidian, .git) which are tooling artifacts, not intents.
207
+ def store_intent_dirs(store)
208
+ Dir.children(store).reject { |e| e.start_with?(".") }.select do |e|
209
+ File.directory?(File.join(store, e))
210
+ end
211
+ end
212
+
213
+ def all_intent_dirs
214
+ dirs = []
215
+
216
+ global_store = File.join(plastic_home, "store")
217
+ if File.directory?(global_store)
218
+ store_intent_dirs(global_store).each do |entry|
219
+ full = File.join(global_store, entry)
220
+ dirs << { path: full, name: entry, scope: "global" }
221
+ end
222
+ end
223
+
224
+ projects_root = File.join(plastic_home, "projects")
225
+ if File.directory?(projects_root)
226
+ Dir.children(projects_root).each do |project|
227
+ project_store = File.join(projects_root, project, "store")
228
+ next unless File.directory?(project_store)
229
+
230
+ store_intent_dirs(project_store).each do |entry|
231
+ full = File.join(project_store, entry)
232
+ dirs << { path: full, name: entry, scope: "project:#{project}" }
233
+ end
234
+ end
235
+ end
236
+
237
+ dirs
238
+ end
239
+
240
+ # --- Check category 1: Global store ---
241
+
242
+ def check_global_store
243
+ checks = []
244
+
245
+ index_path = File.join(plastic_home, "INDEX.md")
246
+
247
+ # index_exists
248
+ if File.exist?(index_path)
249
+ checks << check(
250
+ category: "global_store", name: "index_exists", status: "pass",
251
+ message: "INDEX.md exists"
252
+ )
253
+ else
254
+ checks << check(
255
+ category: "global_store", name: "index_exists", status: "fail",
256
+ message: "INDEX.md not found at #{tilde(index_path)}",
257
+ fixable: true, fix_hint: "Run the Plastic installer to bootstrap the store"
258
+ )
259
+ return checks # Can't check sections/references without INDEX.md
260
+ end
261
+
262
+ # index_sections
263
+ content = File.read(index_path)
264
+ missing_sections = REQUIRED_INDEX_SECTIONS.reject { |s| content.include?(s) }
265
+
266
+ if missing_sections.empty?
267
+ checks << check(
268
+ category: "global_store", name: "index_sections", status: "pass",
269
+ message: "INDEX.md has all 5 required sections"
270
+ )
271
+ else
272
+ checks << check(
273
+ category: "global_store", name: "index_sections", status: "fail",
274
+ message: "INDEX.md missing #{missing_sections.size} required section(s)",
275
+ details: missing_sections,
276
+ fixable: true, fix_hint: "Add missing sections to INDEX.md"
277
+ )
278
+ end
279
+
280
+ # orphaned_intents — directories in store/ not referenced in INDEX.md
281
+ store_dir = File.join(plastic_home, "store")
282
+ if File.directory?(store_dir)
283
+ intent_dirs = store_intent_dirs(store_dir)
284
+ orphans = intent_dirs.reject { |d| content.include?("store/#{d}") }
285
+
286
+ if orphans.empty?
287
+ checks << check(
288
+ category: "global_store", name: "orphaned_intents", status: "pass",
289
+ message: "No orphaned intent directories"
290
+ )
291
+ else
292
+ checks << check(
293
+ category: "global_store", name: "orphaned_intents", status: "warn",
294
+ message: "#{orphans.size} intent director#{orphans.size == 1 ? "y" : "ies"} not referenced in INDEX.md",
295
+ details: orphans.map { |d| "store/#{d}" },
296
+ fixable: true, fix_hint: "Add missing intents to INDEX.md or remove orphaned directories"
297
+ )
298
+ end
299
+ end
300
+
301
+ # ghost_references — paths in INDEX.md pointing to non-existent directories
302
+ store_refs = content.scan(%r{store/[\w][\w-]*(?:/[\w][\w.-]*)*/?\b}).uniq
303
+ # Normalize: extract just the store/ID--slug portion
304
+ store_paths = content.scan(%r{store/\S+}).map { |ref| ref.gsub(/[)\]>].*/, "").chomp("/") }.uniq
305
+
306
+ ghosts = store_paths.select do |ref|
307
+ full_path = File.join(plastic_home, ref)
308
+ !File.exist?(full_path) && !File.directory?(full_path)
309
+ end
310
+
311
+ if ghosts.empty?
312
+ checks << check(
313
+ category: "global_store", name: "ghost_references", status: "pass",
314
+ message: "No ghost references in INDEX.md"
315
+ )
316
+ else
317
+ checks << check(
318
+ category: "global_store", name: "ghost_references", status: "warn",
319
+ message: "#{ghosts.size} path(s) in INDEX.md point to non-existent locations",
320
+ details: ghosts,
321
+ fixable: true, fix_hint: "Remove or fix broken references in INDEX.md"
322
+ )
323
+ end
324
+
325
+ checks
326
+ end
327
+
328
+ # --- Check category 2: Conventions ---
329
+
330
+ # When `scopes` is a non-nil Array of scope strings (e.g. ["global"] or
331
+ # ["project:plastic"]), only intents whose :scope is in that list are checked.
332
+ # When nil (the default, used by the full run), every intent is checked.
333
+ def check_conventions(scopes: nil)
334
+ checks = []
335
+
336
+ intent_dirs = all_intent_dirs
337
+ intent_dirs = intent_dirs.select { |d| scopes.include?(d[:scope]) } unless scopes.nil?
338
+ dirname_pattern = /^\w+--[\w-]+$/
339
+
340
+ # intent_dirname
341
+ bad_dirnames = intent_dirs.reject { |d| d[:name].match?(dirname_pattern) }
342
+
343
+ if bad_dirnames.empty?
344
+ checks << check(
345
+ category: "conventions", name: "intent_dirname", status: "pass",
346
+ message: "All #{intent_dirs.size} intent directories follow {ID}--{slug} format"
347
+ )
348
+ else
349
+ checks << check(
350
+ category: "conventions", name: "intent_dirname", status: "warn",
351
+ message: "#{bad_dirnames.size} intent director#{bad_dirnames.size == 1 ? "y doesn't" : "ies don't"} follow {ID}--{slug} format",
352
+ details: bad_dirnames.map { |d| "#{tilde(d[:path])} (#{d[:scope]})" },
353
+ fixable: true, fix_hint: "Rename directories to {ID}--{slug} format"
354
+ )
355
+ end
356
+
357
+ # intent_filename — primary file inside directory matches {ID}--{slug}.md
358
+ bad_filenames = []
359
+ intent_dirs.each do |d|
360
+ expected_file = "#{d[:name]}.md"
361
+ expected_path = File.join(d[:path], expected_file)
362
+ unless File.exist?(expected_path)
363
+ bad_filenames << { dir: d, expected: expected_file }
364
+ end
365
+ end
366
+
367
+ if bad_filenames.empty?
368
+ checks << check(
369
+ category: "conventions", name: "intent_filename", status: "pass",
370
+ message: "All intent directories have matching {ID}--{slug}.md files"
371
+ )
372
+ else
373
+ checks << check(
374
+ category: "conventions", name: "intent_filename", status: "warn",
375
+ message: "#{bad_filenames.size} intent director#{bad_filenames.size == 1 ? "y" : "ies"} missing primary .md file",
376
+ details: bad_filenames.map { |b| "#{tilde(b[:dir][:path])} — expected #{b[:expected]}" },
377
+ fixable: true, fix_hint: "Create or rename the primary .md file to match the directory name"
378
+ )
379
+ end
380
+
381
+ # frontmatter_fields
382
+ bad_frontmatter = []
383
+ intent_dirs.each do |d|
384
+ md_path = File.join(d[:path], "#{d[:name]}.md")
385
+ next unless File.exist?(md_path)
386
+
387
+ fm = parse_frontmatter(md_path)
388
+ if fm.nil?
389
+ bad_frontmatter << { dir: tilde(d[:path]), missing: ["(no frontmatter found)"] }
390
+ next
391
+ end
392
+
393
+ # Delegate missing-field detection to the single source of truth.
394
+ missing = IntentValidator.validate_frontmatter(fm)[:missing]
395
+ bad_frontmatter << { dir: tilde(d[:path]), missing: missing } unless missing.empty?
396
+ end
397
+
398
+ if bad_frontmatter.empty?
399
+ checks << check(
400
+ category: "conventions", name: "frontmatter_fields", status: "pass",
401
+ message: "All intent files have required frontmatter fields"
402
+ )
403
+ else
404
+ checks << check(
405
+ category: "conventions", name: "frontmatter_fields", status: "warn",
406
+ message: "#{bad_frontmatter.size} intent file(s) missing required frontmatter fields",
407
+ details: bad_frontmatter.map { |b| "#{b[:dir]}: missing #{b[:missing].join(", ")}" },
408
+ fixable: true,
409
+ fix_hint: "Inject the missing required frontmatter field(s) (e.g. chain: []) without disturbing other keys"
410
+ )
411
+ end
412
+
413
+ # frontmatter_valid — per-intent frontmatter shape (sources/chain are
414
+ # well-formed arrays of id strings). Delegates to IntentValidator so the
415
+ # born-complete contract is defined in exactly one place. Read-only: shape
416
+ # repair is not a single-field inject, so this check is not fixable here.
417
+ malformed = []
418
+ intent_dirs.each do |d|
419
+ md_path = File.join(d[:path], "#{d[:name]}.md")
420
+ next unless File.exist?(md_path)
421
+
422
+ result = IntentValidator.validate_frontmatter(parse_frontmatter(md_path))
423
+ shape_errors = result[:errors].select { |e| e.include?("must be an array") || e.include?("invalid id") }
424
+ malformed << { dir: tilde(d[:path]), errors: shape_errors } unless shape_errors.empty?
425
+ end
426
+
427
+ if malformed.empty?
428
+ checks << check(
429
+ category: "conventions", name: "frontmatter_valid", status: "pass",
430
+ message: "All intent frontmatter is well-formed"
431
+ )
432
+ else
433
+ checks << check(
434
+ category: "conventions", name: "frontmatter_valid", status: "warn",
435
+ message: "#{malformed.size} intent file(s) have malformed sources/chain",
436
+ details: malformed.map { |m| "#{m[:dir]}: #{m[:errors].join(", ")}" },
437
+ fixable: false
438
+ )
439
+ end
440
+
441
+ checks
442
+ end
443
+
444
+ # --- Check category 3: Agent registration ---
445
+
446
+ def check_agent_registration(agent_key)
447
+ checks = []
448
+ config = agents[agent_key]
449
+ agent_dir = config[:dir]
450
+
451
+ unless File.directory?(agent_dir)
452
+ checks << check(
453
+ category: "agent_registration", name: "agent_dir_exists", status: "fail",
454
+ message: "Agent directory #{tilde(agent_dir)} not found — #{config[:name]} may not be installed",
455
+ fixable: false
456
+ )
457
+ return checks
458
+ end
459
+
460
+ case agent_key
461
+ when "claude"
462
+ checks += check_claude_registration(agent_dir)
463
+ else
464
+ checks += check_generic_agent_registration(agent_key, agent_dir)
465
+ end
466
+
467
+ checks
468
+ end
469
+
470
+ def check_claude_registration(agent_dir)
471
+ checks = []
472
+ hooks_dir = File.join(agent_dir, "hooks")
473
+
474
+ # hooks_exist
475
+ missing_hooks = CLAUDE_HOOK_SCRIPTS.reject { |h| File.exist?(File.join(hooks_dir, h)) }
476
+
477
+ if missing_hooks.empty?
478
+ checks << check(
479
+ category: "agent_registration", name: "hooks_exist", status: "pass",
480
+ message: "All #{CLAUDE_HOOK_SCRIPTS.size} expected hook scripts exist"
481
+ )
482
+ else
483
+ checks << check(
484
+ category: "agent_registration", name: "hooks_exist", status: "fail",
485
+ message: "#{missing_hooks.size} hook script(s) missing",
486
+ details: missing_hooks.map { |h| "#{tilde(hooks_dir)}/#{h}" },
487
+ fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
488
+ )
489
+ end
490
+
491
+ # hooks_executable
492
+ existing_hooks = CLAUDE_HOOK_SCRIPTS
493
+ .map { |h| File.join(hooks_dir, h) }
494
+ .select { |p| File.exist?(p) }
495
+
496
+ non_executable = existing_hooks.reject { |p| File.executable?(p) }
497
+
498
+ if non_executable.empty?
499
+ checks << check(
500
+ category: "agent_registration", name: "hooks_executable", status: "pass",
501
+ message: "All existing hook scripts are executable"
502
+ )
503
+ else
504
+ checks << check(
505
+ category: "agent_registration", name: "hooks_executable", status: "fail",
506
+ message: "#{non_executable.size} hook script(s) not executable",
507
+ details: non_executable.map { |p| tilde(p) },
508
+ fixable: true, fix_hint: "chmod +x on the listed files"
509
+ )
510
+ end
511
+
512
+ # hooks_registered — settings.json has Plastic hooks for required events
513
+ settings_path = File.join(agent_dir, "settings.json")
514
+ settings = read_json_safe(settings_path)
515
+
516
+ if settings.nil?
517
+ checks << check(
518
+ category: "agent_registration", name: "hooks_registered", status: "fail",
519
+ message: "Cannot read #{tilde(settings_path)} — file missing or invalid",
520
+ fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
521
+ )
522
+ else
523
+ hooks = settings["hooks"] || {}
524
+ missing_events = CLAUDE_HOOK_EVENTS.reject do |event|
525
+ groups = hooks[event]
526
+ next false unless groups.is_a?(Array)
527
+
528
+ groups.any? do |group|
529
+ group.is_a?(Hash) && group["hooks"].is_a?(Array) &&
530
+ group["hooks"].any? { |h| h["command"].to_s.include?("plastic-") }
531
+ end
532
+ end
533
+
534
+ if missing_events.empty?
535
+ checks << check(
536
+ category: "agent_registration", name: "hooks_registered", status: "pass",
537
+ message: "All #{CLAUDE_HOOK_EVENTS.size} hook events registered in settings.json"
538
+ )
539
+ else
540
+ checks << check(
541
+ category: "agent_registration", name: "hooks_registered", status: "fail",
542
+ message: "#{missing_events.size} hook event(s) not registered in settings.json",
543
+ details: missing_events,
544
+ fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
545
+ )
546
+ end
547
+ end
548
+
549
+ # skills_exist — flat, hyphen-namespaced personal skills (plastic-<name>/)
550
+ checks << flat_skills_check(agent_dir, "--claude")
551
+
552
+ checks
553
+ end
554
+
555
+ # Plastic skills install as ~/.claude/skills/plastic-<name>/SKILL.md. Pass if at
556
+ # least one such skill is present.
557
+ def flat_skills_check(agent_dir, installer_flag)
558
+ skills_root = File.join(agent_dir, "skills")
559
+ found = Dir.glob(File.join(skills_root, "plastic-*", "SKILL.md"))
560
+
561
+ if !found.empty?
562
+ check(
563
+ category: "agent_registration", name: "skills_exist", status: "pass",
564
+ message: "#{found.size} plastic-* skill(s) installed in #{tilde(skills_root)}"
565
+ )
566
+ else
567
+ check(
568
+ category: "agent_registration", name: "skills_exist", status: "fail",
569
+ message: "No plastic-* skills found in #{tilde(skills_root)}",
570
+ fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest #{installer_flag}"
571
+ )
572
+ end
573
+ end
574
+
575
+ def check_generic_agent_registration(agent_key, agent_dir)
576
+ checks = []
577
+ config = agents[agent_key]
578
+
579
+ # For codex/hermes: just check skills exist (no settings.json hooks)
580
+ checks << flat_skills_check(agent_dir, "--#{agent_key}")
581
+
582
+ checks
583
+ end
584
+
585
+ # --- Check category 4: Core files ---
586
+
587
+ def check_core_files(agent_key)
588
+ checks = []
589
+
590
+ # plastic_md
591
+ plastic_md = File.join(plastic_home, "PLASTIC.md")
592
+ if File.exist?(plastic_md)
593
+ checks << check(
594
+ category: "core_files", name: "plastic_md", status: "pass",
595
+ message: "PLASTIC.md exists"
596
+ )
597
+ else
598
+ checks << check(
599
+ category: "core_files", name: "plastic_md", status: "fail",
600
+ message: "PLASTIC.md not found at #{tilde(plastic_md)}",
601
+ fixable: true, fix_hint: "Re-run the Plastic installer to restore core files"
602
+ )
603
+ end
604
+
605
+ # version_file
606
+ version_path = File.join(plastic_home, "VERSION")
607
+ if File.exist?(version_path)
608
+ checks << check(
609
+ category: "core_files", name: "version_file", status: "pass",
610
+ message: "VERSION file exists"
611
+ )
612
+ else
613
+ checks << check(
614
+ category: "core_files", name: "version_file", status: "fail",
615
+ message: "VERSION file not found at #{tilde(version_path)}",
616
+ fixable: true, fix_hint: "Re-run the Plastic installer to restore core files"
617
+ )
618
+ end
619
+
620
+ # scripts_present
621
+ scripts_dir = File.join(plastic_home, "scripts")
622
+ missing_scripts = REQUIRED_SCRIPTS.reject { |s| File.exist?(File.join(scripts_dir, s)) }
623
+
624
+ if missing_scripts.empty?
625
+ checks << check(
626
+ category: "core_files", name: "scripts_present", status: "pass",
627
+ message: "All #{REQUIRED_SCRIPTS.size} required scripts present"
628
+ )
629
+ else
630
+ checks << check(
631
+ category: "core_files", name: "scripts_present", status: "fail",
632
+ message: "#{missing_scripts.size} required script(s) missing from #{tilde(scripts_dir)}",
633
+ details: missing_scripts,
634
+ fixable: true, fix_hint: "Re-run the Plastic installer to restore scripts"
635
+ )
636
+ end
637
+
638
+ # scripts_executable
639
+ if File.directory?(scripts_dir)
640
+ script_files = Dir.children(scripts_dir)
641
+ .map { |f| File.join(scripts_dir, f) }
642
+ .select { |f| File.file?(f) }
643
+
644
+ non_executable = script_files.reject { |f| File.executable?(f) }
645
+
646
+ if non_executable.empty?
647
+ checks << check(
648
+ category: "core_files", name: "scripts_executable", status: "pass",
649
+ message: "All scripts in #{tilde(scripts_dir)} are executable"
650
+ )
651
+ else
652
+ checks << check(
653
+ category: "core_files", name: "scripts_executable", status: "fail",
654
+ message: "#{non_executable.size} script(s) not executable",
655
+ details: non_executable.map { |f| tilde(f) },
656
+ fixable: true, fix_hint: "chmod +x on the listed files"
657
+ )
658
+ end
659
+ end
660
+
661
+ # version_match — compare global VERSION with agent-side VERSION
662
+ global_version = read_version
663
+ agent_config = agents[agent_key]
664
+
665
+ if global_version && agent_config
666
+ agent_version_path = case agent_key
667
+ when "claude" then File.join(agent_config[:dir], "plastic", "VERSION")
668
+ else File.join(agent_config[:dir], "plastic", "VERSION")
669
+ end
670
+
671
+ if File.exist?(agent_version_path)
672
+ agent_version = File.read(agent_version_path).strip
673
+
674
+ if global_version == agent_version
675
+ checks << check(
676
+ category: "core_files", name: "version_match", status: "pass",
677
+ message: "Global VERSION (#{global_version}) matches agent-side VERSION"
678
+ )
679
+ else
680
+ checks << check(
681
+ category: "core_files", name: "version_match", status: "warn",
682
+ message: "Version mismatch: global=#{global_version}, agent=#{agent_version}",
683
+ details: [
684
+ "#{tilde(File.join(plastic_home, "VERSION"))}: #{global_version}",
685
+ "#{tilde(agent_version_path)}: #{agent_version}",
686
+ ],
687
+ fixable: false
688
+ )
689
+ end
690
+ else
691
+ checks << check(
692
+ category: "core_files", name: "version_match", status: "warn",
693
+ message: "Agent-side VERSION file not found at #{tilde(agent_version_path)}",
694
+ fixable: false
695
+ )
696
+ end
697
+ end
698
+
699
+ checks
700
+ end
701
+
702
+ # --- Check category: manifest sync (binary core integrity) ---
703
+
704
+ # Verify, for BOTH the global manifest and the agent-side manifest, that every
705
+ # file listed exists and its current SHA256 matches the recorded hash.
706
+ # - GLOBAL manifest: <plastic_home>/manifest.json
707
+ # - AGENT-side manifest: claude -> <dir>/plastic/manifest.json
708
+ # other -> <dir>/plastic-manifest.json
709
+ # Manifest format: { "version", "created", "files": { abs_path => sha256 } }.
710
+ # A missing manifest is a fail; any missing/mismatched listed file is a fail;
711
+ # otherwise a single pass per manifest.
712
+ def check_manifest_sync(agent_key)
713
+ checks = []
714
+
715
+ global_manifest = File.join(plastic_home, "manifest.json")
716
+ checks << verify_manifest(global_manifest, "global")
717
+
718
+ agent_dir = agents[agent_key][:dir]
719
+ agent_manifest = if agent_key == "claude"
720
+ File.join(agent_dir, "plastic", "manifest.json")
721
+ else
722
+ File.join(agent_dir, "plastic-manifest.json")
723
+ end
724
+ checks << verify_manifest(agent_manifest, "agent")
725
+
726
+ checks
727
+ end
728
+
729
+ # Check one manifest file. Returns a single check (pass or fail).
730
+ def verify_manifest(manifest_path, label)
731
+ unless File.exist?(manifest_path)
732
+ return check(
733
+ category: "manifest_sync", name: "#{label}_manifest", status: "fail",
734
+ message: "#{label} core manifest missing — re-run the Plastic installer",
735
+ details: [tilde(manifest_path)],
736
+ fixable: true, fix_hint: "Re-run the Plastic installer"
737
+ )
738
+ end
739
+
740
+ data = read_json_safe(manifest_path)
741
+ files = data.is_a?(Hash) ? data["files"] : nil
742
+ unless files.is_a?(Hash)
743
+ return check(
744
+ category: "manifest_sync", name: "#{label}_manifest", status: "fail",
745
+ message: "#{label} core manifest unreadable or malformed — re-run the Plastic installer",
746
+ details: [tilde(manifest_path)],
747
+ fixable: true, fix_hint: "Re-run the Plastic installer"
748
+ )
749
+ end
750
+
751
+ missing = []
752
+ mismatched = []
753
+ files.each do |path, recorded|
754
+ unless File.exist?(path)
755
+ missing << tilde(path)
756
+ next
757
+ end
758
+ actual = Digest::SHA256.file(path).hexdigest
759
+ mismatched << tilde(path) if actual != recorded
760
+ end
761
+
762
+ if missing.empty? && mismatched.empty?
763
+ check(
764
+ category: "manifest_sync", name: "#{label}_manifest", status: "pass",
765
+ message: "#{label} manifest: all #{files.size} tracked file(s) present and matching"
766
+ )
767
+ else
768
+ details = []
769
+ details += missing.map { |p| "missing: #{p}" }
770
+ details += mismatched.map { |p| "modified: #{p}" }
771
+ check(
772
+ category: "manifest_sync", name: "#{label}_manifest", status: "fail",
773
+ message: "#{label} manifest out of sync: #{missing.size} missing, #{mismatched.size} modified",
774
+ details: details,
775
+ fixable: true, fix_hint: "Re-run the Plastic installer to restore tracked files"
776
+ )
777
+ end
778
+ end
779
+
780
+ # --- Check category 5: Project stores ---
781
+
782
+ def check_project_stores
783
+ checks = []
784
+
785
+ projects_yml_path = File.join(plastic_home, "projects.yml")
786
+ projects_data = load_yaml_safe(projects_yml_path)
787
+
788
+ if projects_data.nil?
789
+ checks << check(
790
+ category: "project_stores", name: "projects_yml", status: "warn",
791
+ message: "projects.yml not found or invalid at #{tilde(projects_yml_path)}",
792
+ fixable: true, fix_hint: "Re-run the Plastic installer to restore projects.yml"
793
+ )
794
+ return checks
795
+ end
796
+
797
+ projects = projects_data["projects"]
798
+ unless projects.is_a?(Hash) && !projects.empty?
799
+ # No projects registered — nothing to check
800
+ checks << check(
801
+ category: "project_stores", name: "projects_yml", status: "pass",
802
+ message: "projects.yml is valid (#{projects.is_a?(Hash) ? projects.size : 0} projects registered)"
803
+ )
804
+ return checks
805
+ end
806
+
807
+ projects.each do |slug, project_info|
808
+ checks += check_project_store(slug, project_info)
809
+ end
810
+
811
+ checks
812
+ end
813
+
814
+ # Per-project validation extracted from check_project_stores so a single
815
+ # project can be checked in isolation (used by `--store <slug>`).
816
+ def check_project_store(slug, project_info)
817
+ checks = []
818
+ project_dir = File.join(plastic_home, "projects", slug)
819
+
820
+ # project_dir_exists
821
+ if File.directory?(project_dir)
822
+ checks << check(
823
+ category: "project_stores", name: "project_dir_exists", status: "pass",
824
+ message: "Project directory exists for '#{slug}'"
825
+ )
826
+ else
827
+ checks << check(
828
+ category: "project_stores", name: "project_dir_exists", status: "warn",
829
+ message: "Project directory missing for '#{slug}'",
830
+ details: [tilde(project_dir)],
831
+ fixable: true, fix_hint: "Create the project store directory: mkdir -p #{tilde(project_dir)}"
832
+ )
833
+ end
834
+
835
+ # project_store_dir
836
+ store_dir = File.join(project_dir, "store")
837
+ if File.directory?(store_dir)
838
+ checks << check(
839
+ category: "project_stores", name: "project_store_dir", status: "pass",
840
+ message: "Store directory exists for '#{slug}'"
841
+ )
842
+ else
843
+ checks << check(
844
+ category: "project_stores", name: "project_store_dir", status: "warn",
845
+ message: "Store directory missing for '#{slug}'",
846
+ details: [tilde(store_dir)],
847
+ fixable: true, fix_hint: "Run: provision-project-store #{slug}"
848
+ )
849
+ end
850
+
851
+ # project_index
852
+ project_index = File.join(project_dir, "INDEX.md")
853
+ if File.exist?(project_index)
854
+ checks << check(
855
+ category: "project_stores", name: "project_index", status: "pass",
856
+ message: "INDEX.md exists for project '#{slug}'"
857
+ )
858
+ else
859
+ checks << check(
860
+ category: "project_stores", name: "project_index", status: "warn",
861
+ message: "INDEX.md missing for project '#{slug}'",
862
+ details: [tilde(project_index)],
863
+ fixable: true, fix_hint: "Create INDEX.md in the project store directory"
864
+ )
865
+ end
866
+
867
+ # project_yml_exists
868
+ project_yml_path = File.join(plastic_home, "projects", slug, "project.yml")
869
+ project_yml_data = nil
870
+
871
+ if File.exist?(project_yml_path)
872
+ checks << check(
873
+ category: "project_stores", name: "project_yml_exists", status: "pass",
874
+ message: "project.yml exists for project '#{slug}'"
875
+ )
876
+ project_yml_data = load_yaml_safe(project_yml_path)
877
+ else
878
+ checks << check(
879
+ category: "project_stores", name: "project_yml_exists", status: "warn",
880
+ message: "project.yml missing for project '#{slug}'",
881
+ fixable: true, fix_hint: "Create project.yml from template — see plastic-creating-project"
882
+ )
883
+ end
884
+
885
+ # governing_docs_exist
886
+ if project_yml_data.is_a?(Hash) && project_yml_data["governing_docs"].is_a?(Array) && !project_yml_data["governing_docs"].empty?
887
+ project_path = project_info.is_a?(Hash) ? project_info["path"] : nil
888
+
889
+ if project_path
890
+ missing_docs = project_yml_data["governing_docs"].reject do |doc_path|
891
+ File.exist?(File.join(project_path, doc_path))
892
+ end
893
+
894
+ if missing_docs.empty?
895
+ checks << check(
896
+ category: "project_stores", name: "governing_docs_exist", status: "pass",
897
+ message: "All governing docs exist for project '#{slug}'"
898
+ )
899
+ else
900
+ checks << check(
901
+ category: "project_stores", name: "governing_docs_exist", status: "warn",
902
+ message: "#{missing_docs.size} governing doc(s) missing for project '#{slug}'",
903
+ details: missing_docs,
904
+ fixable: false
905
+ )
906
+ end
907
+ end
908
+ end
909
+
910
+ # cross_references — if project has `parent` field, check global store intent tags
911
+ parent_id = project_info.is_a?(Hash) ? project_info["parent"] : nil
912
+ return checks unless parent_id
913
+
914
+ # Find the intent directory for the parent ID
915
+ store_dir = File.join(plastic_home, "store")
916
+ parent_dir = nil
917
+ if File.directory?(store_dir)
918
+ parent_dir = Dir.children(store_dir).find { |d| d.start_with?("#{parent_id}--") }
919
+ end
920
+
921
+ if parent_dir.nil?
922
+ checks << check(
923
+ category: "project_stores", name: "cross_references", status: "warn",
924
+ message: "Parent intent '#{parent_id}' for project '#{slug}' not found in global store",
925
+ fixable: false
926
+ )
927
+ return checks
928
+ end
929
+
930
+ intent_md = File.join(store_dir, parent_dir, "#{parent_dir}.md")
931
+ fm = parse_frontmatter(intent_md)
932
+
933
+ if fm.nil?
934
+ checks << check(
935
+ category: "project_stores", name: "cross_references", status: "warn",
936
+ message: "Cannot read frontmatter of parent intent '#{parent_id}' for project '#{slug}'",
937
+ fixable: false
938
+ )
939
+ return checks
940
+ end
941
+
942
+ tags = fm["tags"]
943
+ expected_tag = "project-#{slug}"
944
+
945
+ if tags.is_a?(Array) && tags.include?(expected_tag)
946
+ checks << check(
947
+ category: "project_stores", name: "cross_references", status: "pass",
948
+ message: "Parent intent '#{parent_id}' has '#{expected_tag}' tag for project '#{slug}'"
949
+ )
950
+ else
951
+ checks << check(
952
+ category: "project_stores", name: "cross_references", status: "warn",
953
+ message: "Parent intent '#{parent_id}' missing '#{expected_tag}' tag",
954
+ details: ["Intent: store/#{parent_dir}", "Expected tag: #{expected_tag}", "Current tags: #{(tags || []).inspect}"],
955
+ fixable: false
956
+ )
957
+ end
958
+
959
+ checks
960
+ end
961
+
962
+ # --- Check category 6: Deprecations ---
963
+
964
+ def check_deprecations
965
+ checks = []
966
+
967
+ deprecations_path = File.join(plastic_home, "deprecations.yml")
968
+ data = load_yaml_safe(deprecations_path)
969
+
970
+ if data.nil?
971
+ checks << check(
972
+ category: "deprecations", name: "deprecations_file", status: "pass",
973
+ message: "No deprecations.yml found (nothing to report)"
974
+ )
975
+ return checks
976
+ end
977
+
978
+ entries = data["deprecations"]
979
+ unless entries.is_a?(Array) && !entries.empty?
980
+ checks << check(
981
+ category: "deprecations", name: "active_deprecations", status: "pass",
982
+ message: "No deprecation entries found"
983
+ )
984
+ return checks
985
+ end
986
+
987
+ current_version = read_version
988
+ unless current_version
989
+ checks << check(
990
+ category: "deprecations", name: "active_deprecations", status: "warn",
991
+ message: "Cannot compare deprecation versions — VERSION file missing",
992
+ fixable: false
993
+ )
994
+ return checks
995
+ end
996
+
997
+ # Find deprecations where removal version is greater than current version
998
+ # Use simple string comparison on semver (works for well-formed versions)
999
+ active = entries.select do |entry|
1000
+ removal = entry["removal"].to_s
1001
+ next false if removal.empty?
1002
+
1003
+ compare_versions(current_version, removal) < 0
1004
+ end
1005
+
1006
+ if active.empty?
1007
+ checks << check(
1008
+ category: "deprecations", name: "active_deprecations", status: "pass",
1009
+ message: "No active deprecations for current version (#{current_version})"
1010
+ )
1011
+ else
1012
+ details = active.map do |entry|
1013
+ lines = ["[#{entry["severity"]}] #{entry["summary"]} (removal: #{entry["removal"]})"]
1014
+ if entry["migration_steps"].is_a?(Array)
1015
+ entry["migration_steps"].each { |step| lines << " - #{step}" }
1016
+ end
1017
+ lines.join("\n")
1018
+ end
1019
+
1020
+ checks << check(
1021
+ category: "deprecations", name: "active_deprecations", status: "warn",
1022
+ message: "#{active.size} active deprecation(s) for version #{current_version}",
1023
+ details: details,
1024
+ fixable: false
1025
+ )
1026
+ end
1027
+
1028
+ checks
1029
+ end
1030
+
1031
+ # Compare two semver strings. Returns -1, 0, or 1.
1032
+ # Handles pre-release tags: 1.0.0-alpha.5 < 1.0.0 < 2.0.0
1033
+ def compare_versions(a, b)
1034
+ parse = ->(v) {
1035
+ base, pre = v.split("-", 2)
1036
+ segments = base.split(".").map(&:to_i)
1037
+ [segments, pre]
1038
+ }
1039
+
1040
+ a_segments, a_pre = parse.call(a)
1041
+ b_segments, b_pre = parse.call(b)
1042
+
1043
+ # Pad to equal length
1044
+ max_len = [a_segments.size, b_segments.size].max
1045
+ a_segments += [0] * (max_len - a_segments.size)
1046
+ b_segments += [0] * (max_len - b_segments.size)
1047
+
1048
+ cmp = (a_segments <=> b_segments)
1049
+ return cmp unless cmp == 0
1050
+
1051
+ # Same base version: no pre-release > pre-release (1.0.0 > 1.0.0-alpha)
1052
+ return 0 if a_pre.nil? && b_pre.nil?
1053
+ return 1 if a_pre.nil? && b_pre
1054
+ return -1 if a_pre && b_pre.nil?
1055
+
1056
+ # Both have pre-release: compare lexically
1057
+ a_pre <=> b_pre
1058
+ end
1059
+
1060
+ # --- Check category: QMD integration (read-only, optional) ---
1061
+ #
1062
+ # QMD is an optional integration. When `qmd` is not on PATH we emit a single
1063
+ # passing check and never fail — its absence is not a Plastic health problem.
1064
+ # When present, we report whether every Plastic store is registered as a QMD
1065
+ # collection. Everything here is read-only; we never invoke a mutating qmd
1066
+ # subcommand. detector/runner are injectable so tests stay hermetic.
1067
+ def check_qmd(detector: QmdSync.method(:detect), runner: QmdSync.default_runner)
1068
+ return [absent_qmd_check] unless detector.call
1069
+
1070
+ checks = [check(
1071
+ category: "qmd", name: "present", status: "pass",
1072
+ message: "QMD installed"
1073
+ )]
1074
+
1075
+ status = QmdSync.status(plastic_home: plastic_home, runner: runner, detector: detector)
1076
+ missing = status[:missing] || []
1077
+
1078
+ if status[:all_registered]
1079
+ checks << check(
1080
+ category: "qmd", name: "collections", status: "pass",
1081
+ message: "All #{status[:expected].size} Plastic store(s) registered as QMD collections"
1082
+ )
1083
+ else
1084
+ checks << check(
1085
+ category: "qmd", name: "collections", status: "warn",
1086
+ message: "#{missing.size} Plastic store(s) not registered as QMD collections",
1087
+ details: missing,
1088
+ fixable: true, fix_hint: "Run: qmd-sync register --all"
1089
+ )
1090
+ end
1091
+
1092
+ checks
1093
+ end
1094
+
1095
+ def absent_qmd_check
1096
+ check(
1097
+ category: "qmd", name: "present", status: "pass",
1098
+ message: "QMD not installed (optional integration)"
1099
+ )
1100
+ end
1101
+
1102
+ # --- Run all checks ---
1103
+
1104
+ def run_checks(agent_key)
1105
+ all_checks = []
1106
+ all_checks += check_global_store
1107
+ all_checks += check_conventions
1108
+ all_checks += check_agent_registration(agent_key)
1109
+ all_checks += check_core_files(agent_key)
1110
+ all_checks += check_project_stores
1111
+ all_checks += check_deprecations
1112
+ all_checks += check_qmd
1113
+
1114
+ summarize(all_checks, agent_key)
1115
+ end
1116
+
1117
+ # Binary core sync check: agent registration + core files + manifest sync,
1118
+ # rolled up with binary: true so ANY warn or fail makes the overall status
1119
+ # "fail" (and "warn" is never emitted). Used by `doctor.rb --core`.
1120
+ def run_core_checks(agent_key)
1121
+ all_checks = []
1122
+ all_checks += check_agent_registration(agent_key)
1123
+ all_checks += check_core_files(agent_key)
1124
+ all_checks += check_manifest_sync(agent_key)
1125
+
1126
+ summarize(all_checks, agent_key, binary: true)
1127
+ end
1128
+
1129
+ # Store-scoped checks for `doctor.rb --store [global|<slug>]`.
1130
+ # :all -> global store + all project stores + all conventions
1131
+ # :global -> global store + conventions scoped to ["global"]
1132
+ # "<slug>" -> that project only + conventions scoped to ["project:<slug>"]
1133
+ # (fail if the slug is not registered in projects.yml)
1134
+ # 3-state roll-up (pass/warn/fail), like the full run.
1135
+ def run_store_checks(store)
1136
+ all_checks =
1137
+ case store
1138
+ when :all
1139
+ check_global_store + check_project_stores + check_conventions
1140
+ when :global
1141
+ check_global_store + check_conventions(scopes: ["global"])
1142
+ else
1143
+ all_checks_for_project_slug(store)
1144
+ end
1145
+
1146
+ summarize(all_checks, "claude", binary: false)
1147
+ end
1148
+
1149
+ # Build the checks for a single project slug, or a lone fail check when the
1150
+ # slug is unknown.
1151
+ def all_checks_for_project_slug(slug)
1152
+ projects_data = load_yaml_safe(File.join(plastic_home, "projects.yml"))
1153
+ projects = projects_data.is_a?(Hash) ? projects_data["projects"] : nil
1154
+
1155
+ unless projects.is_a?(Hash) && projects.key?(slug)
1156
+ return [check(
1157
+ category: "project_stores", name: "unknown_project", status: "fail",
1158
+ message: "unknown project '#{slug}'",
1159
+ fixable: false
1160
+ )]
1161
+ end
1162
+
1163
+ check_project_store(slug, projects[slug]) +
1164
+ check_conventions(scopes: ["project:#{slug}"])
1165
+ end
1166
+
1167
+ # Roll a list of checks up into the standard result envelope.
1168
+ # When binary: true, the overall status is "pass" only if there are zero warn
1169
+ # AND zero fail; any warn or fail yields "fail" (never "warn"). When false
1170
+ # (the default) the classic 3-state pass/warn/fail roll-up is used.
1171
+ def summarize(all_checks, agent_key, binary: false)
1172
+ summary = { pass: 0, warn: 0, fail: 0, total: all_checks.size }
1173
+ all_checks.each { |c| summary[c[:status].to_sym] += 1 }
1174
+
1175
+ overall = if binary
1176
+ (summary[:fail] > 0 || summary[:warn] > 0) ? "fail" : "pass"
1177
+ elsif summary[:fail] > 0
1178
+ "fail"
1179
+ elsif summary[:warn] > 0
1180
+ "warn"
1181
+ else
1182
+ "pass"
1183
+ end
1184
+
1185
+ {
1186
+ version: read_version || "unknown",
1187
+ timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
1188
+ status: overall,
1189
+ agent: agent_key,
1190
+ checks: all_checks,
1191
+ summary: summary,
1192
+ }
1193
+ end
1194
+
1195
+ # --- Main ---
1196
+
1197
+ def cli(argv = ARGV)
1198
+ flags = parse_args(argv)
1199
+
1200
+ if flags[:help]
1201
+ show_help
1202
+ exit 0
1203
+ end
1204
+
1205
+ result =
1206
+ if !flags[:store].nil?
1207
+ run_store_checks(flags[:store])
1208
+ elsif flags[:core]
1209
+ run_core_checks(flags[:agent])
1210
+ else
1211
+ run_checks(flags[:agent])
1212
+ end
1213
+
1214
+ puts JSON.pretty_generate(result)
1215
+
1216
+ # --core is binary: status is only ever pass|fail, so this maps to 0|2.
1217
+ # --store and the full run keep the 3-state 0/1/2 mapping.
1218
+ case result[:status]
1219
+ when "fail" then exit 2
1220
+ when "warn" then exit 1
1221
+ else exit 0
1222
+ end
1223
+ end
1224
+
1225
+ end
1226
+
1227
+ Doctor.new.cli(ARGV) if $PROGRAM_NAME == __FILE__