@zalom/plastic 1.0.0-alpha.8 → 1.0.0-beta.1

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