@zalom/plastic 1.0.0-alpha.9 → 1.0.0-beta.10
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 +170 -469
- package/README.md +95 -58
- package/agents/plastic-brainstorming.md +45 -0
- package/agents/plastic-enforcer.md +36 -0
- package/agents/plastic-executor.md +47 -0
- package/agents/{future-intent-researcher.md → plastic-future-intent-researcher.md} +1 -1
- package/agents/{intent-curator.md → plastic-intent-curator.md} +8 -6
- package/agents/plastic-planner.md +47 -0
- package/agents/plastic-spec-specialist.md +45 -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/hooks/statusline +150 -41
- package/package.json +2 -2
- package/scripts/agent-report +142 -0
- package/scripts/dashboard.rb +687 -0
- package/scripts/doctor.rb +1219 -621
- 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 +452 -19
- package/scripts/lib/frontmatter_writer.rb +130 -0
- package/scripts/lib/graph_rebuild.rb +328 -0
- package/scripts/lib/installer_core.rb +812 -0
- package/scripts/lib/intent_validator.rb +235 -0
- package/scripts/lib/links_projection.rb +160 -0
- package/scripts/lib/links_section.rb +207 -0
- package/scripts/lib/power_tools.rb +76 -0
- package/scripts/lib/qmd_hook.rb +57 -0
- package/scripts/lib/qmd_sync.rb +230 -0
- package/scripts/lib/store_provisioning.rb +100 -0
- package/scripts/migrate-to-global +1 -1
- package/scripts/new-intent +327 -0
- package/scripts/project-links +287 -0
- package/scripts/provision-project-store +53 -0
- package/scripts/qmd-sync +139 -0
- package/scripts/rebuild-graph +244 -0
- package/scripts/select-update-target +93 -0
- package/scripts/spawn-preamble +138 -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 +87 -7
- package/skills/auto/evals/evals.json +255 -0
- package/skills/auto/references/agent-architecture.md +155 -0
- package/skills/auto/references/agent-report-contract.md +86 -0
- package/skills/brainstorming/SKILL.md +10 -9
- package/skills/brainstorming/evals/evals.json +22 -0
- package/skills/brainstorming-grill-me/SKILL.md +6 -6
- package/skills/continuing/SKILL.md +99 -82
- package/skills/continuing/evals/evals.json +145 -0
- package/skills/continuing/references/context-management.md +32 -0
- package/skills/creating-intent/SKILL.md +82 -36
- package/skills/creating-intent/evals/evals.json +72 -0
- package/skills/creating-intent/references/lifecycle.md +81 -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 +126 -0
- package/skills/dashboard/evals/evals.json +22 -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 +6 -4
- package/skills/intent-curator/evals/evals.json +22 -0
- package/skills/linking-intents/SKILL.md +22 -7
- package/skills/linking-intents/evals/evals.json +22 -0
- package/skills/linking-intents/references/zettelkasten.md +45 -0
- package/skills/managing-index/SKILL.md +11 -1
- package/skills/managing-index/evals/evals.json +22 -0
- package/skills/managing-index/references/zettelkasten-linking.md +7 -2
- package/skills/releasing/SKILL.md +80 -23
- package/skills/releasing/references/deprecations.md +60 -0
- package/skills/research/SKILL.md +10 -2
- package/skills/research/evals/evals.json +22 -0
- package/skills/savepoint/SKILL.md +46 -37
- package/skills/savepoint/references/context-management.md +32 -0
- package/skills/uninstall/SKILL.md +39 -28
- package/skills/update/SKILL.md +41 -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,1112 @@
|
|
|
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
|
+
require_relative "lib/graph_rebuild"
|
|
21
|
+
require_relative "lib/links_projection"
|
|
22
|
+
require_relative "lib/links_section"
|
|
23
|
+
|
|
24
|
+
# Diagnostic engine, instantiable with an injected store/agent map so tests can
|
|
25
|
+
# run it hermetically (no eval, no global-constant rewriting).
|
|
26
|
+
class Doctor
|
|
27
|
+
DEFAULT_PLASTIC_HOME = File.join(Dir.home, ".plastic")
|
|
28
|
+
|
|
29
|
+
DEFAULT_AGENTS = {
|
|
30
|
+
"claude" => { name: "Claude Code", dir: File.join(Dir.home, ".claude") },
|
|
31
|
+
"codex" => { name: "Codex CLI", dir: File.join(Dir.home, ".agents") },
|
|
32
|
+
"hermes" => { name: "Hermes", dir: File.join(Dir.home, ".hermes") },
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
REQUIRED_INDEX_SECTIONS = ["## Active", "## Future", "## Clusters", "## Abandoned", "## Completed"].freeze
|
|
36
|
+
|
|
37
|
+
# Single source of truth for the required-field list lives in IntentValidator
|
|
38
|
+
# (intent 60). Alias it here so the two can never drift.
|
|
39
|
+
REQUIRED_FRONTMATTER_FIELDS = IntentValidator::REQUIRED_FIELDS
|
|
40
|
+
|
|
41
|
+
CLAUDE_HOOK_SCRIPTS = %w[
|
|
42
|
+
plastic-session-start
|
|
43
|
+
plastic-check-update
|
|
44
|
+
plastic-savepoint
|
|
45
|
+
plastic-gate-check
|
|
46
|
+
plastic-continue
|
|
47
|
+
plastic-future-intent-check
|
|
48
|
+
plastic-qmd-search
|
|
49
|
+
].freeze
|
|
50
|
+
|
|
51
|
+
CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit].freeze
|
|
52
|
+
|
|
53
|
+
REQUIRED_SCRIPTS = %w[
|
|
54
|
+
folgezettel-id
|
|
55
|
+
read-config
|
|
56
|
+
hook-session-start
|
|
57
|
+
hook-continue
|
|
58
|
+
hook-future-intent-check
|
|
59
|
+
hook-gate-check
|
|
60
|
+
validate-intent
|
|
61
|
+
doctor.rb
|
|
62
|
+
].freeze
|
|
63
|
+
|
|
64
|
+
attr_reader :plastic_home, :agents
|
|
65
|
+
|
|
66
|
+
def initialize(plastic_home: DEFAULT_PLASTIC_HOME, agents: DEFAULT_AGENTS)
|
|
67
|
+
@plastic_home = plastic_home
|
|
68
|
+
@agents = agents
|
|
69
|
+
end
|
|
15
70
|
|
|
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
|
|
71
|
+
# --- Flag parsing ---
|
|
72
|
+
|
|
73
|
+
def parse_args(argv)
|
|
74
|
+
agent = "claude"
|
|
75
|
+
help = false
|
|
76
|
+
core = false
|
|
77
|
+
# store flag representation:
|
|
78
|
+
# nil — --store not given
|
|
79
|
+
# :all — --store with no value (check every store)
|
|
80
|
+
# :global — --store global
|
|
81
|
+
# "<slug>" — --store <slug> (a single project)
|
|
82
|
+
store = nil
|
|
83
|
+
|
|
84
|
+
i = 0
|
|
85
|
+
while i < argv.length
|
|
86
|
+
case argv[i]
|
|
87
|
+
when "--agent"
|
|
88
|
+
if argv[i + 1] && agents.key?(argv[i + 1])
|
|
89
|
+
agent = argv[i + 1]
|
|
90
|
+
i += 2
|
|
91
|
+
else
|
|
92
|
+
$stderr.puts "Error: --agent requires one of: #{agents.keys.join(", ")}"
|
|
93
|
+
exit 2
|
|
94
|
+
end
|
|
95
|
+
when "--core"
|
|
96
|
+
core = true
|
|
97
|
+
i += 1
|
|
98
|
+
when "--store"
|
|
99
|
+
nxt = argv[i + 1]
|
|
100
|
+
if nxt && !nxt.start_with?("-")
|
|
101
|
+
store = (nxt == "global") ? :global : nxt
|
|
102
|
+
i += 2
|
|
103
|
+
else
|
|
104
|
+
store = :all
|
|
105
|
+
i += 1
|
|
106
|
+
end
|
|
107
|
+
when "--help", "-h"
|
|
108
|
+
help = true
|
|
109
|
+
i += 1
|
|
62
110
|
else
|
|
63
|
-
|
|
64
|
-
exit 2
|
|
111
|
+
i += 1
|
|
65
112
|
end
|
|
66
|
-
when "--help", "-h"
|
|
67
|
-
help = true
|
|
68
|
-
i += 1
|
|
69
|
-
else
|
|
70
|
-
i += 1
|
|
71
113
|
end
|
|
114
|
+
|
|
115
|
+
{ agent: agent, help: help, core: core, store: store }
|
|
72
116
|
end
|
|
73
117
|
|
|
74
|
-
|
|
75
|
-
|
|
118
|
+
def show_help
|
|
119
|
+
$stderr.puts <<~HELP
|
|
76
120
|
|
|
77
|
-
|
|
78
|
-
$stderr.puts <<~HELP
|
|
121
|
+
plastic doctor — diagnose Plastic installation health
|
|
79
122
|
|
|
80
|
-
|
|
123
|
+
Usage:
|
|
124
|
+
ruby ~/.plastic/scripts/doctor.rb [options]
|
|
81
125
|
|
|
82
|
-
|
|
83
|
-
|
|
126
|
+
Options:
|
|
127
|
+
--agent NAME Agent to check: claude (default), codex, hermes
|
|
128
|
+
--core Binary core sync check: verifies agent registration, core
|
|
129
|
+
files, and that every manifest-tracked file matches its
|
|
130
|
+
recorded SHA256. Exits 0 (pass) or 2 (fail); never warn.
|
|
131
|
+
--store [WHICH] Run only the store/conventions checks. WHICH may be:
|
|
132
|
+
global (global store only), a project slug (that project
|
|
133
|
+
only), or omitted (all stores). 3-state pass/warn/fail.
|
|
134
|
+
-h, --help Show this help
|
|
84
135
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
136
|
+
Output:
|
|
137
|
+
JSON to stdout with check results.
|
|
138
|
+
Exit 0 = all pass, 1 = warnings only, 2 = failures present.
|
|
88
139
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
Exit 0 = all pass, 1 = warnings only, 2 = failures present.
|
|
140
|
+
HELP
|
|
141
|
+
end
|
|
92
142
|
|
|
93
|
-
|
|
94
|
-
end
|
|
143
|
+
# --- Utility helpers ---
|
|
95
144
|
|
|
96
|
-
|
|
145
|
+
def read_version
|
|
146
|
+
version_path = File.join(plastic_home, "VERSION")
|
|
147
|
+
return nil unless File.exist?(version_path)
|
|
97
148
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return nil unless File.exist?(version_path)
|
|
149
|
+
File.read(version_path).strip
|
|
150
|
+
end
|
|
101
151
|
|
|
102
|
-
|
|
103
|
-
|
|
152
|
+
def read_json_safe(path)
|
|
153
|
+
return nil unless File.exist?(path)
|
|
104
154
|
|
|
105
|
-
|
|
106
|
-
|
|
155
|
+
JSON.parse(File.read(path))
|
|
156
|
+
rescue JSON::ParserError
|
|
157
|
+
content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
|
|
158
|
+
JSON.parse(content)
|
|
159
|
+
rescue
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
107
162
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
|
|
111
|
-
JSON.parse(content)
|
|
112
|
-
rescue
|
|
113
|
-
nil
|
|
114
|
-
end
|
|
163
|
+
def load_yaml_safe(path)
|
|
164
|
+
return nil unless File.exist?(path)
|
|
115
165
|
|
|
116
|
-
|
|
117
|
-
|
|
166
|
+
YAML.safe_load(File.read(path)) || {}
|
|
167
|
+
rescue => e
|
|
168
|
+
$stderr.puts "Warning: failed to parse #{path}: #{e.message}"
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
118
171
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
nil
|
|
123
|
-
end
|
|
172
|
+
def tilde(path)
|
|
173
|
+
path.sub(Dir.home, "~")
|
|
174
|
+
end
|
|
124
175
|
|
|
125
|
-
def
|
|
126
|
-
|
|
127
|
-
|
|
176
|
+
def check(category:, name:, status:, message:, details: [], fixable: false, fix_hint: nil)
|
|
177
|
+
result = {
|
|
178
|
+
category: category,
|
|
179
|
+
name: name,
|
|
180
|
+
status: status,
|
|
181
|
+
message: message,
|
|
182
|
+
details: details,
|
|
183
|
+
fixable: fixable,
|
|
184
|
+
}
|
|
185
|
+
result[:fix_hint] = fix_hint if fix_hint
|
|
186
|
+
result
|
|
187
|
+
end
|
|
128
188
|
|
|
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
|
|
189
|
+
# --- Parse frontmatter from an intent markdown file ---
|
|
141
190
|
|
|
142
|
-
|
|
191
|
+
def parse_frontmatter(path)
|
|
192
|
+
return nil unless File.exist?(path)
|
|
143
193
|
|
|
144
|
-
|
|
145
|
-
|
|
194
|
+
content = File.read(path)
|
|
195
|
+
return nil unless content.start_with?("---")
|
|
146
196
|
|
|
147
|
-
|
|
148
|
-
|
|
197
|
+
parts = content.split("---", 3)
|
|
198
|
+
return nil if parts.length < 3
|
|
149
199
|
|
|
150
|
-
|
|
151
|
-
|
|
200
|
+
# created: dates parse as Date objects, which safe_load rejects by default —
|
|
201
|
+
# permit Date/Time so valid frontmatter isn't misreported as missing.
|
|
202
|
+
YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
|
|
203
|
+
rescue
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
152
206
|
|
|
153
|
-
|
|
154
|
-
rescue
|
|
155
|
-
nil
|
|
156
|
-
end
|
|
207
|
+
# --- Collect all intent directories (global + project stores) ---
|
|
157
208
|
|
|
158
|
-
#
|
|
209
|
+
# Child directories of a store, excluding dotfiles/dot-directories
|
|
210
|
+
# (e.g. .obsidian, .git) which are tooling artifacts, not intents.
|
|
211
|
+
def store_intent_dirs(store)
|
|
212
|
+
Dir.children(store).reject { |e| e.start_with?(".") }.select do |e|
|
|
213
|
+
File.directory?(File.join(store, e))
|
|
214
|
+
end
|
|
215
|
+
end
|
|
159
216
|
|
|
160
|
-
def all_intent_dirs
|
|
161
|
-
|
|
217
|
+
def all_intent_dirs
|
|
218
|
+
dirs = []
|
|
162
219
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
220
|
+
global_store = File.join(plastic_home, "store")
|
|
221
|
+
if File.directory?(global_store)
|
|
222
|
+
store_intent_dirs(global_store).each do |entry|
|
|
223
|
+
full = File.join(global_store, entry)
|
|
224
|
+
dirs << { path: full, name: entry, scope: "global" }
|
|
225
|
+
end
|
|
168
226
|
end
|
|
169
|
-
end
|
|
170
227
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
228
|
+
projects_root = File.join(plastic_home, "projects")
|
|
229
|
+
if File.directory?(projects_root)
|
|
230
|
+
Dir.children(projects_root).each do |project|
|
|
231
|
+
project_store = File.join(projects_root, project, "store")
|
|
232
|
+
next unless File.directory?(project_store)
|
|
176
233
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
234
|
+
store_intent_dirs(project_store).each do |entry|
|
|
235
|
+
full = File.join(project_store, entry)
|
|
236
|
+
dirs << { path: full, name: entry, scope: "project:#{project}" }
|
|
237
|
+
end
|
|
180
238
|
end
|
|
181
239
|
end
|
|
240
|
+
|
|
241
|
+
dirs
|
|
182
242
|
end
|
|
183
243
|
|
|
184
|
-
|
|
185
|
-
end
|
|
244
|
+
# --- Check category 1: Global store ---
|
|
186
245
|
|
|
187
|
-
|
|
246
|
+
def check_global_store
|
|
247
|
+
checks = []
|
|
188
248
|
|
|
189
|
-
|
|
190
|
-
checks = []
|
|
249
|
+
index_path = File.join(plastic_home, "INDEX.md")
|
|
191
250
|
|
|
192
|
-
|
|
251
|
+
# index_exists
|
|
252
|
+
if File.exist?(index_path)
|
|
253
|
+
checks << check(
|
|
254
|
+
category: "global_store", name: "index_exists", status: "pass",
|
|
255
|
+
message: "INDEX.md exists"
|
|
256
|
+
)
|
|
257
|
+
else
|
|
258
|
+
checks << check(
|
|
259
|
+
category: "global_store", name: "index_exists", status: "fail",
|
|
260
|
+
message: "INDEX.md not found at #{tilde(index_path)}",
|
|
261
|
+
fixable: true, fix_hint: "Run the Plastic installer to bootstrap the store"
|
|
262
|
+
)
|
|
263
|
+
return checks # Can't check sections/references without INDEX.md
|
|
264
|
+
end
|
|
193
265
|
|
|
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
|
|
266
|
+
# index_sections
|
|
267
|
+
content = File.read(index_path)
|
|
268
|
+
missing_sections = REQUIRED_INDEX_SECTIONS.reject { |s| content.include?(s) }
|
|
208
269
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
270
|
+
if missing_sections.empty?
|
|
271
|
+
checks << check(
|
|
272
|
+
category: "global_store", name: "index_sections", status: "pass",
|
|
273
|
+
message: "INDEX.md has all 5 required sections"
|
|
274
|
+
)
|
|
275
|
+
else
|
|
276
|
+
checks << check(
|
|
277
|
+
category: "global_store", name: "index_sections", status: "fail",
|
|
278
|
+
message: "INDEX.md missing #{missing_sections.size} required section(s)",
|
|
279
|
+
details: missing_sections,
|
|
280
|
+
fixable: true, fix_hint: "Add missing sections to INDEX.md"
|
|
281
|
+
)
|
|
282
|
+
end
|
|
212
283
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
else
|
|
219
|
-
checks << check(
|
|
220
|
-
category: "global_store", name: "index_sections", status: "fail",
|
|
221
|
-
message: "INDEX.md missing #{missing_sections.size} required section(s)",
|
|
222
|
-
details: missing_sections,
|
|
223
|
-
fixable: true, fix_hint: "Add missing sections to INDEX.md"
|
|
224
|
-
)
|
|
225
|
-
end
|
|
284
|
+
# orphaned_intents — directories in store/ not referenced in INDEX.md
|
|
285
|
+
store_dir = File.join(plastic_home, "store")
|
|
286
|
+
if File.directory?(store_dir)
|
|
287
|
+
intent_dirs = store_intent_dirs(store_dir)
|
|
288
|
+
orphans = intent_dirs.reject { |d| content.include?("store/#{d}") }
|
|
226
289
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
290
|
+
if orphans.empty?
|
|
291
|
+
checks << check(
|
|
292
|
+
category: "global_store", name: "orphaned_intents", status: "pass",
|
|
293
|
+
message: "No orphaned intent directories"
|
|
294
|
+
)
|
|
295
|
+
else
|
|
296
|
+
checks << check(
|
|
297
|
+
category: "global_store", name: "orphaned_intents", status: "warn",
|
|
298
|
+
message: "#{orphans.size} intent director#{orphans.size == 1 ? "y" : "ies"} not referenced in INDEX.md",
|
|
299
|
+
details: orphans.map { |d| "store/#{d}" },
|
|
300
|
+
fixable: true, fix_hint: "Add missing intents to INDEX.md or remove orphaned directories"
|
|
301
|
+
)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# ghost_references — paths in INDEX.md pointing to non-existent directories
|
|
306
|
+
store_refs = content.scan(%r{store/[\w][\w-]*(?:/[\w][\w.-]*)*/?\b}).uniq
|
|
307
|
+
# Normalize: extract just the store/ID--slug portion
|
|
308
|
+
store_paths = content.scan(%r{store/\S+}).map { |ref| ref.gsub(/[)\]>].*/, "").chomp("/") }.uniq
|
|
309
|
+
|
|
310
|
+
ghosts = store_paths.select do |ref|
|
|
311
|
+
full_path = File.join(plastic_home, ref)
|
|
312
|
+
!File.exist?(full_path) && !File.directory?(full_path)
|
|
313
|
+
end
|
|
232
314
|
|
|
233
|
-
if
|
|
315
|
+
if ghosts.empty?
|
|
234
316
|
checks << check(
|
|
235
|
-
category: "global_store", name: "
|
|
236
|
-
message: "No
|
|
317
|
+
category: "global_store", name: "ghost_references", status: "pass",
|
|
318
|
+
message: "No ghost references in INDEX.md"
|
|
237
319
|
)
|
|
238
320
|
else
|
|
239
321
|
checks << check(
|
|
240
|
-
category: "global_store", name: "
|
|
241
|
-
message: "#{
|
|
242
|
-
details:
|
|
243
|
-
fixable: true, fix_hint: "
|
|
322
|
+
category: "global_store", name: "ghost_references", status: "warn",
|
|
323
|
+
message: "#{ghosts.size} path(s) in INDEX.md point to non-existent locations",
|
|
324
|
+
details: ghosts,
|
|
325
|
+
fixable: true, fix_hint: "Remove or fix broken references in INDEX.md"
|
|
244
326
|
)
|
|
245
327
|
end
|
|
328
|
+
|
|
329
|
+
checks
|
|
246
330
|
end
|
|
247
331
|
|
|
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
|
|
332
|
+
# --- Check category 2: Conventions ---
|
|
252
333
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
334
|
+
# When `scopes` is a non-nil Array of scope strings (e.g. ["global"] or
|
|
335
|
+
# ["project:plastic"]), only intents whose :scope is in that list are checked.
|
|
336
|
+
# When nil (the default, used by the full run), every intent is checked.
|
|
337
|
+
def check_conventions(scopes: nil)
|
|
338
|
+
checks = []
|
|
257
339
|
|
|
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
|
|
340
|
+
intent_dirs = all_intent_dirs
|
|
341
|
+
intent_dirs = intent_dirs.select { |d| scopes.include?(d[:scope]) } unless scopes.nil?
|
|
342
|
+
dirname_pattern = /^\w+--[\w-]+$/
|
|
271
343
|
|
|
272
|
-
|
|
273
|
-
|
|
344
|
+
# intent_dirname
|
|
345
|
+
bad_dirnames = intent_dirs.reject { |d| d[:name].match?(dirname_pattern) }
|
|
274
346
|
|
|
275
|
-
|
|
347
|
+
if bad_dirnames.empty?
|
|
348
|
+
checks << check(
|
|
349
|
+
category: "conventions", name: "intent_dirname", status: "pass",
|
|
350
|
+
message: "All #{intent_dirs.size} intent directories follow {ID}--{slug} format"
|
|
351
|
+
)
|
|
352
|
+
else
|
|
353
|
+
checks << check(
|
|
354
|
+
category: "conventions", name: "intent_dirname", status: "warn",
|
|
355
|
+
message: "#{bad_dirnames.size} intent director#{bad_dirnames.size == 1 ? "y doesn't" : "ies don't"} follow {ID}--{slug} format",
|
|
356
|
+
details: bad_dirnames.map { |d| "#{tilde(d[:path])} (#{d[:scope]})" },
|
|
357
|
+
fixable: true, fix_hint: "Rename directories to {ID}--{slug} format"
|
|
358
|
+
)
|
|
359
|
+
end
|
|
276
360
|
|
|
277
|
-
|
|
278
|
-
|
|
361
|
+
# intent_filename — primary file inside directory matches {ID}--{slug}.md
|
|
362
|
+
bad_filenames = []
|
|
363
|
+
intent_dirs.each do |d|
|
|
364
|
+
expected_file = "#{d[:name]}.md"
|
|
365
|
+
expected_path = File.join(d[:path], expected_file)
|
|
366
|
+
unless File.exist?(expected_path)
|
|
367
|
+
bad_filenames << { dir: d, expected: expected_file }
|
|
368
|
+
end
|
|
369
|
+
end
|
|
279
370
|
|
|
280
|
-
|
|
281
|
-
|
|
371
|
+
if bad_filenames.empty?
|
|
372
|
+
checks << check(
|
|
373
|
+
category: "conventions", name: "intent_filename", status: "pass",
|
|
374
|
+
message: "All intent directories have matching {ID}--{slug}.md files"
|
|
375
|
+
)
|
|
376
|
+
else
|
|
377
|
+
checks << check(
|
|
378
|
+
category: "conventions", name: "intent_filename", status: "warn",
|
|
379
|
+
message: "#{bad_filenames.size} intent director#{bad_filenames.size == 1 ? "y" : "ies"} missing primary .md file",
|
|
380
|
+
details: bad_filenames.map { |b| "#{tilde(b[:dir][:path])} — expected #{b[:expected]}" },
|
|
381
|
+
fixable: true, fix_hint: "Create or rename the primary .md file to match the directory name"
|
|
382
|
+
)
|
|
383
|
+
end
|
|
282
384
|
|
|
283
|
-
|
|
284
|
-
|
|
385
|
+
# frontmatter_fields
|
|
386
|
+
bad_frontmatter = []
|
|
387
|
+
intent_dirs.each do |d|
|
|
388
|
+
md_path = File.join(d[:path], "#{d[:name]}.md")
|
|
389
|
+
next unless File.exist?(md_path)
|
|
285
390
|
|
|
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
|
|
391
|
+
fm = parse_frontmatter(md_path)
|
|
392
|
+
if fm.nil?
|
|
393
|
+
bad_frontmatter << { dir: tilde(d[:path]), missing: ["(no frontmatter found)"] }
|
|
394
|
+
next
|
|
395
|
+
end
|
|
299
396
|
|
|
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 }
|
|
397
|
+
# Delegate missing-field detection to the single source of truth.
|
|
398
|
+
missing = IntentValidator.validate_frontmatter(fm)[:missing]
|
|
399
|
+
bad_frontmatter << { dir: tilde(d[:path]), missing: missing } unless missing.empty?
|
|
307
400
|
end
|
|
308
|
-
end
|
|
309
401
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
402
|
+
if bad_frontmatter.empty?
|
|
403
|
+
checks << check(
|
|
404
|
+
category: "conventions", name: "frontmatter_fields", status: "pass",
|
|
405
|
+
message: "All intent files have required frontmatter fields"
|
|
406
|
+
)
|
|
407
|
+
else
|
|
408
|
+
checks << check(
|
|
409
|
+
category: "conventions", name: "frontmatter_fields", status: "warn",
|
|
410
|
+
message: "#{bad_frontmatter.size} intent file(s) missing required frontmatter fields",
|
|
411
|
+
details: bad_frontmatter.map { |b| "#{b[:dir]}: missing #{b[:missing].join(", ")}" },
|
|
412
|
+
fixable: true,
|
|
413
|
+
fix_hint: "Inject the missing required frontmatter field(s) (e.g. chain: []) without disturbing other keys"
|
|
414
|
+
)
|
|
415
|
+
end
|
|
323
416
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
417
|
+
# frontmatter_valid — per-intent frontmatter shape (sources/chain are
|
|
418
|
+
# well-formed arrays of id strings). Delegates to IntentValidator so the
|
|
419
|
+
# born-complete contract is defined in exactly one place. Read-only: shape
|
|
420
|
+
# repair is not a single-field inject, so this check is not fixable here.
|
|
421
|
+
malformed = []
|
|
422
|
+
intent_dirs.each do |d|
|
|
423
|
+
md_path = File.join(d[:path], "#{d[:name]}.md")
|
|
424
|
+
next unless File.exist?(md_path)
|
|
425
|
+
|
|
426
|
+
result = IntentValidator.validate_frontmatter(parse_frontmatter(md_path))
|
|
427
|
+
shape_errors = result[:errors].select { |e| e.include?("must be an array") || e.include?("invalid id") }
|
|
428
|
+
malformed << { dir: tilde(d[:path]), errors: shape_errors } unless shape_errors.empty?
|
|
429
|
+
end
|
|
329
430
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
431
|
+
if malformed.empty?
|
|
432
|
+
checks << check(
|
|
433
|
+
category: "conventions", name: "frontmatter_valid", status: "pass",
|
|
434
|
+
message: "All intent frontmatter is well-formed"
|
|
435
|
+
)
|
|
436
|
+
else
|
|
437
|
+
checks << check(
|
|
438
|
+
category: "conventions", name: "frontmatter_valid", status: "warn",
|
|
439
|
+
message: "#{malformed.size} intent file(s) have malformed sources/chain",
|
|
440
|
+
details: malformed.map { |m| "#{m[:dir]}: #{m[:errors].join(", ")}" },
|
|
441
|
+
fixable: false
|
|
442
|
+
)
|
|
334
443
|
end
|
|
335
444
|
|
|
336
|
-
|
|
337
|
-
|
|
445
|
+
# section_structure — per-intent top-level `##` section set. Reuses
|
|
446
|
+
# IntentValidator::SANCTIONED_SECTIONS / validate_sections so the sanctioned
|
|
447
|
+
# set is defined in exactly one place (shared with the create gate and the
|
|
448
|
+
# validate-intent CLI). Read-only diagnostic: unknown or missing sections are
|
|
449
|
+
# not auto-fixable here.
|
|
450
|
+
bad_sections = []
|
|
451
|
+
intent_dirs.each do |d|
|
|
452
|
+
md_path = File.join(d[:path], "#{d[:name]}.md")
|
|
453
|
+
next unless File.exist?(md_path)
|
|
454
|
+
|
|
455
|
+
body = IntentValidator.body_of(File.read(md_path))
|
|
456
|
+
result = IntentValidator.validate_sections(body)
|
|
457
|
+
next if result[:ok]
|
|
458
|
+
|
|
459
|
+
issues = []
|
|
460
|
+
issues.concat(result[:unknown].map { |h| "unknown #{h}" })
|
|
461
|
+
issues.concat(result[:missing].map { |s| "missing #{s}" })
|
|
462
|
+
bad_sections << { dir: tilde(d[:path]), issues: issues }
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
if bad_sections.empty?
|
|
466
|
+
checks << check(
|
|
467
|
+
category: "conventions", name: "section_structure", status: "pass",
|
|
468
|
+
message: "All intent files have the sanctioned ## section structure"
|
|
469
|
+
)
|
|
470
|
+
else
|
|
471
|
+
checks << check(
|
|
472
|
+
category: "conventions", name: "section_structure", status: "warn",
|
|
473
|
+
message: "#{bad_sections.size} intent file(s) have non-sanctioned ## sections",
|
|
474
|
+
details: bad_sections.map { |b| "#{b[:dir]}: #{b[:issues].join(", ")}" },
|
|
475
|
+
fixable: false
|
|
476
|
+
)
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# graph_invariants — cross-intent I1/I3/I4 checks (intent 68). I1/I3/I4 are
|
|
480
|
+
# defined within a single store's id space (bare ids resolve within the same
|
|
481
|
+
# store), so build the `nodes` map per scope and run validate_graph per scope.
|
|
482
|
+
# I2 asymmetry (a relational chain entry with no reciprocal sources) is NEVER
|
|
483
|
+
# flagged: validate_graph does not compute it.
|
|
484
|
+
checks.concat(graph_invariant_checks(intent_dirs))
|
|
485
|
+
|
|
486
|
+
# cross_store_resolution — RESOLVES (not just shape-checks) every cross-store
|
|
487
|
+
# `store:id` ref against the FULL store family via the relocation map
|
|
488
|
+
# (relocation consulted first), closing the shape-only gap i1/i3/i4 leave open.
|
|
489
|
+
# Resolution always spans all stores even under `--store` scoping; only the
|
|
490
|
+
# REPORTED findings are filtered to refs originating in the scoped store(s).
|
|
491
|
+
checks << cross_store_resolution_check(scopes: scopes)
|
|
492
|
+
|
|
493
|
+
# graph_links_projection — the `## Links` section of every intent must EQUAL its
|
|
494
|
+
# canonical I5 frontmatter projection (intent 72), in BOTH set membership AND
|
|
495
|
+
# ordering (sources first, then chain). Recomputes the projection from each
|
|
496
|
+
# intent's sources/chain + the on-disk basenames using the SAME resolver the
|
|
497
|
+
# scripts/project-links tool uses, so the two can never diverge. Resolution
|
|
498
|
+
# spans all stores; only the REPORTED findings are filtered to the scoped store.
|
|
499
|
+
checks << links_projection_check(scopes: scopes)
|
|
500
|
+
|
|
501
|
+
checks
|
|
338
502
|
end
|
|
339
503
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
504
|
+
# Build the cross-store node maps (basename + label per store) + relocation map
|
|
505
|
+
# from ALL stores, then for every intent compute its canonical `## Links`
|
|
506
|
+
# projection and flag any whose ACTUAL `## Links` section differs (membership or
|
|
507
|
+
# ordering drift), or whose projection raises UnresolvedRef. `scopes` (nil = full
|
|
508
|
+
# run) filters only the REPORTED findings by origin scope.
|
|
509
|
+
def links_projection_check(scopes: nil)
|
|
510
|
+
all_dirs = all_intent_dirs
|
|
511
|
+
|
|
512
|
+
store_index = Hash.new { |h, k| h[k] = [] }
|
|
513
|
+
node_index = Hash.new { |h, k| h[k] = {} }
|
|
514
|
+
intents = [] # { scope:, id:, sources:, chain:, path: }
|
|
515
|
+
|
|
516
|
+
all_dirs.each do |d|
|
|
517
|
+
md = File.join(d[:path], "#{d[:name]}.md")
|
|
518
|
+
next unless File.exist?(md)
|
|
519
|
+
|
|
520
|
+
fm = parse_frontmatter(md)
|
|
521
|
+
next unless fm.is_a?(Hash) && fm["id"]
|
|
522
|
+
|
|
523
|
+
id = fm["id"].to_s
|
|
524
|
+
store_index[d[:scope]] << id
|
|
525
|
+
node_index[d[:scope]][id] = { basename: d[:name], label: fm["intent"].to_s.strip }
|
|
526
|
+
intents << {
|
|
527
|
+
scope: d[:scope], id: id, path: md,
|
|
528
|
+
sources: Array(fm["sources"]).map(&:to_s),
|
|
529
|
+
chain: Array(fm["chain"]).map(&:to_s),
|
|
530
|
+
}
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
relocation_map = GraphRebuild.build_relocation_map(cross_store_index_texts)
|
|
534
|
+
|
|
535
|
+
findings = []
|
|
536
|
+
intents.each do |node|
|
|
537
|
+
next if scopes && !scopes.include?(node[:scope])
|
|
538
|
+
|
|
539
|
+
resolve = ->(ref) do
|
|
540
|
+
LinksProjection.resolve_ref_projection(
|
|
541
|
+
ref, referer_store: node[:scope],
|
|
542
|
+
relocation_map: relocation_map, store_index: store_index, node_index: node_index
|
|
543
|
+
)
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
begin
|
|
547
|
+
expected = LinksProjection.section(sources: node[:sources], chain: node[:chain], resolve: resolve)
|
|
548
|
+
actual = actual_links_section(node[:path])
|
|
549
|
+
rescue LinksProjection::UnresolvedRef => e
|
|
550
|
+
findings << "#{node[:id]} ## Links projection failed: #{e.message}"
|
|
551
|
+
next
|
|
552
|
+
rescue LinksSection::AmbiguousLinks => e
|
|
553
|
+
findings << "#{node[:id]} ## Links ambiguous: #{e.message}"
|
|
554
|
+
next
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
next if actual == expected
|
|
558
|
+
|
|
559
|
+
findings << "#{node[:id]} ## Links does not match its frontmatter projection (membership/ordering drift)"
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
graph_finding_check(
|
|
563
|
+
"graph_links_projection", findings,
|
|
564
|
+
"Every intent's ## Links equals its frontmatter projection (membership and ordering)",
|
|
565
|
+
"Run scripts/project-links to regenerate the canonical ## Links sections"
|
|
351
566
|
)
|
|
352
567
|
end
|
|
353
568
|
|
|
354
|
-
|
|
355
|
-
|
|
569
|
+
# Extract a file's ACTUAL REAL `## Links` section text (FENCE-AWARE), normalized
|
|
570
|
+
# to the canonical block shape the projection emits. Delegates to the shared
|
|
571
|
+
# LinksSection.extract_section so the doctor check and the project-links tool
|
|
572
|
+
# agree on the section location and never match a `## Links` heading inside an
|
|
573
|
+
# example code fence. Returns "" when the section is absent (which differs from
|
|
574
|
+
# any real projection, so a missing section is a finding).
|
|
575
|
+
def actual_links_section(path)
|
|
576
|
+
LinksSection.extract_section(IntentValidator.body_of(File.read(path)))
|
|
577
|
+
end
|
|
356
578
|
|
|
357
|
-
#
|
|
579
|
+
# Build the relocation map + cross-store store_index from ALL stores, then for
|
|
580
|
+
# every intent's cross-store `sources`/`chain` ref resolve it and flag:
|
|
581
|
+
# - DEAD: the target resolves nowhere
|
|
582
|
+
# - RELOCATED-STALE: the ref points at an old location the relocation log has
|
|
583
|
+
# moved (the resolved location differs from the literal ref), e.g. the
|
|
584
|
+
# `global:24` id-reuse hazard that direct resolution would silently accept.
|
|
585
|
+
# `scopes` (nil = full run) filters only the REPORTED findings by origin scope.
|
|
586
|
+
def cross_store_resolution_check(scopes: nil)
|
|
587
|
+
all_dirs = all_intent_dirs
|
|
588
|
+
|
|
589
|
+
# Per-scope node maps + store_index over the WHOLE family.
|
|
590
|
+
nodes_by_scope = Hash.new { |h, k| h[k] = {} }
|
|
591
|
+
store_index = Hash.new { |h, k| h[k] = [] }
|
|
592
|
+
all_dirs.each do |d|
|
|
593
|
+
md = File.join(d[:path], "#{d[:name]}.md")
|
|
594
|
+
next unless File.exist?(md)
|
|
595
|
+
|
|
596
|
+
fm = parse_frontmatter(md)
|
|
597
|
+
next unless fm.is_a?(Hash) && fm["id"]
|
|
598
|
+
|
|
599
|
+
id = fm["id"].to_s
|
|
600
|
+
store_index[d[:scope]] << id
|
|
601
|
+
nodes_by_scope[d[:scope]][id] = {
|
|
602
|
+
sources: Array(fm["sources"]).map(&:to_s),
|
|
603
|
+
chain: Array(fm["chain"]).map(&:to_s),
|
|
604
|
+
}
|
|
605
|
+
end
|
|
358
606
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
607
|
+
relocation_map = GraphRebuild.build_relocation_map(cross_store_index_texts)
|
|
608
|
+
|
|
609
|
+
findings = []
|
|
610
|
+
nodes_by_scope.each do |scope, nodes|
|
|
611
|
+
next if scopes && !scopes.include?(scope)
|
|
612
|
+
|
|
613
|
+
nodes.each do |id, edges|
|
|
614
|
+
%i[sources chain].each do |field|
|
|
615
|
+
edges[field].each do |ref|
|
|
616
|
+
next unless ref.include?(":") # only cross-store refs are resolved here
|
|
617
|
+
|
|
618
|
+
res = GraphRebuild.resolve_ref(ref, referer_store: scope,
|
|
619
|
+
relocation_map: relocation_map,
|
|
620
|
+
store_index: store_index)
|
|
621
|
+
case res[:status]
|
|
622
|
+
when :dead
|
|
623
|
+
findings << "#{id}.#{field} cross-store ref #{ref} resolves to no intent (dead)"
|
|
624
|
+
when :same_store
|
|
625
|
+
findings << "#{id}.#{field} cross-store ref #{ref} is relocated-stale (now same-store #{res[:id]})"
|
|
626
|
+
when :cross_store
|
|
627
|
+
findings << "#{id}.#{field} cross-store ref #{ref} is relocated-stale (now #{res[:ref]})" if res[:ref] != ref
|
|
628
|
+
end
|
|
629
|
+
end
|
|
630
|
+
end
|
|
631
|
+
end
|
|
632
|
+
end
|
|
363
633
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
fixable: false
|
|
634
|
+
graph_finding_check(
|
|
635
|
+
"graph_cross_store_resolution", findings,
|
|
636
|
+
"Every cross-store sources/chain ref resolves to a live, current intent",
|
|
637
|
+
"Run scripts/rebuild-graph to repoint/collapse/drop stale cross-store refs"
|
|
369
638
|
)
|
|
370
|
-
return checks
|
|
371
639
|
end
|
|
372
640
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
641
|
+
# { store_key => INDEX.md text } for every store (global + all projects), for the
|
|
642
|
+
# relocation-map builder. Reads INDEX.md one level above each store dir.
|
|
643
|
+
def cross_store_index_texts
|
|
644
|
+
texts = {}
|
|
645
|
+
global_index = File.join(plastic_home, "INDEX.md")
|
|
646
|
+
texts["global"] = File.read(global_index) if File.exist?(global_index)
|
|
647
|
+
|
|
648
|
+
projects_root = File.join(plastic_home, "projects")
|
|
649
|
+
if File.directory?(projects_root)
|
|
650
|
+
Dir.children(projects_root).each do |project|
|
|
651
|
+
idx = File.join(projects_root, project, "INDEX.md")
|
|
652
|
+
texts["project:#{project}"] = File.read(idx) if File.exist?(idx)
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
texts
|
|
378
656
|
end
|
|
379
657
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
658
|
+
# Build a per-scope `nodes` map and surface IntentValidator.validate_graph
|
|
659
|
+
# findings as warn-level checks. Scope-aware (the caller already filtered
|
|
660
|
+
# `intent_dirs` by scope), so a `global` id is not falsely flagged as a dangler
|
|
661
|
+
# when only a `project:` store is loaded, and vice versa.
|
|
662
|
+
def graph_invariant_checks(intent_dirs)
|
|
663
|
+
nodes_by_scope = Hash.new { |h, k| h[k] = {} }
|
|
664
|
+
intent_dirs.each do |d|
|
|
665
|
+
md_path = File.join(d[:path], "#{d[:name]}.md")
|
|
666
|
+
next unless File.exist?(md_path)
|
|
667
|
+
|
|
668
|
+
fm = parse_frontmatter(md_path)
|
|
669
|
+
next unless fm.is_a?(Hash) && fm["id"]
|
|
670
|
+
|
|
671
|
+
nodes_by_scope[d[:scope]][fm["id"].to_s] = {
|
|
672
|
+
sources: Array(fm["sources"]).map(&:to_s),
|
|
673
|
+
chain: Array(fm["chain"]).map(&:to_s),
|
|
674
|
+
}
|
|
675
|
+
end
|
|
386
676
|
|
|
387
|
-
|
|
388
|
-
|
|
677
|
+
i1 = []
|
|
678
|
+
i3 = []
|
|
679
|
+
i4 = []
|
|
680
|
+
nodes_by_scope.each_value do |nodes|
|
|
681
|
+
findings = IntentValidator.validate_graph(nodes)
|
|
682
|
+
i1.concat(findings[:i1])
|
|
683
|
+
i3.concat(findings[:i3])
|
|
684
|
+
i4.concat(findings[:i4])
|
|
685
|
+
end
|
|
389
686
|
|
|
390
|
-
|
|
391
|
-
checks <<
|
|
392
|
-
|
|
393
|
-
|
|
687
|
+
checks = []
|
|
688
|
+
checks << graph_finding_check(
|
|
689
|
+
"graph_i1_reciprocity", i1,
|
|
690
|
+
"Every sources edge has its reciprocal chain entry (I1)",
|
|
691
|
+
"Run new-intent / the rebuild so each source intent's chain backlinks the child"
|
|
394
692
|
)
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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"
|
|
693
|
+
checks << graph_finding_check(
|
|
694
|
+
"graph_i3_disjoint", i3,
|
|
695
|
+
"No intent lists the same id in both sources and chain (I3)",
|
|
696
|
+
"Remove the overlapping id from either sources or chain"
|
|
401
697
|
)
|
|
698
|
+
checks << graph_finding_check(
|
|
699
|
+
"graph_i4_danglers", i4,
|
|
700
|
+
"Every sources/chain id resolves to a real intent (I4)",
|
|
701
|
+
"Fix or remove the dangling id reference"
|
|
702
|
+
)
|
|
703
|
+
checks
|
|
402
704
|
end
|
|
403
705
|
|
|
404
|
-
#
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
706
|
+
# One graph check: pass when `findings` is empty, otherwise warn (never fail, so
|
|
707
|
+
# an existing store does not turn red on a graph finding). I1/I4 are auto-fixable.
|
|
708
|
+
def graph_finding_check(name, findings, pass_message, fix_hint)
|
|
709
|
+
if findings.empty?
|
|
710
|
+
check(category: "conventions", name: name, status: "pass", message: pass_message)
|
|
711
|
+
else
|
|
712
|
+
check(
|
|
713
|
+
category: "conventions", name: name, status: "warn",
|
|
714
|
+
message: "#{findings.size} #{name} violation(s)",
|
|
715
|
+
details: findings,
|
|
716
|
+
fixable: name != "graph_i3_disjoint",
|
|
717
|
+
fix_hint: fix_hint
|
|
718
|
+
)
|
|
719
|
+
end
|
|
720
|
+
end
|
|
408
721
|
|
|
409
|
-
|
|
722
|
+
# --- Check category 3: Agent registration ---
|
|
410
723
|
|
|
411
|
-
|
|
412
|
-
checks
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
724
|
+
def check_agent_registration(agent_key)
|
|
725
|
+
checks = []
|
|
726
|
+
config = agents[agent_key]
|
|
727
|
+
agent_dir = config[:dir]
|
|
728
|
+
|
|
729
|
+
unless File.directory?(agent_dir)
|
|
730
|
+
checks << check(
|
|
731
|
+
category: "agent_registration", name: "agent_dir_exists", status: "fail",
|
|
732
|
+
message: "Agent directory #{tilde(agent_dir)} not found — #{config[:name]} may not be installed",
|
|
733
|
+
fixable: false
|
|
734
|
+
)
|
|
735
|
+
return checks
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
case agent_key
|
|
739
|
+
when "claude"
|
|
740
|
+
checks += check_claude_registration(agent_dir)
|
|
741
|
+
else
|
|
742
|
+
checks += check_generic_agent_registration(agent_key, agent_dir)
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
checks
|
|
423
746
|
end
|
|
424
747
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
748
|
+
def check_claude_registration(agent_dir)
|
|
749
|
+
checks = []
|
|
750
|
+
hooks_dir = File.join(agent_dir, "hooks")
|
|
428
751
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
end
|
|
752
|
+
# hooks_exist
|
|
753
|
+
missing_hooks = CLAUDE_HOOK_SCRIPTS.reject { |h| File.exist?(File.join(hooks_dir, h)) }
|
|
754
|
+
|
|
755
|
+
if missing_hooks.empty?
|
|
756
|
+
checks << check(
|
|
757
|
+
category: "agent_registration", name: "hooks_exist", status: "pass",
|
|
758
|
+
message: "All #{CLAUDE_HOOK_SCRIPTS.size} expected hook scripts exist"
|
|
759
|
+
)
|
|
760
|
+
else
|
|
761
|
+
checks << check(
|
|
762
|
+
category: "agent_registration", name: "hooks_exist", status: "fail",
|
|
763
|
+
message: "#{missing_hooks.size} hook script(s) missing",
|
|
764
|
+
details: missing_hooks.map { |h| "#{tilde(hooks_dir)}/#{h}" },
|
|
765
|
+
fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
|
|
766
|
+
)
|
|
445
767
|
end
|
|
446
768
|
|
|
447
|
-
|
|
769
|
+
# hooks_executable
|
|
770
|
+
existing_hooks = CLAUDE_HOOK_SCRIPTS
|
|
771
|
+
.map { |h| File.join(hooks_dir, h) }
|
|
772
|
+
.select { |p| File.exist?(p) }
|
|
773
|
+
|
|
774
|
+
non_executable = existing_hooks.reject { |p| File.executable?(p) }
|
|
775
|
+
|
|
776
|
+
if non_executable.empty?
|
|
448
777
|
checks << check(
|
|
449
|
-
category: "agent_registration", name: "
|
|
450
|
-
message: "All
|
|
778
|
+
category: "agent_registration", name: "hooks_executable", status: "pass",
|
|
779
|
+
message: "All existing hook scripts are executable"
|
|
451
780
|
)
|
|
452
781
|
else
|
|
782
|
+
checks << check(
|
|
783
|
+
category: "agent_registration", name: "hooks_executable", status: "fail",
|
|
784
|
+
message: "#{non_executable.size} hook script(s) not executable",
|
|
785
|
+
details: non_executable.map { |p| tilde(p) },
|
|
786
|
+
fixable: true, fix_hint: "chmod +x on the listed files"
|
|
787
|
+
)
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
# hooks_registered — settings.json has Plastic hooks for required events
|
|
791
|
+
settings_path = File.join(agent_dir, "settings.json")
|
|
792
|
+
settings = read_json_safe(settings_path)
|
|
793
|
+
|
|
794
|
+
if settings.nil?
|
|
453
795
|
checks << check(
|
|
454
796
|
category: "agent_registration", name: "hooks_registered", status: "fail",
|
|
455
|
-
message: "#{
|
|
456
|
-
details: missing_events,
|
|
797
|
+
message: "Cannot read #{tilde(settings_path)} — file missing or invalid",
|
|
457
798
|
fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
|
|
458
799
|
)
|
|
459
|
-
|
|
460
|
-
|
|
800
|
+
else
|
|
801
|
+
hooks = settings["hooks"] || {}
|
|
802
|
+
missing_events = CLAUDE_HOOK_EVENTS.reject do |event|
|
|
803
|
+
groups = hooks[event]
|
|
804
|
+
next false unless groups.is_a?(Array)
|
|
805
|
+
|
|
806
|
+
groups.any? do |group|
|
|
807
|
+
group.is_a?(Hash) && group["hooks"].is_a?(Array) &&
|
|
808
|
+
group["hooks"].any? { |h| h["command"].to_s.include?("plastic-") }
|
|
809
|
+
end
|
|
810
|
+
end
|
|
461
811
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
812
|
+
if missing_events.empty?
|
|
813
|
+
checks << check(
|
|
814
|
+
category: "agent_registration", name: "hooks_registered", status: "pass",
|
|
815
|
+
message: "All #{CLAUDE_HOOK_EVENTS.size} hook events registered in settings.json"
|
|
816
|
+
)
|
|
817
|
+
else
|
|
818
|
+
checks << check(
|
|
819
|
+
category: "agent_registration", name: "hooks_registered", status: "fail",
|
|
820
|
+
message: "#{missing_events.size} hook event(s) not registered in settings.json",
|
|
821
|
+
details: missing_events,
|
|
822
|
+
fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
|
|
823
|
+
)
|
|
824
|
+
end
|
|
825
|
+
end
|
|
476
826
|
|
|
477
|
-
|
|
478
|
-
|
|
827
|
+
# skills_exist — flat, hyphen-namespaced personal skills (plastic-<name>/)
|
|
828
|
+
checks << flat_skills_check(agent_dir, "--claude")
|
|
479
829
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
config = AGENTS[agent_key]
|
|
830
|
+
# agents_exist — auto-mode role files (plastic-*.md) synced into <dir>/agents
|
|
831
|
+
checks << flat_agents_check(agent_dir, "--claude")
|
|
483
832
|
|
|
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
|
-
)
|
|
833
|
+
checks
|
|
497
834
|
end
|
|
498
835
|
|
|
499
|
-
|
|
500
|
-
|
|
836
|
+
# Plastic skills install as ~/.claude/skills/plastic-<name>/SKILL.md. Pass if at
|
|
837
|
+
# least one such skill is present.
|
|
838
|
+
def flat_skills_check(agent_dir, installer_flag)
|
|
839
|
+
skills_root = File.join(agent_dir, "skills")
|
|
840
|
+
found = Dir.glob(File.join(skills_root, "plastic-*", "SKILL.md"))
|
|
501
841
|
|
|
502
|
-
|
|
842
|
+
if !found.empty?
|
|
843
|
+
check(
|
|
844
|
+
category: "agent_registration", name: "skills_exist", status: "pass",
|
|
845
|
+
message: "#{found.size} plastic-* skill(s) installed in #{tilde(skills_root)}"
|
|
846
|
+
)
|
|
847
|
+
else
|
|
848
|
+
check(
|
|
849
|
+
category: "agent_registration", name: "skills_exist", status: "fail",
|
|
850
|
+
message: "No plastic-* skills found in #{tilde(skills_root)}",
|
|
851
|
+
fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest #{installer_flag}"
|
|
852
|
+
)
|
|
853
|
+
end
|
|
854
|
+
end
|
|
503
855
|
|
|
504
|
-
|
|
505
|
-
|
|
856
|
+
# Auto-mode role agents install as <dir>/agents/plastic-*.md. Pass if at least
|
|
857
|
+
# one such role file is present (the installer syncs agents/ on every install).
|
|
858
|
+
def flat_agents_check(agent_dir, installer_flag)
|
|
859
|
+
agents_root = File.join(agent_dir, "agents")
|
|
860
|
+
found = Dir.glob(File.join(agents_root, "plastic-*.md"))
|
|
506
861
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
)
|
|
862
|
+
if !found.empty?
|
|
863
|
+
check(
|
|
864
|
+
category: "agent_registration", name: "agents_exist", status: "pass",
|
|
865
|
+
message: "#{found.size} plastic-* agent(s) installed in #{tilde(agents_root)}"
|
|
866
|
+
)
|
|
867
|
+
else
|
|
868
|
+
check(
|
|
869
|
+
category: "agent_registration", name: "agents_exist", status: "fail",
|
|
870
|
+
message: "No plastic-* agents found in #{tilde(agents_root)}",
|
|
871
|
+
fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest #{installer_flag}"
|
|
872
|
+
)
|
|
873
|
+
end
|
|
520
874
|
end
|
|
521
875
|
|
|
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
|
|
876
|
+
def check_generic_agent_registration(agent_key, agent_dir)
|
|
877
|
+
checks = []
|
|
878
|
+
config = agents[agent_key]
|
|
536
879
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
880
|
+
# For codex/hermes: just check skills exist (no settings.json hooks)
|
|
881
|
+
checks << flat_skills_check(agent_dir, "--#{agent_key}")
|
|
882
|
+
checks << flat_agents_check(agent_dir, "--#{agent_key}")
|
|
540
883
|
|
|
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
|
-
)
|
|
884
|
+
checks
|
|
553
885
|
end
|
|
554
886
|
|
|
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) }
|
|
887
|
+
# --- Check category 4: Core files ---
|
|
560
888
|
|
|
561
|
-
|
|
889
|
+
def check_core_files(agent_key)
|
|
890
|
+
checks = []
|
|
562
891
|
|
|
563
|
-
|
|
892
|
+
# plastic_md
|
|
893
|
+
plastic_md = File.join(plastic_home, "PLASTIC.md")
|
|
894
|
+
if File.exist?(plastic_md)
|
|
564
895
|
checks << check(
|
|
565
|
-
category: "core_files", name: "
|
|
566
|
-
message: "
|
|
896
|
+
category: "core_files", name: "plastic_md", status: "pass",
|
|
897
|
+
message: "PLASTIC.md exists"
|
|
567
898
|
)
|
|
568
899
|
else
|
|
569
900
|
checks << check(
|
|
570
|
-
category: "core_files", name: "
|
|
571
|
-
message: "#{
|
|
572
|
-
|
|
573
|
-
fixable: true, fix_hint: "chmod +x on the listed files"
|
|
901
|
+
category: "core_files", name: "plastic_md", status: "fail",
|
|
902
|
+
message: "PLASTIC.md not found at #{tilde(plastic_md)}",
|
|
903
|
+
fixable: true, fix_hint: "Re-run the Plastic installer to restore core files"
|
|
574
904
|
)
|
|
575
905
|
end
|
|
576
|
-
end
|
|
577
906
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
907
|
+
# version_file
|
|
908
|
+
version_path = File.join(plastic_home, "VERSION")
|
|
909
|
+
if File.exist?(version_path)
|
|
910
|
+
checks << check(
|
|
911
|
+
category: "core_files", name: "version_file", status: "pass",
|
|
912
|
+
message: "VERSION file exists"
|
|
913
|
+
)
|
|
914
|
+
else
|
|
915
|
+
checks << check(
|
|
916
|
+
category: "core_files", name: "version_file", status: "fail",
|
|
917
|
+
message: "VERSION file not found at #{tilde(version_path)}",
|
|
918
|
+
fixable: true, fix_hint: "Re-run the Plastic installer to restore core files"
|
|
919
|
+
)
|
|
920
|
+
end
|
|
581
921
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
else File.join(agent_config[:dir], "plastic", "VERSION")
|
|
586
|
-
end
|
|
922
|
+
# scripts_present
|
|
923
|
+
scripts_dir = File.join(plastic_home, "scripts")
|
|
924
|
+
missing_scripts = REQUIRED_SCRIPTS.reject { |s| File.exist?(File.join(scripts_dir, s)) }
|
|
587
925
|
|
|
588
|
-
if
|
|
589
|
-
|
|
926
|
+
if missing_scripts.empty?
|
|
927
|
+
checks << check(
|
|
928
|
+
category: "core_files", name: "scripts_present", status: "pass",
|
|
929
|
+
message: "All #{REQUIRED_SCRIPTS.size} required scripts present"
|
|
930
|
+
)
|
|
931
|
+
else
|
|
932
|
+
checks << check(
|
|
933
|
+
category: "core_files", name: "scripts_present", status: "fail",
|
|
934
|
+
message: "#{missing_scripts.size} required script(s) missing from #{tilde(scripts_dir)}",
|
|
935
|
+
details: missing_scripts,
|
|
936
|
+
fixable: true, fix_hint: "Re-run the Plastic installer to restore scripts"
|
|
937
|
+
)
|
|
938
|
+
end
|
|
939
|
+
|
|
940
|
+
# scripts_executable
|
|
941
|
+
if File.directory?(scripts_dir)
|
|
942
|
+
script_files = Dir.children(scripts_dir)
|
|
943
|
+
.map { |f| File.join(scripts_dir, f) }
|
|
944
|
+
.select { |f| File.file?(f) }
|
|
590
945
|
|
|
591
|
-
|
|
946
|
+
non_executable = script_files.reject { |f| File.executable?(f) }
|
|
947
|
+
|
|
948
|
+
if non_executable.empty?
|
|
592
949
|
checks << check(
|
|
593
|
-
category: "core_files", name: "
|
|
594
|
-
message: "
|
|
950
|
+
category: "core_files", name: "scripts_executable", status: "pass",
|
|
951
|
+
message: "All scripts in #{tilde(scripts_dir)} are executable"
|
|
595
952
|
)
|
|
953
|
+
else
|
|
954
|
+
checks << check(
|
|
955
|
+
category: "core_files", name: "scripts_executable", status: "fail",
|
|
956
|
+
message: "#{non_executable.size} script(s) not executable",
|
|
957
|
+
details: non_executable.map { |f| tilde(f) },
|
|
958
|
+
fixable: true, fix_hint: "chmod +x on the listed files"
|
|
959
|
+
)
|
|
960
|
+
end
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
# version_match — compare global VERSION with agent-side VERSION
|
|
964
|
+
global_version = read_version
|
|
965
|
+
agent_config = agents[agent_key]
|
|
966
|
+
|
|
967
|
+
if global_version && agent_config
|
|
968
|
+
agent_version_path = case agent_key
|
|
969
|
+
when "claude" then File.join(agent_config[:dir], "plastic", "VERSION")
|
|
970
|
+
else File.join(agent_config[:dir], "plastic", "VERSION")
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
if File.exist?(agent_version_path)
|
|
974
|
+
agent_version = File.read(agent_version_path).strip
|
|
975
|
+
|
|
976
|
+
if global_version == agent_version
|
|
977
|
+
checks << check(
|
|
978
|
+
category: "core_files", name: "version_match", status: "pass",
|
|
979
|
+
message: "Global VERSION (#{global_version}) matches agent-side VERSION"
|
|
980
|
+
)
|
|
981
|
+
else
|
|
982
|
+
checks << check(
|
|
983
|
+
category: "core_files", name: "version_match", status: "warn",
|
|
984
|
+
message: "Version mismatch: global=#{global_version}, agent=#{agent_version}",
|
|
985
|
+
details: [
|
|
986
|
+
"#{tilde(File.join(plastic_home, "VERSION"))}: #{global_version}",
|
|
987
|
+
"#{tilde(agent_version_path)}: #{agent_version}",
|
|
988
|
+
],
|
|
989
|
+
fixable: false
|
|
990
|
+
)
|
|
991
|
+
end
|
|
596
992
|
else
|
|
597
993
|
checks << check(
|
|
598
994
|
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
|
-
],
|
|
995
|
+
message: "Agent-side VERSION file not found at #{tilde(agent_version_path)}",
|
|
604
996
|
fixable: false
|
|
605
997
|
)
|
|
606
998
|
end
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
checks
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
# --- Check category: manifest sync (binary core integrity) ---
|
|
1005
|
+
|
|
1006
|
+
# Verify, for BOTH the global manifest and the agent-side manifest, that every
|
|
1007
|
+
# file listed exists and its current SHA256 matches the recorded hash.
|
|
1008
|
+
# - GLOBAL manifest: <plastic_home>/manifest.json
|
|
1009
|
+
# - AGENT-side manifest: claude -> <dir>/plastic/manifest.json
|
|
1010
|
+
# other -> <dir>/plastic-manifest.json
|
|
1011
|
+
# Manifest format: { "version", "created", "files": { abs_path => sha256 } }.
|
|
1012
|
+
# A missing manifest is a fail; any missing/mismatched listed file is a fail;
|
|
1013
|
+
# otherwise a single pass per manifest.
|
|
1014
|
+
def check_manifest_sync(agent_key)
|
|
1015
|
+
checks = []
|
|
1016
|
+
|
|
1017
|
+
global_manifest = File.join(plastic_home, "manifest.json")
|
|
1018
|
+
checks << verify_manifest(global_manifest, "global")
|
|
1019
|
+
|
|
1020
|
+
agent_dir = agents[agent_key][:dir]
|
|
1021
|
+
agent_manifest = if agent_key == "claude"
|
|
1022
|
+
File.join(agent_dir, "plastic", "manifest.json")
|
|
1023
|
+
else
|
|
1024
|
+
File.join(agent_dir, "plastic-manifest.json")
|
|
1025
|
+
end
|
|
1026
|
+
checks << verify_manifest(agent_manifest, "agent")
|
|
1027
|
+
|
|
1028
|
+
checks
|
|
1029
|
+
end
|
|
1030
|
+
|
|
1031
|
+
# Check one manifest file. Returns a single check (pass or fail).
|
|
1032
|
+
def verify_manifest(manifest_path, label)
|
|
1033
|
+
unless File.exist?(manifest_path)
|
|
1034
|
+
return check(
|
|
1035
|
+
category: "manifest_sync", name: "#{label}_manifest", status: "fail",
|
|
1036
|
+
message: "#{label} core manifest missing — re-run the Plastic installer",
|
|
1037
|
+
details: [tilde(manifest_path)],
|
|
1038
|
+
fixable: true, fix_hint: "Re-run the Plastic installer"
|
|
1039
|
+
)
|
|
1040
|
+
end
|
|
1041
|
+
|
|
1042
|
+
data = read_json_safe(manifest_path)
|
|
1043
|
+
files = data.is_a?(Hash) ? data["files"] : nil
|
|
1044
|
+
unless files.is_a?(Hash)
|
|
1045
|
+
return check(
|
|
1046
|
+
category: "manifest_sync", name: "#{label}_manifest", status: "fail",
|
|
1047
|
+
message: "#{label} core manifest unreadable or malformed — re-run the Plastic installer",
|
|
1048
|
+
details: [tilde(manifest_path)],
|
|
1049
|
+
fixable: true, fix_hint: "Re-run the Plastic installer"
|
|
1050
|
+
)
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
missing = []
|
|
1054
|
+
mismatched = []
|
|
1055
|
+
files.each do |path, recorded|
|
|
1056
|
+
unless File.exist?(path)
|
|
1057
|
+
missing << tilde(path)
|
|
1058
|
+
next
|
|
1059
|
+
end
|
|
1060
|
+
actual = Digest::SHA256.file(path).hexdigest
|
|
1061
|
+
mismatched << tilde(path) if actual != recorded
|
|
1062
|
+
end
|
|
1063
|
+
|
|
1064
|
+
if missing.empty? && mismatched.empty?
|
|
1065
|
+
check(
|
|
1066
|
+
category: "manifest_sync", name: "#{label}_manifest", status: "pass",
|
|
1067
|
+
message: "#{label} manifest: all #{files.size} tracked file(s) present and matching"
|
|
1068
|
+
)
|
|
607
1069
|
else
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
1070
|
+
details = []
|
|
1071
|
+
details += missing.map { |p| "missing: #{p}" }
|
|
1072
|
+
details += mismatched.map { |p| "modified: #{p}" }
|
|
1073
|
+
check(
|
|
1074
|
+
category: "manifest_sync", name: "#{label}_manifest", status: "fail",
|
|
1075
|
+
message: "#{label} manifest out of sync: #{missing.size} missing, #{mismatched.size} modified",
|
|
1076
|
+
details: details,
|
|
1077
|
+
fixable: true, fix_hint: "Re-run the Plastic installer to restore tracked files"
|
|
612
1078
|
)
|
|
613
1079
|
end
|
|
614
1080
|
end
|
|
615
1081
|
|
|
616
|
-
|
|
617
|
-
end
|
|
1082
|
+
# --- Check category 5: Project stores ---
|
|
618
1083
|
|
|
619
|
-
|
|
1084
|
+
def check_project_stores
|
|
1085
|
+
checks = []
|
|
620
1086
|
|
|
621
|
-
|
|
622
|
-
|
|
1087
|
+
projects_yml_path = File.join(plastic_home, "projects.yml")
|
|
1088
|
+
projects_data = load_yaml_safe(projects_yml_path)
|
|
623
1089
|
|
|
624
|
-
|
|
625
|
-
|
|
1090
|
+
if projects_data.nil?
|
|
1091
|
+
checks << check(
|
|
1092
|
+
category: "project_stores", name: "projects_yml", status: "warn",
|
|
1093
|
+
message: "projects.yml not found or invalid at #{tilde(projects_yml_path)}",
|
|
1094
|
+
fixable: true, fix_hint: "Re-run the Plastic installer to restore projects.yml"
|
|
1095
|
+
)
|
|
1096
|
+
return checks
|
|
1097
|
+
end
|
|
626
1098
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
1099
|
+
projects = projects_data["projects"]
|
|
1100
|
+
unless projects.is_a?(Hash) && !projects.empty?
|
|
1101
|
+
# No projects registered — nothing to check
|
|
1102
|
+
checks << check(
|
|
1103
|
+
category: "project_stores", name: "projects_yml", status: "pass",
|
|
1104
|
+
message: "projects.yml is valid (#{projects.is_a?(Hash) ? projects.size : 0} projects registered)"
|
|
1105
|
+
)
|
|
1106
|
+
return checks
|
|
1107
|
+
end
|
|
635
1108
|
|
|
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
|
|
1109
|
+
projects.each do |slug, project_info|
|
|
1110
|
+
checks += check_project_store(slug, project_info)
|
|
1111
|
+
end
|
|
645
1112
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
index_content = File.exist?(index_path) ? File.read(index_path) : ""
|
|
1113
|
+
checks
|
|
1114
|
+
end
|
|
649
1115
|
|
|
650
|
-
|
|
651
|
-
|
|
1116
|
+
# Per-project validation extracted from check_project_stores so a single
|
|
1117
|
+
# project can be checked in isolation (used by `--store <slug>`).
|
|
1118
|
+
def check_project_store(slug, project_info)
|
|
1119
|
+
checks = []
|
|
1120
|
+
project_dir = File.join(plastic_home, "projects", slug)
|
|
652
1121
|
|
|
653
1122
|
# project_dir_exists
|
|
654
1123
|
if File.directory?(project_dir)
|
|
@@ -665,6 +1134,22 @@ def check_project_stores
|
|
|
665
1134
|
)
|
|
666
1135
|
end
|
|
667
1136
|
|
|
1137
|
+
# project_store_dir
|
|
1138
|
+
store_dir = File.join(project_dir, "store")
|
|
1139
|
+
if File.directory?(store_dir)
|
|
1140
|
+
checks << check(
|
|
1141
|
+
category: "project_stores", name: "project_store_dir", status: "pass",
|
|
1142
|
+
message: "Store directory exists for '#{slug}'"
|
|
1143
|
+
)
|
|
1144
|
+
else
|
|
1145
|
+
checks << check(
|
|
1146
|
+
category: "project_stores", name: "project_store_dir", status: "warn",
|
|
1147
|
+
message: "Store directory missing for '#{slug}'",
|
|
1148
|
+
details: [tilde(store_dir)],
|
|
1149
|
+
fixable: true, fix_hint: "Run: provision-project-store #{slug}"
|
|
1150
|
+
)
|
|
1151
|
+
end
|
|
1152
|
+
|
|
668
1153
|
# project_index
|
|
669
1154
|
project_index = File.join(project_dir, "INDEX.md")
|
|
670
1155
|
if File.exist?(project_index)
|
|
@@ -682,7 +1167,7 @@ def check_project_stores
|
|
|
682
1167
|
end
|
|
683
1168
|
|
|
684
1169
|
# project_yml_exists
|
|
685
|
-
project_yml_path = File.join(
|
|
1170
|
+
project_yml_path = File.join(plastic_home, "projects", slug, "project.yml")
|
|
686
1171
|
project_yml_data = nil
|
|
687
1172
|
|
|
688
1173
|
if File.exist?(project_yml_path)
|
|
@@ -695,7 +1180,7 @@ def check_project_stores
|
|
|
695
1180
|
checks << check(
|
|
696
1181
|
category: "project_stores", name: "project_yml_exists", status: "warn",
|
|
697
1182
|
message: "project.yml missing for project '#{slug}'",
|
|
698
|
-
fixable: true, fix_hint: "Create project.yml from template — see plastic
|
|
1183
|
+
fixable: true, fix_hint: "Create project.yml from template — see plastic-creating-project"
|
|
699
1184
|
)
|
|
700
1185
|
end
|
|
701
1186
|
|
|
@@ -726,10 +1211,10 @@ def check_project_stores
|
|
|
726
1211
|
|
|
727
1212
|
# cross_references — if project has `parent` field, check global store intent tags
|
|
728
1213
|
parent_id = project_info.is_a?(Hash) ? project_info["parent"] : nil
|
|
729
|
-
|
|
1214
|
+
return checks unless parent_id
|
|
730
1215
|
|
|
731
1216
|
# Find the intent directory for the parent ID
|
|
732
|
-
store_dir = File.join(
|
|
1217
|
+
store_dir = File.join(plastic_home, "store")
|
|
733
1218
|
parent_dir = nil
|
|
734
1219
|
if File.directory?(store_dir)
|
|
735
1220
|
parent_dir = Dir.children(store_dir).find { |d| d.start_with?("#{parent_id}--") }
|
|
@@ -741,7 +1226,7 @@ def check_project_stores
|
|
|
741
1226
|
message: "Parent intent '#{parent_id}' for project '#{slug}' not found in global store",
|
|
742
1227
|
fixable: false
|
|
743
1228
|
)
|
|
744
|
-
|
|
1229
|
+
return checks
|
|
745
1230
|
end
|
|
746
1231
|
|
|
747
1232
|
intent_md = File.join(store_dir, parent_dir, "#{parent_dir}.md")
|
|
@@ -753,7 +1238,7 @@ def check_project_stores
|
|
|
753
1238
|
message: "Cannot read frontmatter of parent intent '#{parent_id}' for project '#{slug}'",
|
|
754
1239
|
fixable: false
|
|
755
1240
|
)
|
|
756
|
-
|
|
1241
|
+
return checks
|
|
757
1242
|
end
|
|
758
1243
|
|
|
759
1244
|
tags = fm["tags"]
|
|
@@ -772,160 +1257,273 @@ def check_project_stores
|
|
|
772
1257
|
fixable: false
|
|
773
1258
|
)
|
|
774
1259
|
end
|
|
1260
|
+
|
|
1261
|
+
checks
|
|
775
1262
|
end
|
|
776
1263
|
|
|
777
|
-
|
|
778
|
-
end
|
|
1264
|
+
# --- Check category 6: Deprecations ---
|
|
779
1265
|
|
|
780
|
-
|
|
1266
|
+
def check_deprecations
|
|
1267
|
+
checks = []
|
|
781
1268
|
|
|
782
|
-
|
|
783
|
-
|
|
1269
|
+
deprecations_path = File.join(plastic_home, "deprecations.yml")
|
|
1270
|
+
data = load_yaml_safe(deprecations_path)
|
|
784
1271
|
|
|
785
|
-
|
|
786
|
-
|
|
1272
|
+
if data.nil?
|
|
1273
|
+
checks << check(
|
|
1274
|
+
category: "deprecations", name: "deprecations_file", status: "pass",
|
|
1275
|
+
message: "No deprecations.yml found (nothing to report)"
|
|
1276
|
+
)
|
|
1277
|
+
return checks
|
|
1278
|
+
end
|
|
787
1279
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
1280
|
+
entries = data["deprecations"]
|
|
1281
|
+
unless entries.is_a?(Array) && !entries.empty?
|
|
1282
|
+
checks << check(
|
|
1283
|
+
category: "deprecations", name: "active_deprecations", status: "pass",
|
|
1284
|
+
message: "No deprecation entries found"
|
|
1285
|
+
)
|
|
1286
|
+
return checks
|
|
1287
|
+
end
|
|
1288
|
+
|
|
1289
|
+
current_version = read_version
|
|
1290
|
+
unless current_version
|
|
1291
|
+
checks << check(
|
|
1292
|
+
category: "deprecations", name: "active_deprecations", status: "warn",
|
|
1293
|
+
message: "Cannot compare deprecation versions — VERSION file missing",
|
|
1294
|
+
fixable: false
|
|
1295
|
+
)
|
|
1296
|
+
return checks
|
|
1297
|
+
end
|
|
1298
|
+
|
|
1299
|
+
# Find deprecations where removal version is greater than current version
|
|
1300
|
+
# Use simple string comparison on semver (works for well-formed versions)
|
|
1301
|
+
active = entries.select do |entry|
|
|
1302
|
+
removal = entry["removal"].to_s
|
|
1303
|
+
next false if removal.empty?
|
|
1304
|
+
|
|
1305
|
+
compare_versions(current_version, removal) < 0
|
|
1306
|
+
end
|
|
1307
|
+
|
|
1308
|
+
if active.empty?
|
|
1309
|
+
checks << check(
|
|
1310
|
+
category: "deprecations", name: "active_deprecations", status: "pass",
|
|
1311
|
+
message: "No active deprecations for current version (#{current_version})"
|
|
1312
|
+
)
|
|
1313
|
+
else
|
|
1314
|
+
details = active.map do |entry|
|
|
1315
|
+
lines = ["[#{entry["severity"]}] #{entry["summary"]} (removal: #{entry["removal"]})"]
|
|
1316
|
+
if entry["migration_steps"].is_a?(Array)
|
|
1317
|
+
entry["migration_steps"].each { |step| lines << " - #{step}" }
|
|
1318
|
+
end
|
|
1319
|
+
lines.join("\n")
|
|
1320
|
+
end
|
|
1321
|
+
|
|
1322
|
+
checks << check(
|
|
1323
|
+
category: "deprecations", name: "active_deprecations", status: "warn",
|
|
1324
|
+
message: "#{active.size} active deprecation(s) for version #{current_version}",
|
|
1325
|
+
details: details,
|
|
1326
|
+
fixable: false
|
|
1327
|
+
)
|
|
1328
|
+
end
|
|
1329
|
+
|
|
1330
|
+
checks
|
|
794
1331
|
end
|
|
795
1332
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
1333
|
+
# Compare two semver strings. Returns -1, 0, or 1.
|
|
1334
|
+
# Handles pre-release tags: 1.0.0-alpha.5 < 1.0.0 < 2.0.0
|
|
1335
|
+
def compare_versions(a, b)
|
|
1336
|
+
parse = ->(v) {
|
|
1337
|
+
base, pre = v.split("-", 2)
|
|
1338
|
+
segments = base.split(".").map(&:to_i)
|
|
1339
|
+
[segments, pre]
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
a_segments, a_pre = parse.call(a)
|
|
1343
|
+
b_segments, b_pre = parse.call(b)
|
|
1344
|
+
|
|
1345
|
+
# Pad to equal length
|
|
1346
|
+
max_len = [a_segments.size, b_segments.size].max
|
|
1347
|
+
a_segments += [0] * (max_len - a_segments.size)
|
|
1348
|
+
b_segments += [0] * (max_len - b_segments.size)
|
|
1349
|
+
|
|
1350
|
+
cmp = (a_segments <=> b_segments)
|
|
1351
|
+
return cmp unless cmp == 0
|
|
1352
|
+
|
|
1353
|
+
# Same base version: no pre-release > pre-release (1.0.0 > 1.0.0-alpha)
|
|
1354
|
+
return 0 if a_pre.nil? && b_pre.nil?
|
|
1355
|
+
return 1 if a_pre.nil? && b_pre
|
|
1356
|
+
return -1 if a_pre && b_pre.nil?
|
|
1357
|
+
|
|
1358
|
+
# Both have pre-release: compare lexically
|
|
1359
|
+
a_pre <=> b_pre
|
|
803
1360
|
end
|
|
804
1361
|
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1362
|
+
# --- Check category: QMD integration (read-only, optional) ---
|
|
1363
|
+
#
|
|
1364
|
+
# QMD is an optional integration. When `qmd` is not on PATH we emit a single
|
|
1365
|
+
# passing check and never fail — its absence is not a Plastic health problem.
|
|
1366
|
+
# When present, we report whether every Plastic store is registered as a QMD
|
|
1367
|
+
# collection. Everything here is read-only; we never invoke a mutating qmd
|
|
1368
|
+
# subcommand. detector/runner are injectable so tests stay hermetic.
|
|
1369
|
+
def check_qmd(detector: QmdSync.method(:detect), runner: QmdSync.default_runner)
|
|
1370
|
+
return [absent_qmd_check] unless detector.call
|
|
1371
|
+
|
|
1372
|
+
checks = [check(
|
|
1373
|
+
category: "qmd", name: "present", status: "pass",
|
|
1374
|
+
message: "QMD installed"
|
|
1375
|
+
)]
|
|
1376
|
+
|
|
1377
|
+
status = QmdSync.status(plastic_home: plastic_home, runner: runner, detector: detector)
|
|
1378
|
+
missing = status[:missing] || []
|
|
1379
|
+
|
|
1380
|
+
if status[:all_registered]
|
|
1381
|
+
checks << check(
|
|
1382
|
+
category: "qmd", name: "collections", status: "pass",
|
|
1383
|
+
message: "All #{status[:expected].size} Plastic store(s) registered as QMD collections"
|
|
1384
|
+
)
|
|
1385
|
+
else
|
|
1386
|
+
checks << check(
|
|
1387
|
+
category: "qmd", name: "collections", status: "warn",
|
|
1388
|
+
message: "#{missing.size} Plastic store(s) not registered as QMD collections",
|
|
1389
|
+
details: missing,
|
|
1390
|
+
fixable: true, fix_hint: "Run: qmd-sync register --all"
|
|
1391
|
+
)
|
|
1392
|
+
end
|
|
1393
|
+
|
|
1394
|
+
checks
|
|
1395
|
+
end
|
|
1396
|
+
|
|
1397
|
+
def absent_qmd_check
|
|
1398
|
+
check(
|
|
1399
|
+
category: "qmd", name: "present", status: "pass",
|
|
1400
|
+
message: "QMD not installed (optional integration)"
|
|
811
1401
|
)
|
|
812
|
-
return checks
|
|
813
1402
|
end
|
|
814
1403
|
|
|
815
|
-
#
|
|
816
|
-
# Use simple string comparison on semver (works for well-formed versions)
|
|
817
|
-
active = entries.select do |entry|
|
|
818
|
-
removal = entry["removal"].to_s
|
|
819
|
-
next false if removal.empty?
|
|
1404
|
+
# --- Run all checks ---
|
|
820
1405
|
|
|
821
|
-
|
|
1406
|
+
def run_checks(agent_key)
|
|
1407
|
+
all_checks = []
|
|
1408
|
+
all_checks += check_global_store
|
|
1409
|
+
all_checks += check_conventions
|
|
1410
|
+
all_checks += check_agent_registration(agent_key)
|
|
1411
|
+
all_checks += check_core_files(agent_key)
|
|
1412
|
+
all_checks += check_project_stores
|
|
1413
|
+
all_checks += check_deprecations
|
|
1414
|
+
all_checks += check_qmd
|
|
1415
|
+
|
|
1416
|
+
summarize(all_checks, agent_key)
|
|
822
1417
|
end
|
|
823
1418
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
1419
|
+
# Binary core sync check: agent registration + core files + manifest sync,
|
|
1420
|
+
# rolled up with binary: true so ANY warn or fail makes the overall status
|
|
1421
|
+
# "fail" (and "warn" is never emitted). Used by `doctor.rb --core`.
|
|
1422
|
+
def run_core_checks(agent_key)
|
|
1423
|
+
all_checks = []
|
|
1424
|
+
all_checks += check_agent_registration(agent_key)
|
|
1425
|
+
all_checks += check_core_files(agent_key)
|
|
1426
|
+
all_checks += check_manifest_sync(agent_key)
|
|
1427
|
+
|
|
1428
|
+
summarize(all_checks, agent_key, binary: true)
|
|
1429
|
+
end
|
|
1430
|
+
|
|
1431
|
+
# Store-scoped checks for `doctor.rb --store [global|<slug>]`.
|
|
1432
|
+
# :all -> global store + all project stores + all conventions
|
|
1433
|
+
# :global -> global store + conventions scoped to ["global"]
|
|
1434
|
+
# "<slug>" -> that project only + conventions scoped to ["project:<slug>"]
|
|
1435
|
+
# (fail if the slug is not registered in projects.yml)
|
|
1436
|
+
# 3-state roll-up (pass/warn/fail), like the full run.
|
|
1437
|
+
def run_store_checks(store)
|
|
1438
|
+
all_checks =
|
|
1439
|
+
case store
|
|
1440
|
+
when :all
|
|
1441
|
+
check_global_store + check_project_stores + check_conventions
|
|
1442
|
+
when :global
|
|
1443
|
+
check_global_store + check_conventions(scopes: ["global"])
|
|
1444
|
+
else
|
|
1445
|
+
all_checks_for_project_slug(store)
|
|
834
1446
|
end
|
|
835
|
-
lines.join("\n")
|
|
836
|
-
end
|
|
837
1447
|
|
|
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
|
-
)
|
|
1448
|
+
summarize(all_checks, "claude", binary: false)
|
|
844
1449
|
end
|
|
845
1450
|
|
|
846
|
-
checks
|
|
847
|
-
|
|
1451
|
+
# Build the checks for a single project slug, or a lone fail check when the
|
|
1452
|
+
# slug is unknown.
|
|
1453
|
+
def all_checks_for_project_slug(slug)
|
|
1454
|
+
projects_data = load_yaml_safe(File.join(plastic_home, "projects.yml"))
|
|
1455
|
+
projects = projects_data.is_a?(Hash) ? projects_data["projects"] : nil
|
|
848
1456
|
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
|
1457
|
+
unless projects.is_a?(Hash) && projects.key?(slug)
|
|
1458
|
+
return [check(
|
|
1459
|
+
category: "project_stores", name: "unknown_project", status: "fail",
|
|
1460
|
+
message: "unknown project '#{slug}'",
|
|
1461
|
+
fixable: false
|
|
1462
|
+
)]
|
|
1463
|
+
end
|
|
877
1464
|
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
all_checks = []
|
|
882
|
-
all_checks += check_global_store
|
|
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
|
|
1465
|
+
check_project_store(slug, projects[slug]) +
|
|
1466
|
+
check_conventions(scopes: ["project:#{slug}"])
|
|
1467
|
+
end
|
|
899
1468
|
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
summary:
|
|
907
|
-
|
|
908
|
-
|
|
1469
|
+
# Roll a list of checks up into the standard result envelope.
|
|
1470
|
+
# When binary: true, the overall status is "pass" only if there are zero warn
|
|
1471
|
+
# AND zero fail; any warn or fail yields "fail" (never "warn"). When false
|
|
1472
|
+
# (the default) the classic 3-state pass/warn/fail roll-up is used.
|
|
1473
|
+
def summarize(all_checks, agent_key, binary: false)
|
|
1474
|
+
summary = { pass: 0, warn: 0, fail: 0, total: all_checks.size }
|
|
1475
|
+
all_checks.each { |c| summary[c[:status].to_sym] += 1 }
|
|
1476
|
+
|
|
1477
|
+
overall = if binary
|
|
1478
|
+
(summary[:fail] > 0 || summary[:warn] > 0) ? "fail" : "pass"
|
|
1479
|
+
elsif summary[:fail] > 0
|
|
1480
|
+
"fail"
|
|
1481
|
+
elsif summary[:warn] > 0
|
|
1482
|
+
"warn"
|
|
1483
|
+
else
|
|
1484
|
+
"pass"
|
|
1485
|
+
end
|
|
1486
|
+
|
|
1487
|
+
{
|
|
1488
|
+
version: read_version || "unknown",
|
|
1489
|
+
timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
1490
|
+
status: overall,
|
|
1491
|
+
agent: agent_key,
|
|
1492
|
+
checks: all_checks,
|
|
1493
|
+
summary: summary,
|
|
1494
|
+
}
|
|
1495
|
+
end
|
|
909
1496
|
|
|
910
|
-
# --- Main ---
|
|
1497
|
+
# --- Main ---
|
|
911
1498
|
|
|
912
|
-
def
|
|
913
|
-
|
|
1499
|
+
def cli(argv = ARGV)
|
|
1500
|
+
flags = parse_args(argv)
|
|
914
1501
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
1502
|
+
if flags[:help]
|
|
1503
|
+
show_help
|
|
1504
|
+
exit 0
|
|
1505
|
+
end
|
|
919
1506
|
|
|
920
|
-
|
|
1507
|
+
result =
|
|
1508
|
+
if !flags[:store].nil?
|
|
1509
|
+
run_store_checks(flags[:store])
|
|
1510
|
+
elsif flags[:core]
|
|
1511
|
+
run_core_checks(flags[:agent])
|
|
1512
|
+
else
|
|
1513
|
+
run_checks(flags[:agent])
|
|
1514
|
+
end
|
|
921
1515
|
|
|
922
|
-
|
|
1516
|
+
puts JSON.pretty_generate(result)
|
|
923
1517
|
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
1518
|
+
# --core is binary: status is only ever pass|fail, so this maps to 0|2.
|
|
1519
|
+
# --store and the full run keep the 3-state 0/1/2 mapping.
|
|
1520
|
+
case result[:status]
|
|
1521
|
+
when "fail" then exit 2
|
|
1522
|
+
when "warn" then exit 1
|
|
1523
|
+
else exit 0
|
|
1524
|
+
end
|
|
928
1525
|
end
|
|
1526
|
+
|
|
929
1527
|
end
|
|
930
1528
|
|
|
931
|
-
|
|
1529
|
+
Doctor.new.cli(ARGV) if $PROGRAM_NAME == __FILE__
|