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