@zalom/plastic 1.0.0-alpha.18 → 1.0.0-alpha.19

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