@zalom/plastic 1.0.0-alpha.9 → 1.0.0-beta.2
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 +163 -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 +687 -0
- package/scripts/doctor.rb +1054 -628
- 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 -529
- 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 +235 -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 +226 -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 +74 -36
- package/skills/creating-intent/evals/evals.json +56 -0
- package/skills/creating-intent/references/lifecycle.md +76 -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 +16 -7
- package/skills/linking-intents/references/zettelkasten.md +38 -0
- package/skills/managing-index/SKILL.md +5 -1
- package/skills/managing-index/references/zettelkasten-linking.md +1 -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,940 @@
|
|
|
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
|
|
111
|
+
|
|
112
|
+
{ agent: agent, help: help, core: core, store: store }
|
|
72
113
|
end
|
|
73
114
|
|
|
74
|
-
|
|
75
|
-
|
|
115
|
+
def show_help
|
|
116
|
+
$stderr.puts <<~HELP
|
|
76
117
|
|
|
77
|
-
|
|
78
|
-
$stderr.puts <<~HELP
|
|
118
|
+
plastic doctor — diagnose Plastic installation health
|
|
79
119
|
|
|
80
|
-
|
|
120
|
+
Usage:
|
|
121
|
+
ruby ~/.plastic/scripts/doctor.rb [options]
|
|
81
122
|
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
84
132
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
133
|
+
Output:
|
|
134
|
+
JSON to stdout with check results.
|
|
135
|
+
Exit 0 = all pass, 1 = warnings only, 2 = failures present.
|
|
88
136
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
Exit 0 = all pass, 1 = warnings only, 2 = failures present.
|
|
137
|
+
HELP
|
|
138
|
+
end
|
|
92
139
|
|
|
93
|
-
|
|
94
|
-
end
|
|
140
|
+
# --- Utility helpers ---
|
|
95
141
|
|
|
96
|
-
|
|
142
|
+
def read_version
|
|
143
|
+
version_path = File.join(plastic_home, "VERSION")
|
|
144
|
+
return nil unless File.exist?(version_path)
|
|
97
145
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return nil unless File.exist?(version_path)
|
|
146
|
+
File.read(version_path).strip
|
|
147
|
+
end
|
|
101
148
|
|
|
102
|
-
|
|
103
|
-
|
|
149
|
+
def read_json_safe(path)
|
|
150
|
+
return nil unless File.exist?(path)
|
|
104
151
|
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
107
159
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
|
|
111
|
-
JSON.parse(content)
|
|
112
|
-
rescue
|
|
113
|
-
nil
|
|
114
|
-
end
|
|
160
|
+
def load_yaml_safe(path)
|
|
161
|
+
return nil unless File.exist?(path)
|
|
115
162
|
|
|
116
|
-
|
|
117
|
-
|
|
163
|
+
YAML.safe_load(File.read(path)) || {}
|
|
164
|
+
rescue => e
|
|
165
|
+
$stderr.puts "Warning: failed to parse #{path}: #{e.message}"
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
118
168
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
nil
|
|
123
|
-
end
|
|
169
|
+
def tilde(path)
|
|
170
|
+
path.sub(Dir.home, "~")
|
|
171
|
+
end
|
|
124
172
|
|
|
125
|
-
def
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
128
185
|
|
|
129
|
-
|
|
130
|
-
result = {
|
|
131
|
-
category: category,
|
|
132
|
-
name: name,
|
|
133
|
-
status: status,
|
|
134
|
-
message: message,
|
|
135
|
-
details: details,
|
|
136
|
-
fixable: fixable,
|
|
137
|
-
}
|
|
138
|
-
result[:fix_hint] = fix_hint if fix_hint
|
|
139
|
-
result
|
|
140
|
-
end
|
|
186
|
+
# --- Parse frontmatter from an intent markdown file ---
|
|
141
187
|
|
|
142
|
-
|
|
188
|
+
def parse_frontmatter(path)
|
|
189
|
+
return nil unless File.exist?(path)
|
|
143
190
|
|
|
144
|
-
|
|
145
|
-
|
|
191
|
+
content = File.read(path)
|
|
192
|
+
return nil unless content.start_with?("---")
|
|
146
193
|
|
|
147
|
-
|
|
148
|
-
|
|
194
|
+
parts = content.split("---", 3)
|
|
195
|
+
return nil if parts.length < 3
|
|
149
196
|
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
152
203
|
|
|
153
|
-
|
|
154
|
-
rescue
|
|
155
|
-
nil
|
|
156
|
-
end
|
|
204
|
+
# --- Collect all intent directories (global + project stores) ---
|
|
157
205
|
|
|
158
|
-
#
|
|
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))
|
|
211
|
+
end
|
|
212
|
+
end
|
|
159
213
|
|
|
160
|
-
def all_intent_dirs
|
|
161
|
-
|
|
214
|
+
def all_intent_dirs
|
|
215
|
+
dirs = []
|
|
162
216
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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" }
|
|
222
|
+
end
|
|
168
223
|
end
|
|
169
|
-
end
|
|
170
224
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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)
|
|
176
230
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
180
235
|
end
|
|
181
236
|
end
|
|
237
|
+
|
|
238
|
+
dirs
|
|
182
239
|
end
|
|
183
240
|
|
|
184
|
-
|
|
185
|
-
end
|
|
241
|
+
# --- Check category 1: Global store ---
|
|
186
242
|
|
|
187
|
-
|
|
243
|
+
def check_global_store
|
|
244
|
+
checks = []
|
|
188
245
|
|
|
189
|
-
|
|
190
|
-
checks = []
|
|
246
|
+
index_path = File.join(plastic_home, "INDEX.md")
|
|
191
247
|
|
|
192
|
-
|
|
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
|
|
193
262
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
263
|
+
# index_sections
|
|
264
|
+
content = File.read(index_path)
|
|
265
|
+
missing_sections = REQUIRED_INDEX_SECTIONS.reject { |s| content.include?(s) }
|
|
208
266
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
267
|
+
if missing_sections.empty?
|
|
268
|
+
checks << check(
|
|
269
|
+
category: "global_store", name: "index_sections", status: "pass",
|
|
270
|
+
message: "INDEX.md has all 5 required sections"
|
|
271
|
+
)
|
|
272
|
+
else
|
|
273
|
+
checks << check(
|
|
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"
|
|
278
|
+
)
|
|
279
|
+
end
|
|
212
280
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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}") }
|
|
286
|
+
|
|
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
|
|
301
|
+
|
|
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
|
|
226
306
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
orphans = intent_dirs.reject { |d| content.include?("store/#{d}") }
|
|
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
|
|
232
311
|
|
|
233
|
-
if
|
|
312
|
+
if ghosts.empty?
|
|
234
313
|
checks << check(
|
|
235
|
-
category: "global_store", name: "
|
|
236
|
-
message: "No
|
|
314
|
+
category: "global_store", name: "ghost_references", status: "pass",
|
|
315
|
+
message: "No ghost references in INDEX.md"
|
|
237
316
|
)
|
|
238
317
|
else
|
|
239
318
|
checks << check(
|
|
240
|
-
category: "global_store", name: "
|
|
241
|
-
message: "#{
|
|
242
|
-
details:
|
|
243
|
-
fixable: true, fix_hint: "
|
|
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"
|
|
244
323
|
)
|
|
245
324
|
end
|
|
325
|
+
|
|
326
|
+
checks
|
|
246
327
|
end
|
|
247
328
|
|
|
248
|
-
#
|
|
249
|
-
store_refs = content.scan(%r{store/[\w][\w-]*(?:/[\w][\w.-]*)*/?\b}).uniq
|
|
250
|
-
# Normalize: extract just the store/ID--slug portion
|
|
251
|
-
store_paths = content.scan(%r{store/\S+}).map { |ref| ref.gsub(/[)\]>].*/, "").chomp("/") }.uniq
|
|
329
|
+
# --- Check category 2: Conventions ---
|
|
252
330
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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 = []
|
|
257
336
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
message: "No ghost references in INDEX.md"
|
|
262
|
-
)
|
|
263
|
-
else
|
|
264
|
-
checks << check(
|
|
265
|
-
category: "global_store", name: "ghost_references", status: "warn",
|
|
266
|
-
message: "#{ghosts.size} path(s) in INDEX.md point to non-existent locations",
|
|
267
|
-
details: ghosts,
|
|
268
|
-
fixable: true, fix_hint: "Remove or fix broken references in INDEX.md"
|
|
269
|
-
)
|
|
270
|
-
end
|
|
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-]+$/
|
|
271
340
|
|
|
272
|
-
|
|
273
|
-
|
|
341
|
+
# intent_dirname
|
|
342
|
+
bad_dirnames = intent_dirs.reject { |d| d[:name].match?(dirname_pattern) }
|
|
274
343
|
|
|
275
|
-
|
|
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
|
|
276
357
|
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
279
367
|
|
|
280
|
-
|
|
281
|
-
|
|
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
|
+
)
|
|
380
|
+
end
|
|
282
381
|
|
|
283
|
-
|
|
284
|
-
|
|
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)
|
|
285
387
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
else
|
|
292
|
-
checks << check(
|
|
293
|
-
category: "conventions", name: "intent_dirname", status: "warn",
|
|
294
|
-
message: "#{bad_dirnames.size} intent director#{bad_dirnames.size == 1 ? "y doesn't" : "ies don't"} follow {ID}--{slug} format",
|
|
295
|
-
details: bad_dirnames.map { |d| "#{tilde(d[:path])} (#{d[:scope]})" },
|
|
296
|
-
fixable: true, fix_hint: "Rename directories to {ID}--{slug} format"
|
|
297
|
-
)
|
|
298
|
-
end
|
|
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
|
|
299
393
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
expected_file = "#{d[:name]}.md"
|
|
304
|
-
expected_path = File.join(d[:path], expected_file)
|
|
305
|
-
unless File.exist?(expected_path)
|
|
306
|
-
bad_filenames << { dir: d, expected: expected_file }
|
|
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?
|
|
307
397
|
end
|
|
308
|
-
end
|
|
309
398
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|
323
413
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
|
329
427
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
+
)
|
|
334
440
|
end
|
|
335
441
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
339
461
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
|
353
475
|
|
|
354
|
-
|
|
355
|
-
|
|
476
|
+
# graph_invariants — cross-intent I1/I3/I4 checks (intent 68). I1/I3/I4 are
|
|
477
|
+
# defined within a single store's id space (bare ids resolve within the same
|
|
478
|
+
# store), so build the `nodes` map per scope and run validate_graph per scope.
|
|
479
|
+
# I2 asymmetry (a relational chain entry with no reciprocal sources) is NEVER
|
|
480
|
+
# flagged: validate_graph does not compute it.
|
|
481
|
+
checks.concat(graph_invariant_checks(intent_dirs))
|
|
482
|
+
|
|
483
|
+
checks
|
|
484
|
+
end
|
|
356
485
|
|
|
357
|
-
#
|
|
486
|
+
# Build a per-scope `nodes` map and surface IntentValidator.validate_graph
|
|
487
|
+
# findings as warn-level checks. Scope-aware (the caller already filtered
|
|
488
|
+
# `intent_dirs` by scope), so a `global` id is not falsely flagged as a dangler
|
|
489
|
+
# when only a `project:` store is loaded, and vice versa.
|
|
490
|
+
def graph_invariant_checks(intent_dirs)
|
|
491
|
+
nodes_by_scope = Hash.new { |h, k| h[k] = {} }
|
|
492
|
+
intent_dirs.each do |d|
|
|
493
|
+
md_path = File.join(d[:path], "#{d[:name]}.md")
|
|
494
|
+
next unless File.exist?(md_path)
|
|
495
|
+
|
|
496
|
+
fm = parse_frontmatter(md_path)
|
|
497
|
+
next unless fm.is_a?(Hash) && fm["id"]
|
|
498
|
+
|
|
499
|
+
nodes_by_scope[d[:scope]][fm["id"].to_s] = {
|
|
500
|
+
sources: Array(fm["sources"]).map(&:to_s),
|
|
501
|
+
chain: Array(fm["chain"]).map(&:to_s),
|
|
502
|
+
}
|
|
503
|
+
end
|
|
358
504
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
505
|
+
i1 = []
|
|
506
|
+
i3 = []
|
|
507
|
+
i4 = []
|
|
508
|
+
nodes_by_scope.each_value do |nodes|
|
|
509
|
+
findings = IntentValidator.validate_graph(nodes)
|
|
510
|
+
i1.concat(findings[:i1])
|
|
511
|
+
i3.concat(findings[:i3])
|
|
512
|
+
i4.concat(findings[:i4])
|
|
513
|
+
end
|
|
363
514
|
|
|
364
|
-
|
|
365
|
-
checks <<
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
515
|
+
checks = []
|
|
516
|
+
checks << graph_finding_check(
|
|
517
|
+
"graph_i1_reciprocity", i1,
|
|
518
|
+
"Every sources edge has its reciprocal chain entry (I1)",
|
|
519
|
+
"Run new-intent / the rebuild so each source intent's chain backlinks the child"
|
|
520
|
+
)
|
|
521
|
+
checks << graph_finding_check(
|
|
522
|
+
"graph_i3_disjoint", i3,
|
|
523
|
+
"No intent lists the same id in both sources and chain (I3)",
|
|
524
|
+
"Remove the overlapping id from either sources or chain"
|
|
369
525
|
)
|
|
370
|
-
|
|
526
|
+
checks << graph_finding_check(
|
|
527
|
+
"graph_i4_danglers", i4,
|
|
528
|
+
"Every sources/chain id resolves to a real intent (I4)",
|
|
529
|
+
"Fix or remove the dangling id reference"
|
|
530
|
+
)
|
|
531
|
+
checks
|
|
371
532
|
end
|
|
372
533
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
534
|
+
# One graph check: pass when `findings` is empty, otherwise warn (never fail, so
|
|
535
|
+
# an existing store does not turn red on a graph finding). I1/I4 are auto-fixable.
|
|
536
|
+
def graph_finding_check(name, findings, pass_message, fix_hint)
|
|
537
|
+
if findings.empty?
|
|
538
|
+
check(category: "conventions", name: name, status: "pass", message: pass_message)
|
|
539
|
+
else
|
|
540
|
+
check(
|
|
541
|
+
category: "conventions", name: name, status: "warn",
|
|
542
|
+
message: "#{findings.size} #{name} violation(s)",
|
|
543
|
+
details: findings,
|
|
544
|
+
fixable: name != "graph_i3_disjoint",
|
|
545
|
+
fix_hint: fix_hint
|
|
546
|
+
)
|
|
547
|
+
end
|
|
378
548
|
end
|
|
379
549
|
|
|
380
|
-
|
|
381
|
-
end
|
|
550
|
+
# --- Check category 3: Agent registration ---
|
|
382
551
|
|
|
383
|
-
def
|
|
384
|
-
|
|
385
|
-
|
|
552
|
+
def check_agent_registration(agent_key)
|
|
553
|
+
checks = []
|
|
554
|
+
config = agents[agent_key]
|
|
555
|
+
agent_dir = config[:dir]
|
|
386
556
|
|
|
387
|
-
|
|
388
|
-
|
|
557
|
+
unless File.directory?(agent_dir)
|
|
558
|
+
checks << check(
|
|
559
|
+
category: "agent_registration", name: "agent_dir_exists", status: "fail",
|
|
560
|
+
message: "Agent directory #{tilde(agent_dir)} not found — #{config[:name]} may not be installed",
|
|
561
|
+
fixable: false
|
|
562
|
+
)
|
|
563
|
+
return checks
|
|
564
|
+
end
|
|
389
565
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
)
|
|
566
|
+
case agent_key
|
|
567
|
+
when "claude"
|
|
568
|
+
checks += check_claude_registration(agent_dir)
|
|
569
|
+
else
|
|
570
|
+
checks += check_generic_agent_registration(agent_key, agent_dir)
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
checks
|
|
402
574
|
end
|
|
403
575
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
.select { |p| File.exist?(p) }
|
|
576
|
+
def check_claude_registration(agent_dir)
|
|
577
|
+
checks = []
|
|
578
|
+
hooks_dir = File.join(agent_dir, "hooks")
|
|
408
579
|
|
|
409
|
-
|
|
580
|
+
# hooks_exist
|
|
581
|
+
missing_hooks = CLAUDE_HOOK_SCRIPTS.reject { |h| File.exist?(File.join(hooks_dir, h)) }
|
|
410
582
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
583
|
+
if missing_hooks.empty?
|
|
584
|
+
checks << check(
|
|
585
|
+
category: "agent_registration", name: "hooks_exist", status: "pass",
|
|
586
|
+
message: "All #{CLAUDE_HOOK_SCRIPTS.size} expected hook scripts exist"
|
|
587
|
+
)
|
|
588
|
+
else
|
|
589
|
+
checks << check(
|
|
590
|
+
category: "agent_registration", name: "hooks_exist", status: "fail",
|
|
591
|
+
message: "#{missing_hooks.size} hook script(s) missing",
|
|
592
|
+
details: missing_hooks.map { |h| "#{tilde(hooks_dir)}/#{h}" },
|
|
593
|
+
fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
|
|
594
|
+
)
|
|
595
|
+
end
|
|
424
596
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
597
|
+
# hooks_executable
|
|
598
|
+
existing_hooks = CLAUDE_HOOK_SCRIPTS
|
|
599
|
+
.map { |h| File.join(hooks_dir, h) }
|
|
600
|
+
.select { |p| File.exist?(p) }
|
|
428
601
|
|
|
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
|
|
602
|
+
non_executable = existing_hooks.reject { |p| File.executable?(p) }
|
|
446
603
|
|
|
447
|
-
if
|
|
604
|
+
if non_executable.empty?
|
|
448
605
|
checks << check(
|
|
449
|
-
category: "agent_registration", name: "
|
|
450
|
-
message: "All
|
|
606
|
+
category: "agent_registration", name: "hooks_executable", status: "pass",
|
|
607
|
+
message: "All existing hook scripts are executable"
|
|
451
608
|
)
|
|
452
609
|
else
|
|
610
|
+
checks << check(
|
|
611
|
+
category: "agent_registration", name: "hooks_executable", status: "fail",
|
|
612
|
+
message: "#{non_executable.size} hook script(s) not executable",
|
|
613
|
+
details: non_executable.map { |p| tilde(p) },
|
|
614
|
+
fixable: true, fix_hint: "chmod +x on the listed files"
|
|
615
|
+
)
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
# hooks_registered — settings.json has Plastic hooks for required events
|
|
619
|
+
settings_path = File.join(agent_dir, "settings.json")
|
|
620
|
+
settings = read_json_safe(settings_path)
|
|
621
|
+
|
|
622
|
+
if settings.nil?
|
|
453
623
|
checks << check(
|
|
454
624
|
category: "agent_registration", name: "hooks_registered", status: "fail",
|
|
455
|
-
message: "#{
|
|
456
|
-
details: missing_events,
|
|
625
|
+
message: "Cannot read #{tilde(settings_path)} — file missing or invalid",
|
|
457
626
|
fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
|
|
458
627
|
)
|
|
459
|
-
|
|
460
|
-
|
|
628
|
+
else
|
|
629
|
+
hooks = settings["hooks"] || {}
|
|
630
|
+
missing_events = CLAUDE_HOOK_EVENTS.reject do |event|
|
|
631
|
+
groups = hooks[event]
|
|
632
|
+
next false unless groups.is_a?(Array)
|
|
633
|
+
|
|
634
|
+
groups.any? do |group|
|
|
635
|
+
group.is_a?(Hash) && group["hooks"].is_a?(Array) &&
|
|
636
|
+
group["hooks"].any? { |h| h["command"].to_s.include?("plastic-") }
|
|
637
|
+
end
|
|
638
|
+
end
|
|
461
639
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
640
|
+
if missing_events.empty?
|
|
641
|
+
checks << check(
|
|
642
|
+
category: "agent_registration", name: "hooks_registered", status: "pass",
|
|
643
|
+
message: "All #{CLAUDE_HOOK_EVENTS.size} hook events registered in settings.json"
|
|
644
|
+
)
|
|
645
|
+
else
|
|
646
|
+
checks << check(
|
|
647
|
+
category: "agent_registration", name: "hooks_registered", status: "fail",
|
|
648
|
+
message: "#{missing_events.size} hook event(s) not registered in settings.json",
|
|
649
|
+
details: missing_events,
|
|
650
|
+
fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
|
|
651
|
+
)
|
|
652
|
+
end
|
|
653
|
+
end
|
|
476
654
|
|
|
477
|
-
|
|
478
|
-
|
|
655
|
+
# skills_exist — flat, hyphen-namespaced personal skills (plastic-<name>/)
|
|
656
|
+
checks << flat_skills_check(agent_dir, "--claude")
|
|
479
657
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
config = AGENTS[agent_key]
|
|
658
|
+
# agents_exist — auto-mode role files (plastic-*.md) synced into <dir>/agents
|
|
659
|
+
checks << flat_agents_check(agent_dir, "--claude")
|
|
483
660
|
|
|
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
|
-
)
|
|
661
|
+
checks
|
|
497
662
|
end
|
|
498
663
|
|
|
499
|
-
|
|
500
|
-
|
|
664
|
+
# Plastic skills install as ~/.claude/skills/plastic-<name>/SKILL.md. Pass if at
|
|
665
|
+
# least one such skill is present.
|
|
666
|
+
def flat_skills_check(agent_dir, installer_flag)
|
|
667
|
+
skills_root = File.join(agent_dir, "skills")
|
|
668
|
+
found = Dir.glob(File.join(skills_root, "plastic-*", "SKILL.md"))
|
|
501
669
|
|
|
502
|
-
|
|
670
|
+
if !found.empty?
|
|
671
|
+
check(
|
|
672
|
+
category: "agent_registration", name: "skills_exist", status: "pass",
|
|
673
|
+
message: "#{found.size} plastic-* skill(s) installed in #{tilde(skills_root)}"
|
|
674
|
+
)
|
|
675
|
+
else
|
|
676
|
+
check(
|
|
677
|
+
category: "agent_registration", name: "skills_exist", status: "fail",
|
|
678
|
+
message: "No plastic-* skills found in #{tilde(skills_root)}",
|
|
679
|
+
fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest #{installer_flag}"
|
|
680
|
+
)
|
|
681
|
+
end
|
|
682
|
+
end
|
|
503
683
|
|
|
504
|
-
|
|
505
|
-
|
|
684
|
+
# Auto-mode role agents install as <dir>/agents/plastic-*.md. Pass if at least
|
|
685
|
+
# one such role file is present (the installer syncs agents/ on every install).
|
|
686
|
+
def flat_agents_check(agent_dir, installer_flag)
|
|
687
|
+
agents_root = File.join(agent_dir, "agents")
|
|
688
|
+
found = Dir.glob(File.join(agents_root, "plastic-*.md"))
|
|
506
689
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
)
|
|
690
|
+
if !found.empty?
|
|
691
|
+
check(
|
|
692
|
+
category: "agent_registration", name: "agents_exist", status: "pass",
|
|
693
|
+
message: "#{found.size} plastic-* agent(s) installed in #{tilde(agents_root)}"
|
|
694
|
+
)
|
|
695
|
+
else
|
|
696
|
+
check(
|
|
697
|
+
category: "agent_registration", name: "agents_exist", status: "fail",
|
|
698
|
+
message: "No plastic-* agents found in #{tilde(agents_root)}",
|
|
699
|
+
fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest #{installer_flag}"
|
|
700
|
+
)
|
|
701
|
+
end
|
|
520
702
|
end
|
|
521
703
|
|
|
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
|
|
704
|
+
def check_generic_agent_registration(agent_key, agent_dir)
|
|
705
|
+
checks = []
|
|
706
|
+
config = agents[agent_key]
|
|
536
707
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
708
|
+
# For codex/hermes: just check skills exist (no settings.json hooks)
|
|
709
|
+
checks << flat_skills_check(agent_dir, "--#{agent_key}")
|
|
710
|
+
checks << flat_agents_check(agent_dir, "--#{agent_key}")
|
|
540
711
|
|
|
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
|
-
)
|
|
712
|
+
checks
|
|
553
713
|
end
|
|
554
714
|
|
|
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) }
|
|
715
|
+
# --- Check category 4: Core files ---
|
|
560
716
|
|
|
561
|
-
|
|
717
|
+
def check_core_files(agent_key)
|
|
718
|
+
checks = []
|
|
562
719
|
|
|
563
|
-
|
|
720
|
+
# plastic_md
|
|
721
|
+
plastic_md = File.join(plastic_home, "PLASTIC.md")
|
|
722
|
+
if File.exist?(plastic_md)
|
|
564
723
|
checks << check(
|
|
565
|
-
category: "core_files", name: "
|
|
566
|
-
message: "
|
|
724
|
+
category: "core_files", name: "plastic_md", status: "pass",
|
|
725
|
+
message: "PLASTIC.md exists"
|
|
567
726
|
)
|
|
568
727
|
else
|
|
569
728
|
checks << check(
|
|
570
|
-
category: "core_files", name: "
|
|
571
|
-
message: "#{
|
|
572
|
-
|
|
573
|
-
fixable: true, fix_hint: "chmod +x on the listed files"
|
|
729
|
+
category: "core_files", name: "plastic_md", status: "fail",
|
|
730
|
+
message: "PLASTIC.md not found at #{tilde(plastic_md)}",
|
|
731
|
+
fixable: true, fix_hint: "Re-run the Plastic installer to restore core files"
|
|
574
732
|
)
|
|
575
733
|
end
|
|
576
|
-
end
|
|
577
734
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
735
|
+
# version_file
|
|
736
|
+
version_path = File.join(plastic_home, "VERSION")
|
|
737
|
+
if File.exist?(version_path)
|
|
738
|
+
checks << check(
|
|
739
|
+
category: "core_files", name: "version_file", status: "pass",
|
|
740
|
+
message: "VERSION file exists"
|
|
741
|
+
)
|
|
742
|
+
else
|
|
743
|
+
checks << check(
|
|
744
|
+
category: "core_files", name: "version_file", status: "fail",
|
|
745
|
+
message: "VERSION file not found at #{tilde(version_path)}",
|
|
746
|
+
fixable: true, fix_hint: "Re-run the Plastic installer to restore core files"
|
|
747
|
+
)
|
|
748
|
+
end
|
|
581
749
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
else File.join(agent_config[:dir], "plastic", "VERSION")
|
|
586
|
-
end
|
|
750
|
+
# scripts_present
|
|
751
|
+
scripts_dir = File.join(plastic_home, "scripts")
|
|
752
|
+
missing_scripts = REQUIRED_SCRIPTS.reject { |s| File.exist?(File.join(scripts_dir, s)) }
|
|
587
753
|
|
|
588
|
-
if
|
|
589
|
-
|
|
754
|
+
if missing_scripts.empty?
|
|
755
|
+
checks << check(
|
|
756
|
+
category: "core_files", name: "scripts_present", status: "pass",
|
|
757
|
+
message: "All #{REQUIRED_SCRIPTS.size} required scripts present"
|
|
758
|
+
)
|
|
759
|
+
else
|
|
760
|
+
checks << check(
|
|
761
|
+
category: "core_files", name: "scripts_present", status: "fail",
|
|
762
|
+
message: "#{missing_scripts.size} required script(s) missing from #{tilde(scripts_dir)}",
|
|
763
|
+
details: missing_scripts,
|
|
764
|
+
fixable: true, fix_hint: "Re-run the Plastic installer to restore scripts"
|
|
765
|
+
)
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
# scripts_executable
|
|
769
|
+
if File.directory?(scripts_dir)
|
|
770
|
+
script_files = Dir.children(scripts_dir)
|
|
771
|
+
.map { |f| File.join(scripts_dir, f) }
|
|
772
|
+
.select { |f| File.file?(f) }
|
|
773
|
+
|
|
774
|
+
non_executable = script_files.reject { |f| File.executable?(f) }
|
|
590
775
|
|
|
591
|
-
if
|
|
776
|
+
if non_executable.empty?
|
|
777
|
+
checks << check(
|
|
778
|
+
category: "core_files", name: "scripts_executable", status: "pass",
|
|
779
|
+
message: "All scripts in #{tilde(scripts_dir)} are executable"
|
|
780
|
+
)
|
|
781
|
+
else
|
|
592
782
|
checks << check(
|
|
593
|
-
category: "core_files", name: "
|
|
594
|
-
message: "
|
|
783
|
+
category: "core_files", name: "scripts_executable", status: "fail",
|
|
784
|
+
message: "#{non_executable.size} script(s) not executable",
|
|
785
|
+
details: non_executable.map { |f| tilde(f) },
|
|
786
|
+
fixable: true, fix_hint: "chmod +x on the listed files"
|
|
595
787
|
)
|
|
788
|
+
end
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
# version_match — compare global VERSION with agent-side VERSION
|
|
792
|
+
global_version = read_version
|
|
793
|
+
agent_config = agents[agent_key]
|
|
794
|
+
|
|
795
|
+
if global_version && agent_config
|
|
796
|
+
agent_version_path = case agent_key
|
|
797
|
+
when "claude" then File.join(agent_config[:dir], "plastic", "VERSION")
|
|
798
|
+
else File.join(agent_config[:dir], "plastic", "VERSION")
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
if File.exist?(agent_version_path)
|
|
802
|
+
agent_version = File.read(agent_version_path).strip
|
|
803
|
+
|
|
804
|
+
if global_version == agent_version
|
|
805
|
+
checks << check(
|
|
806
|
+
category: "core_files", name: "version_match", status: "pass",
|
|
807
|
+
message: "Global VERSION (#{global_version}) matches agent-side VERSION"
|
|
808
|
+
)
|
|
809
|
+
else
|
|
810
|
+
checks << check(
|
|
811
|
+
category: "core_files", name: "version_match", status: "warn",
|
|
812
|
+
message: "Version mismatch: global=#{global_version}, agent=#{agent_version}",
|
|
813
|
+
details: [
|
|
814
|
+
"#{tilde(File.join(plastic_home, "VERSION"))}: #{global_version}",
|
|
815
|
+
"#{tilde(agent_version_path)}: #{agent_version}",
|
|
816
|
+
],
|
|
817
|
+
fixable: false
|
|
818
|
+
)
|
|
819
|
+
end
|
|
596
820
|
else
|
|
597
821
|
checks << check(
|
|
598
822
|
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
|
-
],
|
|
823
|
+
message: "Agent-side VERSION file not found at #{tilde(agent_version_path)}",
|
|
604
824
|
fixable: false
|
|
605
825
|
)
|
|
606
826
|
end
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
checks
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
# --- Check category: manifest sync (binary core integrity) ---
|
|
833
|
+
|
|
834
|
+
# Verify, for BOTH the global manifest and the agent-side manifest, that every
|
|
835
|
+
# file listed exists and its current SHA256 matches the recorded hash.
|
|
836
|
+
# - GLOBAL manifest: <plastic_home>/manifest.json
|
|
837
|
+
# - AGENT-side manifest: claude -> <dir>/plastic/manifest.json
|
|
838
|
+
# other -> <dir>/plastic-manifest.json
|
|
839
|
+
# Manifest format: { "version", "created", "files": { abs_path => sha256 } }.
|
|
840
|
+
# A missing manifest is a fail; any missing/mismatched listed file is a fail;
|
|
841
|
+
# otherwise a single pass per manifest.
|
|
842
|
+
def check_manifest_sync(agent_key)
|
|
843
|
+
checks = []
|
|
844
|
+
|
|
845
|
+
global_manifest = File.join(plastic_home, "manifest.json")
|
|
846
|
+
checks << verify_manifest(global_manifest, "global")
|
|
847
|
+
|
|
848
|
+
agent_dir = agents[agent_key][:dir]
|
|
849
|
+
agent_manifest = if agent_key == "claude"
|
|
850
|
+
File.join(agent_dir, "plastic", "manifest.json")
|
|
851
|
+
else
|
|
852
|
+
File.join(agent_dir, "plastic-manifest.json")
|
|
853
|
+
end
|
|
854
|
+
checks << verify_manifest(agent_manifest, "agent")
|
|
855
|
+
|
|
856
|
+
checks
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
# Check one manifest file. Returns a single check (pass or fail).
|
|
860
|
+
def verify_manifest(manifest_path, label)
|
|
861
|
+
unless File.exist?(manifest_path)
|
|
862
|
+
return check(
|
|
863
|
+
category: "manifest_sync", name: "#{label}_manifest", status: "fail",
|
|
864
|
+
message: "#{label} core manifest missing — re-run the Plastic installer",
|
|
865
|
+
details: [tilde(manifest_path)],
|
|
866
|
+
fixable: true, fix_hint: "Re-run the Plastic installer"
|
|
867
|
+
)
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
data = read_json_safe(manifest_path)
|
|
871
|
+
files = data.is_a?(Hash) ? data["files"] : nil
|
|
872
|
+
unless files.is_a?(Hash)
|
|
873
|
+
return check(
|
|
874
|
+
category: "manifest_sync", name: "#{label}_manifest", status: "fail",
|
|
875
|
+
message: "#{label} core manifest unreadable or malformed — re-run the Plastic installer",
|
|
876
|
+
details: [tilde(manifest_path)],
|
|
877
|
+
fixable: true, fix_hint: "Re-run the Plastic installer"
|
|
878
|
+
)
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
missing = []
|
|
882
|
+
mismatched = []
|
|
883
|
+
files.each do |path, recorded|
|
|
884
|
+
unless File.exist?(path)
|
|
885
|
+
missing << tilde(path)
|
|
886
|
+
next
|
|
887
|
+
end
|
|
888
|
+
actual = Digest::SHA256.file(path).hexdigest
|
|
889
|
+
mismatched << tilde(path) if actual != recorded
|
|
890
|
+
end
|
|
891
|
+
|
|
892
|
+
if missing.empty? && mismatched.empty?
|
|
893
|
+
check(
|
|
894
|
+
category: "manifest_sync", name: "#{label}_manifest", status: "pass",
|
|
895
|
+
message: "#{label} manifest: all #{files.size} tracked file(s) present and matching"
|
|
896
|
+
)
|
|
607
897
|
else
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
898
|
+
details = []
|
|
899
|
+
details += missing.map { |p| "missing: #{p}" }
|
|
900
|
+
details += mismatched.map { |p| "modified: #{p}" }
|
|
901
|
+
check(
|
|
902
|
+
category: "manifest_sync", name: "#{label}_manifest", status: "fail",
|
|
903
|
+
message: "#{label} manifest out of sync: #{missing.size} missing, #{mismatched.size} modified",
|
|
904
|
+
details: details,
|
|
905
|
+
fixable: true, fix_hint: "Re-run the Plastic installer to restore tracked files"
|
|
612
906
|
)
|
|
613
907
|
end
|
|
614
908
|
end
|
|
615
909
|
|
|
616
|
-
|
|
617
|
-
end
|
|
910
|
+
# --- Check category 5: Project stores ---
|
|
618
911
|
|
|
619
|
-
|
|
912
|
+
def check_project_stores
|
|
913
|
+
checks = []
|
|
620
914
|
|
|
621
|
-
|
|
622
|
-
|
|
915
|
+
projects_yml_path = File.join(plastic_home, "projects.yml")
|
|
916
|
+
projects_data = load_yaml_safe(projects_yml_path)
|
|
623
917
|
|
|
624
|
-
|
|
625
|
-
|
|
918
|
+
if projects_data.nil?
|
|
919
|
+
checks << check(
|
|
920
|
+
category: "project_stores", name: "projects_yml", status: "warn",
|
|
921
|
+
message: "projects.yml not found or invalid at #{tilde(projects_yml_path)}",
|
|
922
|
+
fixable: true, fix_hint: "Re-run the Plastic installer to restore projects.yml"
|
|
923
|
+
)
|
|
924
|
+
return checks
|
|
925
|
+
end
|
|
626
926
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
927
|
+
projects = projects_data["projects"]
|
|
928
|
+
unless projects.is_a?(Hash) && !projects.empty?
|
|
929
|
+
# No projects registered — nothing to check
|
|
930
|
+
checks << check(
|
|
931
|
+
category: "project_stores", name: "projects_yml", status: "pass",
|
|
932
|
+
message: "projects.yml is valid (#{projects.is_a?(Hash) ? projects.size : 0} projects registered)"
|
|
933
|
+
)
|
|
934
|
+
return checks
|
|
935
|
+
end
|
|
635
936
|
|
|
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
|
|
937
|
+
projects.each do |slug, project_info|
|
|
938
|
+
checks += check_project_store(slug, project_info)
|
|
939
|
+
end
|
|
645
940
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
index_content = File.exist?(index_path) ? File.read(index_path) : ""
|
|
941
|
+
checks
|
|
942
|
+
end
|
|
649
943
|
|
|
650
|
-
|
|
651
|
-
|
|
944
|
+
# Per-project validation extracted from check_project_stores so a single
|
|
945
|
+
# project can be checked in isolation (used by `--store <slug>`).
|
|
946
|
+
def check_project_store(slug, project_info)
|
|
947
|
+
checks = []
|
|
948
|
+
project_dir = File.join(plastic_home, "projects", slug)
|
|
652
949
|
|
|
653
950
|
# project_dir_exists
|
|
654
951
|
if File.directory?(project_dir)
|
|
@@ -665,6 +962,22 @@ def check_project_stores
|
|
|
665
962
|
)
|
|
666
963
|
end
|
|
667
964
|
|
|
965
|
+
# project_store_dir
|
|
966
|
+
store_dir = File.join(project_dir, "store")
|
|
967
|
+
if File.directory?(store_dir)
|
|
968
|
+
checks << check(
|
|
969
|
+
category: "project_stores", name: "project_store_dir", status: "pass",
|
|
970
|
+
message: "Store directory exists for '#{slug}'"
|
|
971
|
+
)
|
|
972
|
+
else
|
|
973
|
+
checks << check(
|
|
974
|
+
category: "project_stores", name: "project_store_dir", status: "warn",
|
|
975
|
+
message: "Store directory missing for '#{slug}'",
|
|
976
|
+
details: [tilde(store_dir)],
|
|
977
|
+
fixable: true, fix_hint: "Run: provision-project-store #{slug}"
|
|
978
|
+
)
|
|
979
|
+
end
|
|
980
|
+
|
|
668
981
|
# project_index
|
|
669
982
|
project_index = File.join(project_dir, "INDEX.md")
|
|
670
983
|
if File.exist?(project_index)
|
|
@@ -682,7 +995,7 @@ def check_project_stores
|
|
|
682
995
|
end
|
|
683
996
|
|
|
684
997
|
# project_yml_exists
|
|
685
|
-
project_yml_path = File.join(
|
|
998
|
+
project_yml_path = File.join(plastic_home, "projects", slug, "project.yml")
|
|
686
999
|
project_yml_data = nil
|
|
687
1000
|
|
|
688
1001
|
if File.exist?(project_yml_path)
|
|
@@ -695,7 +1008,7 @@ def check_project_stores
|
|
|
695
1008
|
checks << check(
|
|
696
1009
|
category: "project_stores", name: "project_yml_exists", status: "warn",
|
|
697
1010
|
message: "project.yml missing for project '#{slug}'",
|
|
698
|
-
fixable: true, fix_hint: "Create project.yml from template — see plastic
|
|
1011
|
+
fixable: true, fix_hint: "Create project.yml from template — see plastic-creating-project"
|
|
699
1012
|
)
|
|
700
1013
|
end
|
|
701
1014
|
|
|
@@ -726,10 +1039,10 @@ def check_project_stores
|
|
|
726
1039
|
|
|
727
1040
|
# cross_references — if project has `parent` field, check global store intent tags
|
|
728
1041
|
parent_id = project_info.is_a?(Hash) ? project_info["parent"] : nil
|
|
729
|
-
|
|
1042
|
+
return checks unless parent_id
|
|
730
1043
|
|
|
731
1044
|
# Find the intent directory for the parent ID
|
|
732
|
-
store_dir = File.join(
|
|
1045
|
+
store_dir = File.join(plastic_home, "store")
|
|
733
1046
|
parent_dir = nil
|
|
734
1047
|
if File.directory?(store_dir)
|
|
735
1048
|
parent_dir = Dir.children(store_dir).find { |d| d.start_with?("#{parent_id}--") }
|
|
@@ -741,7 +1054,7 @@ def check_project_stores
|
|
|
741
1054
|
message: "Parent intent '#{parent_id}' for project '#{slug}' not found in global store",
|
|
742
1055
|
fixable: false
|
|
743
1056
|
)
|
|
744
|
-
|
|
1057
|
+
return checks
|
|
745
1058
|
end
|
|
746
1059
|
|
|
747
1060
|
intent_md = File.join(store_dir, parent_dir, "#{parent_dir}.md")
|
|
@@ -753,7 +1066,7 @@ def check_project_stores
|
|
|
753
1066
|
message: "Cannot read frontmatter of parent intent '#{parent_id}' for project '#{slug}'",
|
|
754
1067
|
fixable: false
|
|
755
1068
|
)
|
|
756
|
-
|
|
1069
|
+
return checks
|
|
757
1070
|
end
|
|
758
1071
|
|
|
759
1072
|
tags = fm["tags"]
|
|
@@ -772,160 +1085,273 @@ def check_project_stores
|
|
|
772
1085
|
fixable: false
|
|
773
1086
|
)
|
|
774
1087
|
end
|
|
1088
|
+
|
|
1089
|
+
checks
|
|
775
1090
|
end
|
|
776
1091
|
|
|
777
|
-
|
|
778
|
-
end
|
|
1092
|
+
# --- Check category 6: Deprecations ---
|
|
779
1093
|
|
|
780
|
-
|
|
1094
|
+
def check_deprecations
|
|
1095
|
+
checks = []
|
|
781
1096
|
|
|
782
|
-
|
|
783
|
-
|
|
1097
|
+
deprecations_path = File.join(plastic_home, "deprecations.yml")
|
|
1098
|
+
data = load_yaml_safe(deprecations_path)
|
|
784
1099
|
|
|
785
|
-
|
|
786
|
-
|
|
1100
|
+
if data.nil?
|
|
1101
|
+
checks << check(
|
|
1102
|
+
category: "deprecations", name: "deprecations_file", status: "pass",
|
|
1103
|
+
message: "No deprecations.yml found (nothing to report)"
|
|
1104
|
+
)
|
|
1105
|
+
return checks
|
|
1106
|
+
end
|
|
787
1107
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
1108
|
+
entries = data["deprecations"]
|
|
1109
|
+
unless entries.is_a?(Array) && !entries.empty?
|
|
1110
|
+
checks << check(
|
|
1111
|
+
category: "deprecations", name: "active_deprecations", status: "pass",
|
|
1112
|
+
message: "No deprecation entries found"
|
|
1113
|
+
)
|
|
1114
|
+
return checks
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1117
|
+
current_version = read_version
|
|
1118
|
+
unless current_version
|
|
1119
|
+
checks << check(
|
|
1120
|
+
category: "deprecations", name: "active_deprecations", status: "warn",
|
|
1121
|
+
message: "Cannot compare deprecation versions — VERSION file missing",
|
|
1122
|
+
fixable: false
|
|
1123
|
+
)
|
|
1124
|
+
return checks
|
|
1125
|
+
end
|
|
1126
|
+
|
|
1127
|
+
# Find deprecations where removal version is greater than current version
|
|
1128
|
+
# Use simple string comparison on semver (works for well-formed versions)
|
|
1129
|
+
active = entries.select do |entry|
|
|
1130
|
+
removal = entry["removal"].to_s
|
|
1131
|
+
next false if removal.empty?
|
|
1132
|
+
|
|
1133
|
+
compare_versions(current_version, removal) < 0
|
|
1134
|
+
end
|
|
1135
|
+
|
|
1136
|
+
if active.empty?
|
|
1137
|
+
checks << check(
|
|
1138
|
+
category: "deprecations", name: "active_deprecations", status: "pass",
|
|
1139
|
+
message: "No active deprecations for current version (#{current_version})"
|
|
1140
|
+
)
|
|
1141
|
+
else
|
|
1142
|
+
details = active.map do |entry|
|
|
1143
|
+
lines = ["[#{entry["severity"]}] #{entry["summary"]} (removal: #{entry["removal"]})"]
|
|
1144
|
+
if entry["migration_steps"].is_a?(Array)
|
|
1145
|
+
entry["migration_steps"].each { |step| lines << " - #{step}" }
|
|
1146
|
+
end
|
|
1147
|
+
lines.join("\n")
|
|
1148
|
+
end
|
|
1149
|
+
|
|
1150
|
+
checks << check(
|
|
1151
|
+
category: "deprecations", name: "active_deprecations", status: "warn",
|
|
1152
|
+
message: "#{active.size} active deprecation(s) for version #{current_version}",
|
|
1153
|
+
details: details,
|
|
1154
|
+
fixable: false
|
|
1155
|
+
)
|
|
1156
|
+
end
|
|
1157
|
+
|
|
1158
|
+
checks
|
|
794
1159
|
end
|
|
795
1160
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
1161
|
+
# Compare two semver strings. Returns -1, 0, or 1.
|
|
1162
|
+
# Handles pre-release tags: 1.0.0-alpha.5 < 1.0.0 < 2.0.0
|
|
1163
|
+
def compare_versions(a, b)
|
|
1164
|
+
parse = ->(v) {
|
|
1165
|
+
base, pre = v.split("-", 2)
|
|
1166
|
+
segments = base.split(".").map(&:to_i)
|
|
1167
|
+
[segments, pre]
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
a_segments, a_pre = parse.call(a)
|
|
1171
|
+
b_segments, b_pre = parse.call(b)
|
|
1172
|
+
|
|
1173
|
+
# Pad to equal length
|
|
1174
|
+
max_len = [a_segments.size, b_segments.size].max
|
|
1175
|
+
a_segments += [0] * (max_len - a_segments.size)
|
|
1176
|
+
b_segments += [0] * (max_len - b_segments.size)
|
|
1177
|
+
|
|
1178
|
+
cmp = (a_segments <=> b_segments)
|
|
1179
|
+
return cmp unless cmp == 0
|
|
1180
|
+
|
|
1181
|
+
# Same base version: no pre-release > pre-release (1.0.0 > 1.0.0-alpha)
|
|
1182
|
+
return 0 if a_pre.nil? && b_pre.nil?
|
|
1183
|
+
return 1 if a_pre.nil? && b_pre
|
|
1184
|
+
return -1 if a_pre && b_pre.nil?
|
|
1185
|
+
|
|
1186
|
+
# Both have pre-release: compare lexically
|
|
1187
|
+
a_pre <=> b_pre
|
|
1188
|
+
end
|
|
1189
|
+
|
|
1190
|
+
# --- Check category: QMD integration (read-only, optional) ---
|
|
1191
|
+
#
|
|
1192
|
+
# QMD is an optional integration. When `qmd` is not on PATH we emit a single
|
|
1193
|
+
# passing check and never fail — its absence is not a Plastic health problem.
|
|
1194
|
+
# When present, we report whether every Plastic store is registered as a QMD
|
|
1195
|
+
# collection. Everything here is read-only; we never invoke a mutating qmd
|
|
1196
|
+
# subcommand. detector/runner are injectable so tests stay hermetic.
|
|
1197
|
+
def check_qmd(detector: QmdSync.method(:detect), runner: QmdSync.default_runner)
|
|
1198
|
+
return [absent_qmd_check] unless detector.call
|
|
1199
|
+
|
|
1200
|
+
checks = [check(
|
|
1201
|
+
category: "qmd", name: "present", status: "pass",
|
|
1202
|
+
message: "QMD installed"
|
|
1203
|
+
)]
|
|
1204
|
+
|
|
1205
|
+
status = QmdSync.status(plastic_home: plastic_home, runner: runner, detector: detector)
|
|
1206
|
+
missing = status[:missing] || []
|
|
1207
|
+
|
|
1208
|
+
if status[:all_registered]
|
|
1209
|
+
checks << check(
|
|
1210
|
+
category: "qmd", name: "collections", status: "pass",
|
|
1211
|
+
message: "All #{status[:expected].size} Plastic store(s) registered as QMD collections"
|
|
1212
|
+
)
|
|
1213
|
+
else
|
|
1214
|
+
checks << check(
|
|
1215
|
+
category: "qmd", name: "collections", status: "warn",
|
|
1216
|
+
message: "#{missing.size} Plastic store(s) not registered as QMD collections",
|
|
1217
|
+
details: missing,
|
|
1218
|
+
fixable: true, fix_hint: "Run: qmd-sync register --all"
|
|
1219
|
+
)
|
|
1220
|
+
end
|
|
1221
|
+
|
|
1222
|
+
checks
|
|
803
1223
|
end
|
|
804
1224
|
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
message: "Cannot compare deprecation versions — VERSION file missing",
|
|
810
|
-
fixable: false
|
|
1225
|
+
def absent_qmd_check
|
|
1226
|
+
check(
|
|
1227
|
+
category: "qmd", name: "present", status: "pass",
|
|
1228
|
+
message: "QMD not installed (optional integration)"
|
|
811
1229
|
)
|
|
812
|
-
return checks
|
|
813
1230
|
end
|
|
814
1231
|
|
|
815
|
-
#
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
1232
|
+
# --- Run all checks ---
|
|
1233
|
+
|
|
1234
|
+
def run_checks(agent_key)
|
|
1235
|
+
all_checks = []
|
|
1236
|
+
all_checks += check_global_store
|
|
1237
|
+
all_checks += check_conventions
|
|
1238
|
+
all_checks += check_agent_registration(agent_key)
|
|
1239
|
+
all_checks += check_core_files(agent_key)
|
|
1240
|
+
all_checks += check_project_stores
|
|
1241
|
+
all_checks += check_deprecations
|
|
1242
|
+
all_checks += check_qmd
|
|
820
1243
|
|
|
821
|
-
|
|
1244
|
+
summarize(all_checks, agent_key)
|
|
822
1245
|
end
|
|
823
1246
|
|
|
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
|
|
1247
|
+
# Binary core sync check: agent registration + core files + manifest sync,
|
|
1248
|
+
# rolled up with binary: true so ANY warn or fail makes the overall status
|
|
1249
|
+
# "fail" (and "warn" is never emitted). Used by `doctor.rb --core`.
|
|
1250
|
+
def run_core_checks(agent_key)
|
|
1251
|
+
all_checks = []
|
|
1252
|
+
all_checks += check_agent_registration(agent_key)
|
|
1253
|
+
all_checks += check_core_files(agent_key)
|
|
1254
|
+
all_checks += check_manifest_sync(agent_key)
|
|
837
1255
|
|
|
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
|
-
)
|
|
1256
|
+
summarize(all_checks, agent_key, binary: true)
|
|
844
1257
|
end
|
|
845
1258
|
|
|
846
|
-
checks
|
|
847
|
-
|
|
1259
|
+
# Store-scoped checks for `doctor.rb --store [global|<slug>]`.
|
|
1260
|
+
# :all -> global store + all project stores + all conventions
|
|
1261
|
+
# :global -> global store + conventions scoped to ["global"]
|
|
1262
|
+
# "<slug>" -> that project only + conventions scoped to ["project:<slug>"]
|
|
1263
|
+
# (fail if the slug is not registered in projects.yml)
|
|
1264
|
+
# 3-state roll-up (pass/warn/fail), like the full run.
|
|
1265
|
+
def run_store_checks(store)
|
|
1266
|
+
all_checks =
|
|
1267
|
+
case store
|
|
1268
|
+
when :all
|
|
1269
|
+
check_global_store + check_project_stores + check_conventions
|
|
1270
|
+
when :global
|
|
1271
|
+
check_global_store + check_conventions(scopes: ["global"])
|
|
1272
|
+
else
|
|
1273
|
+
all_checks_for_project_slug(store)
|
|
1274
|
+
end
|
|
848
1275
|
|
|
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
|
|
1276
|
+
summarize(all_checks, "claude", binary: false)
|
|
1277
|
+
end
|
|
877
1278
|
|
|
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
|
|
1279
|
+
# Build the checks for a single project slug, or a lone fail check when the
|
|
1280
|
+
# slug is unknown.
|
|
1281
|
+
def all_checks_for_project_slug(slug)
|
|
1282
|
+
projects_data = load_yaml_safe(File.join(plastic_home, "projects.yml"))
|
|
1283
|
+
projects = projects_data.is_a?(Hash) ? projects_data["projects"] : nil
|
|
909
1284
|
|
|
910
|
-
|
|
1285
|
+
unless projects.is_a?(Hash) && projects.key?(slug)
|
|
1286
|
+
return [check(
|
|
1287
|
+
category: "project_stores", name: "unknown_project", status: "fail",
|
|
1288
|
+
message: "unknown project '#{slug}'",
|
|
1289
|
+
fixable: false
|
|
1290
|
+
)]
|
|
1291
|
+
end
|
|
911
1292
|
|
|
912
|
-
|
|
913
|
-
|
|
1293
|
+
check_project_store(slug, projects[slug]) +
|
|
1294
|
+
check_conventions(scopes: ["project:#{slug}"])
|
|
1295
|
+
end
|
|
914
1296
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
1297
|
+
# Roll a list of checks up into the standard result envelope.
|
|
1298
|
+
# When binary: true, the overall status is "pass" only if there are zero warn
|
|
1299
|
+
# AND zero fail; any warn or fail yields "fail" (never "warn"). When false
|
|
1300
|
+
# (the default) the classic 3-state pass/warn/fail roll-up is used.
|
|
1301
|
+
def summarize(all_checks, agent_key, binary: false)
|
|
1302
|
+
summary = { pass: 0, warn: 0, fail: 0, total: all_checks.size }
|
|
1303
|
+
all_checks.each { |c| summary[c[:status].to_sym] += 1 }
|
|
1304
|
+
|
|
1305
|
+
overall = if binary
|
|
1306
|
+
(summary[:fail] > 0 || summary[:warn] > 0) ? "fail" : "pass"
|
|
1307
|
+
elsif summary[:fail] > 0
|
|
1308
|
+
"fail"
|
|
1309
|
+
elsif summary[:warn] > 0
|
|
1310
|
+
"warn"
|
|
1311
|
+
else
|
|
1312
|
+
"pass"
|
|
1313
|
+
end
|
|
1314
|
+
|
|
1315
|
+
{
|
|
1316
|
+
version: read_version || "unknown",
|
|
1317
|
+
timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
1318
|
+
status: overall,
|
|
1319
|
+
agent: agent_key,
|
|
1320
|
+
checks: all_checks,
|
|
1321
|
+
summary: summary,
|
|
1322
|
+
}
|
|
918
1323
|
end
|
|
919
1324
|
|
|
920
|
-
|
|
1325
|
+
# --- Main ---
|
|
1326
|
+
|
|
1327
|
+
def cli(argv = ARGV)
|
|
1328
|
+
flags = parse_args(argv)
|
|
1329
|
+
|
|
1330
|
+
if flags[:help]
|
|
1331
|
+
show_help
|
|
1332
|
+
exit 0
|
|
1333
|
+
end
|
|
1334
|
+
|
|
1335
|
+
result =
|
|
1336
|
+
if !flags[:store].nil?
|
|
1337
|
+
run_store_checks(flags[:store])
|
|
1338
|
+
elsif flags[:core]
|
|
1339
|
+
run_core_checks(flags[:agent])
|
|
1340
|
+
else
|
|
1341
|
+
run_checks(flags[:agent])
|
|
1342
|
+
end
|
|
921
1343
|
|
|
922
|
-
|
|
1344
|
+
puts JSON.pretty_generate(result)
|
|
923
1345
|
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
1346
|
+
# --core is binary: status is only ever pass|fail, so this maps to 0|2.
|
|
1347
|
+
# --store and the full run keep the 3-state 0/1/2 mapping.
|
|
1348
|
+
case result[:status]
|
|
1349
|
+
when "fail" then exit 2
|
|
1350
|
+
when "warn" then exit 1
|
|
1351
|
+
else exit 0
|
|
1352
|
+
end
|
|
928
1353
|
end
|
|
1354
|
+
|
|
929
1355
|
end
|
|
930
1356
|
|
|
931
|
-
|
|
1357
|
+
Doctor.new.cli(ARGV) if $PROGRAM_NAME == __FILE__
|