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