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