@zalom/plastic 1.0.0-alpha.5 → 1.0.0-alpha.6
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/package.json +1 -1
- package/scripts/doctor.rb +888 -0
- package/scripts/install.rb +1 -0
- package/skills/doctor/SKILL.md +112 -0
- package/skills/doctor/report.md +96 -0
- package/skills/update/SKILL.md +10 -2
package/package.json
CHANGED
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# Plastic doctor — diagnostic engine that checks a Plastic installation for health issues.
|
|
6
|
+
# Usage: ruby ~/.plastic/scripts/doctor.rb [--agent claude|codex|hermes] [--help]
|
|
7
|
+
#
|
|
8
|
+
# Output: JSON to stdout. Warnings/errors to stderr.
|
|
9
|
+
# Exit codes: 0 (all pass), 1 (warnings only), 2 (failures present)
|
|
10
|
+
# Read-only — never modifies files.
|
|
11
|
+
|
|
12
|
+
require "json"
|
|
13
|
+
require "yaml"
|
|
14
|
+
require "time"
|
|
15
|
+
|
|
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
|
|
62
|
+
else
|
|
63
|
+
$stderr.puts "Error: --agent requires one of: #{AGENTS.keys.join(", ")}"
|
|
64
|
+
exit 2
|
|
65
|
+
end
|
|
66
|
+
when "--help", "-h"
|
|
67
|
+
help = true
|
|
68
|
+
i += 1
|
|
69
|
+
else
|
|
70
|
+
i += 1
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
{ agent: agent, help: help }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def show_help
|
|
78
|
+
$stderr.puts <<~HELP
|
|
79
|
+
|
|
80
|
+
plastic doctor — diagnose Plastic installation health
|
|
81
|
+
|
|
82
|
+
Usage:
|
|
83
|
+
ruby ~/.plastic/scripts/doctor.rb [options]
|
|
84
|
+
|
|
85
|
+
Options:
|
|
86
|
+
--agent NAME Agent to check: claude (default), codex, hermes
|
|
87
|
+
-h, --help Show this help
|
|
88
|
+
|
|
89
|
+
Output:
|
|
90
|
+
JSON to stdout with check results.
|
|
91
|
+
Exit 0 = all pass, 1 = warnings only, 2 = failures present.
|
|
92
|
+
|
|
93
|
+
HELP
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# --- Utility helpers ---
|
|
97
|
+
|
|
98
|
+
def read_version
|
|
99
|
+
version_path = File.join(PLASTIC_HOME, "VERSION")
|
|
100
|
+
return nil unless File.exist?(version_path)
|
|
101
|
+
|
|
102
|
+
File.read(version_path).strip
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def read_json_safe(path)
|
|
106
|
+
return nil unless File.exist?(path)
|
|
107
|
+
|
|
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
|
|
115
|
+
|
|
116
|
+
def load_yaml_safe(path)
|
|
117
|
+
return nil unless File.exist?(path)
|
|
118
|
+
|
|
119
|
+
YAML.safe_load(File.read(path)) || {}
|
|
120
|
+
rescue => e
|
|
121
|
+
$stderr.puts "Warning: failed to parse #{path}: #{e.message}"
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def tilde(path)
|
|
126
|
+
path.sub(Dir.home, "~")
|
|
127
|
+
end
|
|
128
|
+
|
|
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
|
|
141
|
+
|
|
142
|
+
# --- Parse frontmatter from an intent markdown file ---
|
|
143
|
+
|
|
144
|
+
def parse_frontmatter(path)
|
|
145
|
+
return nil unless File.exist?(path)
|
|
146
|
+
|
|
147
|
+
content = File.read(path)
|
|
148
|
+
return nil unless content.start_with?("---")
|
|
149
|
+
|
|
150
|
+
parts = content.split("---", 3)
|
|
151
|
+
return nil if parts.length < 3
|
|
152
|
+
|
|
153
|
+
YAML.safe_load(parts[1]) || {}
|
|
154
|
+
rescue
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# --- Collect all intent directories (global + project stores) ---
|
|
159
|
+
|
|
160
|
+
def all_intent_dirs
|
|
161
|
+
dirs = []
|
|
162
|
+
|
|
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)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
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)
|
|
176
|
+
|
|
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)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
dirs
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# --- Check category 1: Global store ---
|
|
188
|
+
|
|
189
|
+
def check_global_store
|
|
190
|
+
checks = []
|
|
191
|
+
|
|
192
|
+
index_path = File.join(PLASTIC_HOME, "INDEX.md")
|
|
193
|
+
|
|
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
|
|
208
|
+
|
|
209
|
+
# index_sections
|
|
210
|
+
content = File.read(index_path)
|
|
211
|
+
missing_sections = REQUIRED_INDEX_SECTIONS.reject { |s| content.include?(s) }
|
|
212
|
+
|
|
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
|
|
226
|
+
|
|
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}") }
|
|
232
|
+
|
|
233
|
+
if orphans.empty?
|
|
234
|
+
checks << check(
|
|
235
|
+
category: "global_store", name: "orphaned_intents", status: "pass",
|
|
236
|
+
message: "No orphaned intent directories"
|
|
237
|
+
)
|
|
238
|
+
else
|
|
239
|
+
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"
|
|
244
|
+
)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
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
|
|
252
|
+
|
|
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
|
|
257
|
+
|
|
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
|
+
)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
checks
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# --- Check category 2: Conventions ---
|
|
276
|
+
|
|
277
|
+
def check_conventions
|
|
278
|
+
checks = []
|
|
279
|
+
|
|
280
|
+
intent_dirs = all_intent_dirs
|
|
281
|
+
dirname_pattern = /^\w+--[\w-]+$/
|
|
282
|
+
|
|
283
|
+
# intent_dirname
|
|
284
|
+
bad_dirnames = intent_dirs.reject { |d| d[:name].match?(dirname_pattern) }
|
|
285
|
+
|
|
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
|
|
299
|
+
|
|
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 }
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
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
|
|
323
|
+
|
|
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)
|
|
329
|
+
|
|
330
|
+
fm = parse_frontmatter(md_path)
|
|
331
|
+
if fm.nil?
|
|
332
|
+
bad_frontmatter << { dir: tilde(d[:path]), missing: ["(no frontmatter found)"] }
|
|
333
|
+
next
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
missing = REQUIRED_FRONTMATTER_FIELDS.reject { |f| fm.key?(f) }
|
|
337
|
+
bad_frontmatter << { dir: tilde(d[:path]), missing: missing } unless missing.empty?
|
|
338
|
+
end
|
|
339
|
+
|
|
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
|
|
353
|
+
|
|
354
|
+
checks
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# --- Check category 3: Agent registration ---
|
|
358
|
+
|
|
359
|
+
def check_agent_registration(agent_key)
|
|
360
|
+
checks = []
|
|
361
|
+
config = AGENTS[agent_key]
|
|
362
|
+
agent_dir = config[:dir]
|
|
363
|
+
|
|
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
|
|
371
|
+
end
|
|
372
|
+
|
|
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
|
|
379
|
+
|
|
380
|
+
checks
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def check_claude_registration(agent_dir)
|
|
384
|
+
checks = []
|
|
385
|
+
hooks_dir = File.join(agent_dir, "hooks")
|
|
386
|
+
|
|
387
|
+
# hooks_exist
|
|
388
|
+
missing_hooks = CLAUDE_HOOK_SCRIPTS.reject { |h| File.exist?(File.join(hooks_dir, h)) }
|
|
389
|
+
|
|
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
|
+
)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# hooks_executable
|
|
405
|
+
existing_hooks = CLAUDE_HOOK_SCRIPTS
|
|
406
|
+
.map { |h| File.join(hooks_dir, h) }
|
|
407
|
+
.select { |p| File.exist?(p) }
|
|
408
|
+
|
|
409
|
+
non_executable = existing_hooks.reject { |p| File.executable?(p) }
|
|
410
|
+
|
|
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
|
|
424
|
+
|
|
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)
|
|
428
|
+
|
|
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
|
|
446
|
+
|
|
447
|
+
if missing_events.empty?
|
|
448
|
+
checks << check(
|
|
449
|
+
category: "agent_registration", name: "hooks_registered", status: "pass",
|
|
450
|
+
message: "All #{CLAUDE_HOOK_EVENTS.size} hook events registered in settings.json"
|
|
451
|
+
)
|
|
452
|
+
else
|
|
453
|
+
checks << check(
|
|
454
|
+
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,
|
|
457
|
+
fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
|
|
458
|
+
)
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
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
|
|
476
|
+
|
|
477
|
+
checks
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def check_generic_agent_registration(agent_key, agent_dir)
|
|
481
|
+
checks = []
|
|
482
|
+
config = AGENTS[agent_key]
|
|
483
|
+
|
|
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
|
+
)
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
checks
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# --- Check category 4: Core files ---
|
|
503
|
+
|
|
504
|
+
def check_core_files(agent_key)
|
|
505
|
+
checks = []
|
|
506
|
+
|
|
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
|
+
)
|
|
520
|
+
end
|
|
521
|
+
|
|
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
|
|
536
|
+
|
|
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)) }
|
|
540
|
+
|
|
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
|
+
)
|
|
553
|
+
end
|
|
554
|
+
|
|
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) }
|
|
560
|
+
|
|
561
|
+
non_executable = script_files.reject { |f| File.executable?(f) }
|
|
562
|
+
|
|
563
|
+
if non_executable.empty?
|
|
564
|
+
checks << check(
|
|
565
|
+
category: "core_files", name: "scripts_executable", status: "pass",
|
|
566
|
+
message: "All scripts in #{tilde(scripts_dir)} are executable"
|
|
567
|
+
)
|
|
568
|
+
else
|
|
569
|
+
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"
|
|
574
|
+
)
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# version_match — compare global VERSION with agent-side VERSION
|
|
579
|
+
global_version = read_version
|
|
580
|
+
agent_config = AGENTS[agent_key]
|
|
581
|
+
|
|
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
|
|
587
|
+
|
|
588
|
+
if File.exist?(agent_version_path)
|
|
589
|
+
agent_version = File.read(agent_version_path).strip
|
|
590
|
+
|
|
591
|
+
if global_version == agent_version
|
|
592
|
+
checks << check(
|
|
593
|
+
category: "core_files", name: "version_match", status: "pass",
|
|
594
|
+
message: "Global VERSION (#{global_version}) matches agent-side VERSION"
|
|
595
|
+
)
|
|
596
|
+
else
|
|
597
|
+
checks << check(
|
|
598
|
+
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
|
+
],
|
|
604
|
+
fixable: false
|
|
605
|
+
)
|
|
606
|
+
end
|
|
607
|
+
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
|
|
612
|
+
)
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
checks
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# --- Check category 5: Project stores ---
|
|
620
|
+
|
|
621
|
+
def check_project_stores
|
|
622
|
+
checks = []
|
|
623
|
+
|
|
624
|
+
projects_yml_path = File.join(PLASTIC_HOME, "projects.yml")
|
|
625
|
+
projects_data = load_yaml_safe(projects_yml_path)
|
|
626
|
+
|
|
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
|
|
635
|
+
|
|
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
|
|
645
|
+
|
|
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) : ""
|
|
649
|
+
|
|
650
|
+
projects.each do |slug, project_info|
|
|
651
|
+
project_dir = File.join(PLASTIC_HOME, "projects", slug)
|
|
652
|
+
|
|
653
|
+
# project_dir_exists
|
|
654
|
+
if File.directory?(project_dir)
|
|
655
|
+
checks << check(
|
|
656
|
+
category: "project_stores", name: "project_dir_exists", status: "pass",
|
|
657
|
+
message: "Project directory exists for '#{slug}'"
|
|
658
|
+
)
|
|
659
|
+
else
|
|
660
|
+
checks << check(
|
|
661
|
+
category: "project_stores", name: "project_dir_exists", status: "warn",
|
|
662
|
+
message: "Project directory missing for '#{slug}'",
|
|
663
|
+
details: [tilde(project_dir)],
|
|
664
|
+
fixable: true, fix_hint: "Create the project store directory: mkdir -p #{tilde(project_dir)}"
|
|
665
|
+
)
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# project_index
|
|
669
|
+
project_index = File.join(project_dir, "INDEX.md")
|
|
670
|
+
if File.exist?(project_index)
|
|
671
|
+
checks << check(
|
|
672
|
+
category: "project_stores", name: "project_index", status: "pass",
|
|
673
|
+
message: "INDEX.md exists for project '#{slug}'"
|
|
674
|
+
)
|
|
675
|
+
else
|
|
676
|
+
checks << check(
|
|
677
|
+
category: "project_stores", name: "project_index", status: "warn",
|
|
678
|
+
message: "INDEX.md missing for project '#{slug}'",
|
|
679
|
+
details: [tilde(project_index)],
|
|
680
|
+
fixable: true, fix_hint: "Create INDEX.md in the project store directory"
|
|
681
|
+
)
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
# cross_references — if project has `parent` field, check global store intent tags
|
|
685
|
+
parent_id = project_info.is_a?(Hash) ? project_info["parent"] : nil
|
|
686
|
+
next unless parent_id
|
|
687
|
+
|
|
688
|
+
# Find the intent directory for the parent ID
|
|
689
|
+
store_dir = File.join(PLASTIC_HOME, "store")
|
|
690
|
+
parent_dir = nil
|
|
691
|
+
if File.directory?(store_dir)
|
|
692
|
+
parent_dir = Dir.children(store_dir).find { |d| d.start_with?("#{parent_id}--") }
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
if parent_dir.nil?
|
|
696
|
+
checks << check(
|
|
697
|
+
category: "project_stores", name: "cross_references", status: "warn",
|
|
698
|
+
message: "Parent intent '#{parent_id}' for project '#{slug}' not found in global store",
|
|
699
|
+
fixable: false
|
|
700
|
+
)
|
|
701
|
+
next
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
intent_md = File.join(store_dir, parent_dir, "#{parent_dir}.md")
|
|
705
|
+
fm = parse_frontmatter(intent_md)
|
|
706
|
+
|
|
707
|
+
if fm.nil?
|
|
708
|
+
checks << check(
|
|
709
|
+
category: "project_stores", name: "cross_references", status: "warn",
|
|
710
|
+
message: "Cannot read frontmatter of parent intent '#{parent_id}' for project '#{slug}'",
|
|
711
|
+
fixable: false
|
|
712
|
+
)
|
|
713
|
+
next
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
tags = fm["tags"]
|
|
717
|
+
expected_tag = "project-#{slug}"
|
|
718
|
+
|
|
719
|
+
if tags.is_a?(Array) && tags.include?(expected_tag)
|
|
720
|
+
checks << check(
|
|
721
|
+
category: "project_stores", name: "cross_references", status: "pass",
|
|
722
|
+
message: "Parent intent '#{parent_id}' has '#{expected_tag}' tag for project '#{slug}'"
|
|
723
|
+
)
|
|
724
|
+
else
|
|
725
|
+
checks << check(
|
|
726
|
+
category: "project_stores", name: "cross_references", status: "warn",
|
|
727
|
+
message: "Parent intent '#{parent_id}' missing '#{expected_tag}' tag",
|
|
728
|
+
details: ["Intent: store/#{parent_dir}", "Expected tag: #{expected_tag}", "Current tags: #{(tags || []).inspect}"],
|
|
729
|
+
fixable: false
|
|
730
|
+
)
|
|
731
|
+
end
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
checks
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
# --- Check category 6: Deprecations ---
|
|
738
|
+
|
|
739
|
+
def check_deprecations
|
|
740
|
+
checks = []
|
|
741
|
+
|
|
742
|
+
deprecations_path = File.join(PLASTIC_HOME, "deprecations.yml")
|
|
743
|
+
data = load_yaml_safe(deprecations_path)
|
|
744
|
+
|
|
745
|
+
if data.nil?
|
|
746
|
+
checks << check(
|
|
747
|
+
category: "deprecations", name: "deprecations_file", status: "pass",
|
|
748
|
+
message: "No deprecations.yml found (nothing to report)"
|
|
749
|
+
)
|
|
750
|
+
return checks
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
entries = data["deprecations"]
|
|
754
|
+
unless entries.is_a?(Array) && !entries.empty?
|
|
755
|
+
checks << check(
|
|
756
|
+
category: "deprecations", name: "active_deprecations", status: "pass",
|
|
757
|
+
message: "No deprecation entries found"
|
|
758
|
+
)
|
|
759
|
+
return checks
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
current_version = read_version
|
|
763
|
+
unless current_version
|
|
764
|
+
checks << check(
|
|
765
|
+
category: "deprecations", name: "active_deprecations", status: "warn",
|
|
766
|
+
message: "Cannot compare deprecation versions — VERSION file missing",
|
|
767
|
+
fixable: false
|
|
768
|
+
)
|
|
769
|
+
return checks
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
# Find deprecations where removal version is greater than current version
|
|
773
|
+
# Use simple string comparison on semver (works for well-formed versions)
|
|
774
|
+
active = entries.select do |entry|
|
|
775
|
+
removal = entry["removal"].to_s
|
|
776
|
+
next false if removal.empty?
|
|
777
|
+
|
|
778
|
+
compare_versions(current_version, removal) < 0
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
if active.empty?
|
|
782
|
+
checks << check(
|
|
783
|
+
category: "deprecations", name: "active_deprecations", status: "pass",
|
|
784
|
+
message: "No active deprecations for current version (#{current_version})"
|
|
785
|
+
)
|
|
786
|
+
else
|
|
787
|
+
details = active.map do |entry|
|
|
788
|
+
lines = ["[#{entry["severity"]}] #{entry["summary"]} (removal: #{entry["removal"]})"]
|
|
789
|
+
if entry["migration_steps"].is_a?(Array)
|
|
790
|
+
entry["migration_steps"].each { |step| lines << " - #{step}" }
|
|
791
|
+
end
|
|
792
|
+
lines.join("\n")
|
|
793
|
+
end
|
|
794
|
+
|
|
795
|
+
checks << check(
|
|
796
|
+
category: "deprecations", name: "active_deprecations", status: "warn",
|
|
797
|
+
message: "#{active.size} active deprecation(s) for version #{current_version}",
|
|
798
|
+
details: details,
|
|
799
|
+
fixable: false
|
|
800
|
+
)
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
checks
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
# Compare two semver strings. Returns -1, 0, or 1.
|
|
807
|
+
# Handles pre-release tags: 1.0.0-alpha.5 < 1.0.0 < 2.0.0
|
|
808
|
+
def compare_versions(a, b)
|
|
809
|
+
parse = ->(v) {
|
|
810
|
+
base, pre = v.split("-", 2)
|
|
811
|
+
segments = base.split(".").map(&:to_i)
|
|
812
|
+
[segments, pre]
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
a_segments, a_pre = parse.call(a)
|
|
816
|
+
b_segments, b_pre = parse.call(b)
|
|
817
|
+
|
|
818
|
+
# Pad to equal length
|
|
819
|
+
max_len = [a_segments.size, b_segments.size].max
|
|
820
|
+
a_segments += [0] * (max_len - a_segments.size)
|
|
821
|
+
b_segments += [0] * (max_len - b_segments.size)
|
|
822
|
+
|
|
823
|
+
cmp = (a_segments <=> b_segments)
|
|
824
|
+
return cmp unless cmp == 0
|
|
825
|
+
|
|
826
|
+
# Same base version: no pre-release > pre-release (1.0.0 > 1.0.0-alpha)
|
|
827
|
+
return 0 if a_pre.nil? && b_pre.nil?
|
|
828
|
+
return 1 if a_pre.nil? && b_pre
|
|
829
|
+
return -1 if a_pre && b_pre.nil?
|
|
830
|
+
|
|
831
|
+
# Both have pre-release: compare lexically
|
|
832
|
+
a_pre <=> b_pre
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
# --- Run all checks ---
|
|
836
|
+
|
|
837
|
+
def run_checks(agent_key)
|
|
838
|
+
all_checks = []
|
|
839
|
+
all_checks += check_global_store
|
|
840
|
+
all_checks += check_conventions
|
|
841
|
+
all_checks += check_agent_registration(agent_key)
|
|
842
|
+
all_checks += check_core_files(agent_key)
|
|
843
|
+
all_checks += check_project_stores
|
|
844
|
+
all_checks += check_deprecations
|
|
845
|
+
|
|
846
|
+
summary = { pass: 0, warn: 0, fail: 0, total: all_checks.size }
|
|
847
|
+
all_checks.each { |c| summary[c[:status].to_sym] += 1 }
|
|
848
|
+
|
|
849
|
+
overall = if summary[:fail] > 0
|
|
850
|
+
"fail"
|
|
851
|
+
elsif summary[:warn] > 0
|
|
852
|
+
"warn"
|
|
853
|
+
else
|
|
854
|
+
"pass"
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
{
|
|
858
|
+
version: read_version || "unknown",
|
|
859
|
+
timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
860
|
+
status: overall,
|
|
861
|
+
agent: agent_key,
|
|
862
|
+
checks: all_checks,
|
|
863
|
+
summary: summary,
|
|
864
|
+
}
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
# --- Main ---
|
|
868
|
+
|
|
869
|
+
def main
|
|
870
|
+
flags = parse_args(ARGV)
|
|
871
|
+
|
|
872
|
+
if flags[:help]
|
|
873
|
+
show_help
|
|
874
|
+
exit 0
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
result = run_checks(flags[:agent])
|
|
878
|
+
|
|
879
|
+
puts JSON.pretty_generate(result)
|
|
880
|
+
|
|
881
|
+
case result[:status]
|
|
882
|
+
when "fail" then exit 2
|
|
883
|
+
when "warn" then exit 1
|
|
884
|
+
else exit 0
|
|
885
|
+
end
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
main
|
package/scripts/install.rb
CHANGED
|
@@ -153,6 +153,7 @@ def distribute(mode)
|
|
|
153
153
|
"scripts/hook-future-intent-check" => "scripts/hook-future-intent-check",
|
|
154
154
|
"scripts/hook-gate-check" => "scripts/hook-gate-check",
|
|
155
155
|
"scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
|
|
156
|
+
"scripts/doctor.rb" => "scripts/doctor.rb",
|
|
156
157
|
}
|
|
157
158
|
|
|
158
159
|
core_files.each do |src, dest|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: doctor
|
|
3
|
+
description: Use when diagnosing Plastic installation health, after updates, or when something seems broken. Runs checks and reports findings with fix options.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Doctor — Plastic Health Check
|
|
7
|
+
|
|
8
|
+
## When to Use
|
|
9
|
+
|
|
10
|
+
- User invokes `/plastic:doctor`
|
|
11
|
+
- After `plastic:update` completes (automatically)
|
|
12
|
+
- When hooks aren't firing, skills aren't loading, or something seems broken
|
|
13
|
+
- When the user says "check plastic", "diagnose", "what's wrong with plastic"
|
|
14
|
+
|
|
15
|
+
## Procedure
|
|
16
|
+
|
|
17
|
+
### Step 1: Run the diagnostic script
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
ruby ~/.plastic/scripts/doctor.rb --agent claude
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Replace `claude` with the current agent type if known (`codex`, `hermes`).
|
|
24
|
+
|
|
25
|
+
Parse the JSON output from stdout. The script is read-only and never modifies
|
|
26
|
+
files. Errors go to stderr.
|
|
27
|
+
|
|
28
|
+
Exit codes indicate check results, not script failure:
|
|
29
|
+
- `0` — all checks passed
|
|
30
|
+
- `1` — warnings found
|
|
31
|
+
- `2` — failures found
|
|
32
|
+
|
|
33
|
+
All three exit codes mean the script ran successfully. Do not treat non-zero
|
|
34
|
+
as an error.
|
|
35
|
+
|
|
36
|
+
### Step 2: Determine overall status
|
|
37
|
+
|
|
38
|
+
Read the `status` field from the JSON root:
|
|
39
|
+
|
|
40
|
+
| Status | Meaning |
|
|
41
|
+
|--------|---------|
|
|
42
|
+
| `pass` | Everything is healthy |
|
|
43
|
+
| `warn` | Warnings found but Plastic works normally |
|
|
44
|
+
| `fail` | Blocking issues that prevent Plastic from operating |
|
|
45
|
+
|
|
46
|
+
### Step 3: Fill the report template
|
|
47
|
+
|
|
48
|
+
Read `report.md` from the same directory as this SKILL.md
|
|
49
|
+
(`~/.plastic/skills/doctor/report.md` at runtime, or the plugin source
|
|
50
|
+
`skills/doctor/report.md` during development).
|
|
51
|
+
|
|
52
|
+
Group checks by category. For each category, list the checks with their
|
|
53
|
+
status icon and message. If a check has `details`, list them as sub-items.
|
|
54
|
+
|
|
55
|
+
Present the filled template to the user.
|
|
56
|
+
|
|
57
|
+
### Step 4: Offer fixes (if applicable)
|
|
58
|
+
|
|
59
|
+
If any checks have `fixable: true` AND status is not `pass`:
|
|
60
|
+
|
|
61
|
+
1. Group fixable items by category
|
|
62
|
+
2. Show what each fix would do (from `fix_hint`)
|
|
63
|
+
3. Ask the user: **"Fix all / Select individually / Skip"**
|
|
64
|
+
|
|
65
|
+
If no fixable issues exist, skip this step.
|
|
66
|
+
|
|
67
|
+
### Step 5: Apply fixes
|
|
68
|
+
|
|
69
|
+
Use the `fix_hint` value to determine the correct action:
|
|
70
|
+
|
|
71
|
+
| Fix hint pattern | Agent action |
|
|
72
|
+
|---|---|
|
|
73
|
+
| "chmod +x on the listed files" | Run `chmod +x` on each file listed in `details` |
|
|
74
|
+
| "Create missing directory" | Run `mkdir -p` on the path |
|
|
75
|
+
| "Create INDEX.md with required sections" | Write INDEX.md with the 5 sections: Active, Future, Clusters, Abandoned, Completed |
|
|
76
|
+
| "Add missing entries to INDEX.md" | Add orphaned intents to the appropriate INDEX.md section |
|
|
77
|
+
| "Remove stale references from INDEX.md" | Edit INDEX.md to remove ghost references |
|
|
78
|
+
| "Re-run installer" | Run `npx @zalom/plastic@latest --agent` |
|
|
79
|
+
|
|
80
|
+
For fixes the agent cannot handle automatically, explain what the user needs
|
|
81
|
+
to do manually.
|
|
82
|
+
|
|
83
|
+
### Step 6: Verify
|
|
84
|
+
|
|
85
|
+
After applying fixes, re-run the diagnostic script:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
ruby ~/.plastic/scripts/doctor.rb --agent claude
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Show the updated results.
|
|
92
|
+
|
|
93
|
+
- If all checks pass: announce success.
|
|
94
|
+
- If issues remain: explain what is still wrong and what the user can do.
|
|
95
|
+
|
|
96
|
+
## Post-Update Mode
|
|
97
|
+
|
|
98
|
+
When invoked from `plastic:update` (not directly by the user):
|
|
99
|
+
|
|
100
|
+
1. Run the diagnostic script as in Step 1.
|
|
101
|
+
2. If all checks pass: show a single line — **"Health check: all clear."**
|
|
102
|
+
3. If issues are found: show the full report (Steps 3-6).
|
|
103
|
+
|
|
104
|
+
This keeps the update flow clean when nothing is wrong.
|
|
105
|
+
|
|
106
|
+
## Important Notes
|
|
107
|
+
|
|
108
|
+
- The script is **read-only**. It inspects but never modifies files.
|
|
109
|
+
All fixes are performed by the agent using standard tools.
|
|
110
|
+
- The script outputs JSON to stdout. Any diagnostic errors go to stderr.
|
|
111
|
+
- Non-zero exit codes mean "issues found", not "script crashed".
|
|
112
|
+
Always parse stdout regardless of exit code.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Plastic Doctor Report
|
|
2
|
+
|
|
3
|
+
<!-- =======================================================================
|
|
4
|
+
AGENT INSTRUCTIONS -- How to fill this template
|
|
5
|
+
=========================================================================
|
|
6
|
+
1. Run the doctor script. It outputs JSON with check results.
|
|
7
|
+
2. Replace every {{placeholder}} below with the corresponding JSON value.
|
|
8
|
+
3. For the category sections: the template shows ONE example section.
|
|
9
|
+
Repeat that pattern for each unique category in the checks array.
|
|
10
|
+
The six known categories and their display names are:
|
|
11
|
+
global_store -> "Global Store"
|
|
12
|
+
conventions -> "Conventions"
|
|
13
|
+
agent_registration -> "Agent Registration"
|
|
14
|
+
core_files -> "Core Files"
|
|
15
|
+
project_stores -> "Project Stores"
|
|
16
|
+
deprecations -> "Deprecations"
|
|
17
|
+
4. For each check within a category, emit one line with the status icon
|
|
18
|
+
and the check message. If the check has non-empty details, list them
|
|
19
|
+
as indented sub-items.
|
|
20
|
+
5. The "Fixable Issues" section should ONLY appear if at least one check
|
|
21
|
+
has fixable=true AND status is not "pass". Omit the entire section
|
|
22
|
+
otherwise.
|
|
23
|
+
6. Status icons (plain text, no emoji):
|
|
24
|
+
pass -> [PASS]
|
|
25
|
+
warn -> [WARN]
|
|
26
|
+
fail -> [FAIL]
|
|
27
|
+
7. The overall status icon in the header uses the same mapping.
|
|
28
|
+
8. After filling, remove all HTML comments -- they are instructions only.
|
|
29
|
+
======================================================================= -->
|
|
30
|
+
|
|
31
|
+
## {{overall_status_icon}} Overall: {{status}} -- Plastic v{{version}}
|
|
32
|
+
|
|
33
|
+
Checked at: {{timestamp}}
|
|
34
|
+
|
|
35
|
+
### Summary
|
|
36
|
+
|
|
37
|
+
{{pass}} passed, {{warn}} warnings, {{fail}} failed -- {{total}} checks total
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
<!-- =====================================================================
|
|
42
|
+
CATEGORY SECTIONS
|
|
43
|
+
======================================================================
|
|
44
|
+
Repeat the block below ONCE PER CATEGORY present in the checks array.
|
|
45
|
+
Group checks by their "category" field. Use the display name mapping
|
|
46
|
+
above for the heading. Within each category, list every check as a
|
|
47
|
+
single line: status icon + message. If a check has non-empty "details",
|
|
48
|
+
list each detail as an indented bullet beneath.
|
|
49
|
+
|
|
50
|
+
Example category section (for agent_registration with two checks):
|
|
51
|
+
===================================================================== -->
|
|
52
|
+
|
|
53
|
+
### Agent Registration
|
|
54
|
+
|
|
55
|
+
- [PASS] Claude Code adapter registered
|
|
56
|
+
- [FAIL] 2 hook scripts not executable
|
|
57
|
+
- ~/.claude/hooks/plastic-session-start
|
|
58
|
+
- ~/.claude/hooks/plastic-gate-check
|
|
59
|
+
|
|
60
|
+
<!-- =====================================================================
|
|
61
|
+
Repeat the above pattern for each category found in the checks array.
|
|
62
|
+
Only include categories that have at least one check.
|
|
63
|
+
Order categories as they appear in the checks array.
|
|
64
|
+
===================================================================== -->
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
<!-- =====================================================================
|
|
69
|
+
FIXABLE ISSUES SECTION
|
|
70
|
+
======================================================================
|
|
71
|
+
Include this section ONLY if one or more checks have fixable=true AND
|
|
72
|
+
status is "warn" or "fail". If no fixable issues exist, omit everything
|
|
73
|
+
from the "Fixable Issues" heading through the end of the horizontal
|
|
74
|
+
rule that follows the table.
|
|
75
|
+
|
|
76
|
+
For each fixable check that is not "pass", emit one table row:
|
|
77
|
+
| status_icon | check_message | fix_hint |
|
|
78
|
+
===================================================================== -->
|
|
79
|
+
|
|
80
|
+
### Fixable Issues
|
|
81
|
+
|
|
82
|
+
| Status | Issue | Fix |
|
|
83
|
+
|--------|-------|-----|
|
|
84
|
+
| [FAIL] | 2 hook scripts not executable | chmod +x on the listed files |
|
|
85
|
+
|
|
86
|
+
<!-- =====================================================================
|
|
87
|
+
Repeat one row per fixable non-pass check.
|
|
88
|
+
===================================================================== -->
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
<!-- =====================================================================
|
|
93
|
+
FOOTER -- always include this line exactly as written.
|
|
94
|
+
===================================================================== -->
|
|
95
|
+
|
|
96
|
+
Run `plastic doctor --fix` to auto-fix all fixable issues, or ask me to fix them now.
|
package/skills/update/SKILL.md
CHANGED
|
@@ -54,13 +54,21 @@ Key changes in this version:
|
|
|
54
54
|
Recommendation: run /clear for a clean session with all new conventions loaded.
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
### Step 4:
|
|
57
|
+
### Step 4: Run health check
|
|
58
|
+
|
|
59
|
+
Invoke `plastic:doctor` to verify the installation is healthy after the update.
|
|
60
|
+
|
|
61
|
+
If all checks pass, show: **"Health check: all clear."**
|
|
62
|
+
|
|
63
|
+
If issues are found, show the full doctor report and offer to fix any fixable items.
|
|
64
|
+
|
|
65
|
+
### Step 5: Commit
|
|
58
66
|
|
|
59
67
|
```bash
|
|
60
68
|
cd ~/.plastic && git add PLASTIC.md scripts/ AGENTS.md VERSION 2>/dev/null && git commit -m "chore: update Plastic core files" --allow-empty
|
|
61
69
|
```
|
|
62
70
|
|
|
63
|
-
### Step
|
|
71
|
+
### Step 6: Clear update cache
|
|
64
72
|
|
|
65
73
|
```bash
|
|
66
74
|
rm -f ~/.plastic/.cache/update-check.json
|