@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/README.md +48 -6
- package/bin/plastic.js +57 -0
- package/bin/test +28 -0
- package/hooks/bash-gate +3 -0
- package/hooks/hooks.json +10 -0
- package/package.json +2 -2
- package/scripts/doctor.rb +741 -729
- package/scripts/hook-bash-gate +53 -0
- package/scripts/hook-session-start +42 -34
- package/scripts/install.rb +90 -695
- package/scripts/lib/bridge.rb +103 -0
- package/scripts/lib/installer_core.rb +760 -0
- package/scripts/uninstall.rb +53 -0
- package/scripts/update.rb +142 -0
- package/scripts/versions.rb +141 -0
- package/skills/brainstorming/SKILL.md +1 -1
- package/skills/executing-plan/SKILL.md +2 -2
- package/skills/install/SKILL.md +5 -5
- package/skills/uninstall/SKILL.md +2 -2
- package/skills/update/SKILL.md +35 -91
- package/skills/versions/SKILL.md +65 -0
- package/templates/outcome.md +13 -0
- package/templates/spec.md +25 -0
- package/bin/install.js +0 -29
package/scripts/doctor.rb
CHANGED
|
@@ -14,925 +14,937 @@ require "yaml"
|
|
|
14
14
|
require "time"
|
|
15
15
|
require "date"
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
88
|
+
def show_help
|
|
89
|
+
$stderr.puts <<~HELP
|
|
85
90
|
|
|
86
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
Exit 0 = all pass, 1 = warnings only, 2 = failures present.
|
|
93
|
+
Usage:
|
|
94
|
+
ruby ~/.plastic/scripts/doctor.rb [options]
|
|
93
95
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
+
Options:
|
|
97
|
+
--agent NAME Agent to check: claude (default), codex, hermes
|
|
98
|
+
-h, --help Show this help
|
|
96
99
|
|
|
97
|
-
|
|
100
|
+
Output:
|
|
101
|
+
JSON to stdout with check results.
|
|
102
|
+
Exit 0 = all pass, 1 = warnings only, 2 = failures present.
|
|
98
103
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
return nil unless File.exist?(version_path)
|
|
104
|
+
HELP
|
|
105
|
+
end
|
|
102
106
|
|
|
103
|
-
|
|
104
|
-
end
|
|
107
|
+
# --- Utility helpers ---
|
|
105
108
|
|
|
106
|
-
def
|
|
107
|
-
|
|
109
|
+
def read_version
|
|
110
|
+
version_path = File.join(plastic_home, "VERSION")
|
|
111
|
+
return nil unless File.exist?(version_path)
|
|
108
112
|
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
118
|
-
|
|
116
|
+
def read_json_safe(path)
|
|
117
|
+
return nil unless File.exist?(path)
|
|
119
118
|
|
|
120
|
-
|
|
121
|
-
rescue
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
127
|
-
|
|
128
|
-
end
|
|
127
|
+
def load_yaml_safe(path)
|
|
128
|
+
return nil unless File.exist?(path)
|
|
129
129
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
136
|
+
def tilde(path)
|
|
137
|
+
path.sub(Dir.home, "~")
|
|
138
|
+
end
|
|
144
139
|
|
|
145
|
-
def
|
|
146
|
-
|
|
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
|
-
|
|
149
|
-
return nil unless content.start_with?("---")
|
|
153
|
+
# --- Parse frontmatter from an intent markdown file ---
|
|
150
154
|
|
|
151
|
-
|
|
152
|
-
|
|
155
|
+
def parse_frontmatter(path)
|
|
156
|
+
return nil unless File.exist?(path)
|
|
153
157
|
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
161
|
+
parts = content.split("---", 3)
|
|
162
|
+
return nil if parts.length < 3
|
|
162
163
|
|
|
163
|
-
#
|
|
164
|
-
#
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
172
|
-
dirs = []
|
|
171
|
+
# --- Collect all intent directories (global + project stores) ---
|
|
173
172
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
215
|
+
# index_exists
|
|
216
|
+
if File.exist?(index_path)
|
|
245
217
|
checks << check(
|
|
246
|
-
category: "global_store", name: "
|
|
247
|
-
message: "
|
|
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: "
|
|
252
|
-
message: "
|
|
253
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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
|
-
#
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
348
|
-
bad_frontmatter << { dir: tilde(d[:path]), missing: missing } unless missing.empty?
|
|
293
|
+
checks
|
|
349
294
|
end
|
|
350
295
|
|
|
351
|
-
|
|
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
|
-
|
|
366
|
-
|
|
298
|
+
def check_conventions
|
|
299
|
+
checks = []
|
|
367
300
|
|
|
368
|
-
|
|
301
|
+
intent_dirs = all_intent_dirs
|
|
302
|
+
dirname_pattern = /^\w+--[\w-]+$/
|
|
369
303
|
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
392
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
.
|
|
418
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
|
361
|
+
if bad_frontmatter.empty?
|
|
459
362
|
checks << check(
|
|
460
|
-
category: "
|
|
461
|
-
message: "All
|
|
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: "
|
|
466
|
-
message: "#{
|
|
467
|
-
details:
|
|
468
|
-
fixable:
|
|
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
|
-
|
|
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
|
-
|
|
500
|
-
checks = []
|
|
501
|
-
config = AGENTS[agent_key]
|
|
378
|
+
# --- Check category 3: Agent registration ---
|
|
502
379
|
|
|
503
|
-
|
|
504
|
-
|
|
380
|
+
def check_agent_registration(agent_key)
|
|
381
|
+
checks = []
|
|
382
|
+
config = agents[agent_key]
|
|
383
|
+
agent_dir = config[:dir]
|
|
505
384
|
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
.
|
|
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 =
|
|
430
|
+
non_executable = existing_hooks.reject { |p| File.executable?(p) }
|
|
569
431
|
|
|
570
432
|
if non_executable.empty?
|
|
571
433
|
checks << check(
|
|
572
|
-
category: "
|
|
573
|
-
message: "All
|
|
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: "
|
|
578
|
-
message: "#{non_executable.size} script(s) not executable",
|
|
579
|
-
details: non_executable.map { |
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
|
596
|
-
|
|
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
|
|
468
|
+
if missing_events.empty?
|
|
599
469
|
checks << check(
|
|
600
|
-
category: "
|
|
601
|
-
message: "
|
|
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: "
|
|
606
|
-
message: "
|
|
607
|
-
details:
|
|
608
|
-
|
|
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
|
-
#
|
|
483
|
+
# skills_exist — flat, hyphen-namespaced personal skills (plastic-<name>/)
|
|
484
|
+
checks << flat_skills_check(agent_dir, "--claude")
|
|
627
485
|
|
|
628
|
-
|
|
629
|
-
|
|
486
|
+
checks
|
|
487
|
+
end
|
|
630
488
|
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
658
|
-
|
|
521
|
+
def check_core_files(agent_key)
|
|
522
|
+
checks = []
|
|
659
523
|
|
|
660
|
-
#
|
|
661
|
-
|
|
524
|
+
# plastic_md
|
|
525
|
+
plastic_md = File.join(plastic_home, "PLASTIC.md")
|
|
526
|
+
if File.exist?(plastic_md)
|
|
662
527
|
checks << check(
|
|
663
|
-
category: "
|
|
664
|
-
message: "
|
|
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: "
|
|
669
|
-
message: "
|
|
670
|
-
|
|
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
|
-
#
|
|
676
|
-
|
|
677
|
-
if File.exist?(
|
|
539
|
+
# version_file
|
|
540
|
+
version_path = File.join(plastic_home, "VERSION")
|
|
541
|
+
if File.exist?(version_path)
|
|
678
542
|
checks << check(
|
|
679
|
-
category: "
|
|
680
|
-
message: "
|
|
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: "
|
|
685
|
-
message: "
|
|
686
|
-
|
|
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
|
-
#
|
|
692
|
-
|
|
693
|
-
|
|
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
|
|
558
|
+
if missing_scripts.empty?
|
|
696
559
|
checks << check(
|
|
697
|
-
category: "
|
|
698
|
-
message: "
|
|
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: "
|
|
704
|
-
message: "
|
|
705
|
-
|
|
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
|
-
#
|
|
710
|
-
if
|
|
711
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
721
|
-
message: "
|
|
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: "
|
|
726
|
-
message: "
|
|
727
|
-
details:
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
next unless parent_id
|
|
633
|
+
checks
|
|
634
|
+
end
|
|
737
635
|
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
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: "
|
|
748
|
-
message: "
|
|
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
|
-
|
|
660
|
+
return checks
|
|
752
661
|
end
|
|
753
662
|
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
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: "
|
|
760
|
-
message: "
|
|
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
|
-
|
|
829
|
+
return checks
|
|
764
830
|
end
|
|
765
831
|
|
|
766
|
-
|
|
767
|
-
|
|
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
|
|
841
|
+
if active.empty?
|
|
770
842
|
checks << check(
|
|
771
|
-
category: "
|
|
772
|
-
message: "
|
|
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: "
|
|
777
|
-
message: "
|
|
778
|
-
details:
|
|
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
|
-
|
|
785
|
-
|
|
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
|
-
|
|
875
|
+
a_segments, a_pre = parse.call(a)
|
|
876
|
+
b_segments, b_pre = parse.call(b)
|
|
788
877
|
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
793
|
-
|
|
883
|
+
cmp = (a_segments <=> b_segments)
|
|
884
|
+
return cmp unless cmp == 0
|
|
794
885
|
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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
|
-
|
|
804
|
-
|
|
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
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
|
|
829
|
-
|
|
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
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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
|
-
|
|
854
|
-
end
|
|
927
|
+
# --- Main ---
|
|
855
928
|
|
|
856
|
-
|
|
857
|
-
|
|
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
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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
|
-
|
|
937
|
+
result = run_checks(flags[:agent])
|
|
918
938
|
|
|
919
|
-
|
|
920
|
-
flags = parse_args(ARGV)
|
|
939
|
+
puts JSON.pretty_generate(result)
|
|
921
940
|
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
exit
|
|
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
|
-
|
|
950
|
+
Doctor.new.cli(ARGV) if $PROGRAM_NAME == __FILE__
|