@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.
Files changed (99) hide show
  1. package/PLASTIC.md +163 -469
  2. package/README.md +95 -58
  3. package/agents/plastic-brainstorming.md +37 -0
  4. package/agents/plastic-enforcer.md +36 -0
  5. package/agents/plastic-executor.md +37 -0
  6. package/agents/{future-intent-researcher.md → plastic-future-intent-researcher.md} +1 -1
  7. package/agents/{intent-curator.md → plastic-intent-curator.md} +1 -1
  8. package/agents/plastic-planner.md +37 -0
  9. package/agents/plastic-spec-specialist.md +37 -0
  10. package/bin/plastic.js +57 -0
  11. package/bin/test +28 -0
  12. package/deprecations.yml +1 -10
  13. package/hooks/auto-arm +5 -0
  14. package/hooks/bash-gate +3 -0
  15. package/hooks/check-update +12 -8
  16. package/hooks/code-gate +12 -0
  17. package/hooks/create-gate +3 -0
  18. package/hooks/gate-check +3 -1
  19. package/hooks/hooks.json +52 -0
  20. package/hooks/qmd-search +8 -0
  21. package/package.json +2 -2
  22. package/scripts/dashboard.rb +687 -0
  23. package/scripts/doctor.rb +1054 -628
  24. package/scripts/hook-auto-arm +51 -0
  25. package/scripts/hook-bash-gate +41 -0
  26. package/scripts/hook-code-gate +27 -0
  27. package/scripts/hook-continue +15 -114
  28. package/scripts/hook-create-gate +59 -0
  29. package/scripts/hook-gate-check +47 -32
  30. package/scripts/hook-qmd-search +44 -0
  31. package/scripts/hook-session-start +106 -38
  32. package/scripts/install.rb +91 -529
  33. package/scripts/lib/boot_banner.rb +28 -0
  34. package/scripts/lib/bridge.rb +404 -19
  35. package/scripts/lib/installer_core.rb +807 -0
  36. package/scripts/lib/intent_validator.rb +235 -0
  37. package/scripts/lib/qmd_hook.rb +44 -0
  38. package/scripts/lib/qmd_sync.rb +209 -0
  39. package/scripts/lib/store_provisioning.rb +100 -0
  40. package/scripts/migrate-to-global +1 -1
  41. package/scripts/new-intent +226 -0
  42. package/scripts/provision-project-store +53 -0
  43. package/scripts/qmd-sync +92 -0
  44. package/scripts/select-update-target +93 -0
  45. package/scripts/spawn-preamble +121 -0
  46. package/scripts/uninstall.rb +53 -0
  47. package/scripts/update.rb +164 -0
  48. package/scripts/validate-intent +54 -0
  49. package/scripts/versions.rb +141 -0
  50. package/skills/_active-intent-gate.md +1 -1
  51. package/skills/add-project-store/SKILL.md +54 -0
  52. package/skills/auto/SKILL.md +77 -7
  53. package/skills/auto/evals/evals.json +207 -0
  54. package/skills/auto/references/agent-architecture.md +135 -0
  55. package/skills/brainstorming/SKILL.md +9 -9
  56. package/skills/brainstorming-grill-me/SKILL.md +6 -6
  57. package/skills/continuing/SKILL.md +92 -82
  58. package/skills/continuing/evals/evals.json +136 -0
  59. package/skills/continuing/references/context-management.md +32 -0
  60. package/skills/creating-intent/SKILL.md +74 -36
  61. package/skills/creating-intent/evals/evals.json +56 -0
  62. package/skills/creating-intent/references/lifecycle.md +76 -0
  63. package/skills/creating-intent/references/wikilinks.md +8 -0
  64. package/skills/creating-project/SKILL.md +40 -8
  65. package/skills/creating-project/references/hubs-projects.md +55 -0
  66. package/skills/dashboard/SKILL.md +121 -0
  67. package/skills/dashboard/templates/dashboard-global.md +31 -0
  68. package/skills/dashboard/templates/dashboard-project.md +40 -0
  69. package/skills/doctor/SKILL.md +51 -4
  70. package/skills/doctor/references/gates-stuck-detection.md +38 -0
  71. package/skills/doctor/report.md +4 -0
  72. package/skills/evaluating-skills/SKILL.md +140 -0
  73. package/skills/evaluating-skills/assets/eval-template.json +12 -0
  74. package/skills/evaluating-skills/evals/evals.json +75 -0
  75. package/skills/evaluating-skills/references/convention-checks.md +76 -0
  76. package/skills/evaluating-skills/references/eval-methodology.md +154 -0
  77. package/skills/executing-plan/SKILL.md +5 -3
  78. package/skills/install/SKILL.md +69 -8
  79. package/skills/intent-curator/SKILL.md +3 -3
  80. package/skills/linking-intents/SKILL.md +16 -7
  81. package/skills/linking-intents/references/zettelkasten.md +38 -0
  82. package/skills/managing-index/SKILL.md +5 -1
  83. package/skills/managing-index/references/zettelkasten-linking.md +1 -1
  84. package/skills/releasing/SKILL.md +80 -23
  85. package/skills/releasing/references/deprecations.md +60 -0
  86. package/skills/research/SKILL.md +2 -2
  87. package/skills/savepoint/SKILL.md +46 -37
  88. package/skills/savepoint/references/context-management.md +32 -0
  89. package/skills/uninstall/SKILL.md +39 -28
  90. package/skills/update/SKILL.md +41 -44
  91. package/skills/versions/SKILL.md +65 -0
  92. package/skills/writing-instructions/SKILL.md +159 -0
  93. package/skills/writing-instructions/references/agentskills-spec.md +135 -0
  94. package/skills/writing-plans/SKILL.md +5 -5
  95. package/templates/agents.md +7 -7
  96. package/templates/outcome.md +13 -0
  97. package/templates/savepoint.md +14 -13
  98. package/templates/spec.md +25 -0
  99. 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
- PLASTIC_HOME = File.join(Dir.home, ".plastic")
17
-
18
- AGENTS = {
19
- "claude" => { name: "Claude Code", dir: File.join(Dir.home, ".claude") },
20
- "codex" => { name: "Codex CLI", dir: File.join(Dir.home, ".agents") },
21
- "hermes" => { name: "Hermes", dir: File.join(Dir.home, ".hermes") },
22
- }.freeze
23
-
24
- REQUIRED_INDEX_SECTIONS = ["## Active", "## Future", "## Clusters", "## Abandoned", "## Completed"].freeze
25
-
26
- REQUIRED_FRONTMATTER_FIELDS = %w[id intent sources chain created author tags].freeze
27
-
28
- CLAUDE_HOOK_SCRIPTS = %w[
29
- plastic-session-start
30
- plastic-check-update
31
- plastic-savepoint
32
- plastic-gate-check
33
- plastic-continue
34
- plastic-future-intent-check
35
- ].freeze
36
-
37
- CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit].freeze
38
-
39
- REQUIRED_SCRIPTS = %w[
40
- folgezettel-id
41
- read-config
42
- hook-session-start
43
- hook-continue
44
- hook-future-intent-check
45
- hook-gate-check
46
- doctor.rb
47
- ].freeze
48
-
49
- # --- Flag parsing ---
50
-
51
- def parse_args(argv)
52
- agent = "claude"
53
- help = false
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
- $stderr.puts "Error: --agent requires one of: #{AGENTS.keys.join(", ")}"
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
- { agent: agent, help: help }
75
- end
115
+ def show_help
116
+ $stderr.puts <<~HELP
76
117
 
77
- def show_help
78
- $stderr.puts <<~HELP
118
+ plastic doctor — diagnose Plastic installation health
79
119
 
80
- plastic doctor — diagnose Plastic installation health
120
+ Usage:
121
+ ruby ~/.plastic/scripts/doctor.rb [options]
81
122
 
82
- Usage:
83
- ruby ~/.plastic/scripts/doctor.rb [options]
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
- Options:
86
- --agent NAME Agent to check: claude (default), codex, hermes
87
- -h, --help Show this help
133
+ Output:
134
+ JSON to stdout with check results.
135
+ Exit 0 = all pass, 1 = warnings only, 2 = failures present.
88
136
 
89
- Output:
90
- JSON to stdout with check results.
91
- Exit 0 = all pass, 1 = warnings only, 2 = failures present.
137
+ HELP
138
+ end
92
139
 
93
- HELP
94
- end
140
+ # --- Utility helpers ---
95
141
 
96
- # --- Utility helpers ---
142
+ def read_version
143
+ version_path = File.join(plastic_home, "VERSION")
144
+ return nil unless File.exist?(version_path)
97
145
 
98
- def read_version
99
- version_path = File.join(PLASTIC_HOME, "VERSION")
100
- return nil unless File.exist?(version_path)
146
+ File.read(version_path).strip
147
+ end
101
148
 
102
- File.read(version_path).strip
103
- end
149
+ def read_json_safe(path)
150
+ return nil unless File.exist?(path)
104
151
 
105
- def read_json_safe(path)
106
- return nil unless File.exist?(path)
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
- JSON.parse(File.read(path))
109
- rescue JSON::ParserError
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
- def load_yaml_safe(path)
117
- return nil unless File.exist?(path)
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
- YAML.safe_load(File.read(path)) || {}
120
- rescue => e
121
- $stderr.puts "Warning: failed to parse #{path}: #{e.message}"
122
- nil
123
- end
169
+ def tilde(path)
170
+ path.sub(Dir.home, "~")
171
+ end
124
172
 
125
- def tilde(path)
126
- path.sub(Dir.home, "~")
127
- end
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
- def check(category:, name:, status:, message:, details: [], fixable: false, fix_hint: nil)
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
- # --- Parse frontmatter from an intent markdown file ---
188
+ def parse_frontmatter(path)
189
+ return nil unless File.exist?(path)
143
190
 
144
- def parse_frontmatter(path)
145
- return nil unless File.exist?(path)
191
+ content = File.read(path)
192
+ return nil unless content.start_with?("---")
146
193
 
147
- content = File.read(path)
148
- return nil unless content.start_with?("---")
194
+ parts = content.split("---", 3)
195
+ return nil if parts.length < 3
149
196
 
150
- parts = content.split("---", 3)
151
- return nil if parts.length < 3
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
- YAML.safe_load(parts[1]) || {}
154
- rescue
155
- nil
156
- end
204
+ # --- Collect all intent directories (global + project stores) ---
157
205
 
158
- # --- Collect all intent directories (global + project stores) ---
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
- dirs = []
214
+ def all_intent_dirs
215
+ dirs = []
162
216
 
163
- global_store = File.join(PLASTIC_HOME, "store")
164
- if File.directory?(global_store)
165
- Dir.children(global_store).each do |entry|
166
- full = File.join(global_store, entry)
167
- dirs << { path: full, name: entry, scope: "global" } if File.directory?(full)
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
- projects_root = File.join(PLASTIC_HOME, "projects")
172
- if File.directory?(projects_root)
173
- Dir.children(projects_root).each do |project|
174
- project_store = File.join(projects_root, project, "store")
175
- next unless File.directory?(project_store)
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
- Dir.children(project_store).each do |entry|
178
- full = File.join(project_store, entry)
179
- dirs << { path: full, name: entry, scope: "project:#{project}" } if File.directory?(full)
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
- dirs
185
- end
241
+ # --- Check category 1: Global store ---
186
242
 
187
- # --- Check category 1: Global store ---
243
+ def check_global_store
244
+ checks = []
188
245
 
189
- def check_global_store
190
- checks = []
246
+ index_path = File.join(plastic_home, "INDEX.md")
191
247
 
192
- index_path = File.join(PLASTIC_HOME, "INDEX.md")
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
- # index_exists
195
- if File.exist?(index_path)
196
- checks << check(
197
- category: "global_store", name: "index_exists", status: "pass",
198
- message: "INDEX.md exists"
199
- )
200
- else
201
- checks << check(
202
- category: "global_store", name: "index_exists", status: "fail",
203
- message: "INDEX.md not found at #{tilde(index_path)}",
204
- fixable: true, fix_hint: "Run the Plastic installer to bootstrap the store"
205
- )
206
- return checks # Can't check sections/references without INDEX.md
207
- end
263
+ # index_sections
264
+ content = File.read(index_path)
265
+ missing_sections = REQUIRED_INDEX_SECTIONS.reject { |s| content.include?(s) }
208
266
 
209
- # index_sections
210
- content = File.read(index_path)
211
- missing_sections = REQUIRED_INDEX_SECTIONS.reject { |s| content.include?(s) }
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
- if missing_sections.empty?
214
- checks << check(
215
- category: "global_store", name: "index_sections", status: "pass",
216
- message: "INDEX.md has all 5 required sections"
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
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
- # orphaned_intents directories in store/ not referenced in INDEX.md
228
- store_dir = File.join(PLASTIC_HOME, "store")
229
- if File.directory?(store_dir)
230
- intent_dirs = Dir.children(store_dir).select { |e| File.directory?(File.join(store_dir, e)) }
231
- orphans = intent_dirs.reject { |d| content.include?("store/#{d}") }
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 orphans.empty?
312
+ if ghosts.empty?
234
313
  checks << check(
235
- category: "global_store", name: "orphaned_intents", status: "pass",
236
- message: "No orphaned intent directories"
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: "orphaned_intents", status: "warn",
241
- message: "#{orphans.size} intent director#{orphans.size == 1 ? "y" : "ies"} not referenced in INDEX.md",
242
- details: orphans.map { |d| "store/#{d}" },
243
- fixable: true, fix_hint: "Add missing intents to INDEX.md or remove orphaned directories"
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
- # ghost_references paths in INDEX.md pointing to non-existent directories
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
- ghosts = store_paths.select do |ref|
254
- full_path = File.join(PLASTIC_HOME, ref)
255
- !File.exist?(full_path) && !File.directory?(full_path)
256
- end
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
- if ghosts.empty?
259
- checks << check(
260
- category: "global_store", name: "ghost_references", status: "pass",
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
- checks
273
- end
341
+ # intent_dirname
342
+ bad_dirnames = intent_dirs.reject { |d| d[:name].match?(dirname_pattern) }
274
343
 
275
- # --- Check category 2: Conventions ---
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
- def check_conventions
278
- checks = []
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
- intent_dirs = all_intent_dirs
281
- dirname_pattern = /^\w+--[\w-]+$/
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
- # intent_dirname
284
- bad_dirnames = intent_dirs.reject { |d| d[:name].match?(dirname_pattern) }
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
- if bad_dirnames.empty?
287
- checks << check(
288
- category: "conventions", name: "intent_dirname", status: "pass",
289
- message: "All #{intent_dirs.size} intent directories follow {ID}--{slug} format"
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
- # intent_filename primary file inside directory matches {ID}--{slug}.md
301
- bad_filenames = []
302
- intent_dirs.each do |d|
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
- if bad_filenames.empty?
311
- checks << check(
312
- category: "conventions", name: "intent_filename", status: "pass",
313
- message: "All intent directories have matching {ID}--{slug}.md files"
314
- )
315
- else
316
- checks << check(
317
- category: "conventions", name: "intent_filename", status: "warn",
318
- message: "#{bad_filenames.size} intent director#{bad_filenames.size == 1 ? "y" : "ies"} missing primary .md file",
319
- details: bad_filenames.map { |b| "#{tilde(b[:dir][:path])} expected #{b[:expected]}" },
320
- fixable: true, fix_hint: "Create or rename the primary .md file to match the directory name"
321
- )
322
- end
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
- # frontmatter_fields
325
- bad_frontmatter = []
326
- intent_dirs.each do |d|
327
- md_path = File.join(d[:path], "#{d[:name]}.md")
328
- next unless File.exist?(md_path)
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
- fm = parse_frontmatter(md_path)
331
- if fm.nil?
332
- bad_frontmatter << { dir: tilde(d[:path]), missing: ["(no frontmatter found)"] }
333
- next
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
- missing = REQUIRED_FRONTMATTER_FIELDS.reject { |f| fm.key?(f) }
337
- bad_frontmatter << { dir: tilde(d[:path]), missing: missing } unless missing.empty?
338
- end
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
- if bad_frontmatter.empty?
341
- checks << check(
342
- category: "conventions", name: "frontmatter_fields", status: "pass",
343
- message: "All intent files have required frontmatter fields"
344
- )
345
- else
346
- checks << check(
347
- category: "conventions", name: "frontmatter_fields", status: "warn",
348
- message: "#{bad_frontmatter.size} intent file(s) missing required frontmatter fields",
349
- details: bad_frontmatter.map { |b| "#{b[:dir]}: missing #{b[:missing].join(", ")}" },
350
- fixable: false
351
- )
352
- end
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
- checks
355
- end
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
- # --- Check category 3: Agent registration ---
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
- def check_agent_registration(agent_key)
360
- checks = []
361
- config = AGENTS[agent_key]
362
- agent_dir = config[:dir]
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
- unless File.directory?(agent_dir)
365
- checks << check(
366
- category: "agent_registration", name: "agent_dir_exists", status: "fail",
367
- message: "Agent directory #{tilde(agent_dir)} not found #{config[:name]} may not be installed",
368
- fixable: false
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
- return checks
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
- case agent_key
374
- when "claude"
375
- checks += check_claude_registration(agent_dir)
376
- else
377
- checks += check_generic_agent_registration(agent_key, agent_dir)
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
- checks
381
- end
550
+ # --- Check category 3: Agent registration ---
382
551
 
383
- def check_claude_registration(agent_dir)
384
- checks = []
385
- hooks_dir = File.join(agent_dir, "hooks")
552
+ def check_agent_registration(agent_key)
553
+ checks = []
554
+ config = agents[agent_key]
555
+ agent_dir = config[:dir]
386
556
 
387
- # hooks_exist
388
- missing_hooks = CLAUDE_HOOK_SCRIPTS.reject { |h| File.exist?(File.join(hooks_dir, h)) }
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
- if missing_hooks.empty?
391
- checks << check(
392
- category: "agent_registration", name: "hooks_exist", status: "pass",
393
- message: "All #{CLAUDE_HOOK_SCRIPTS.size} expected hook scripts exist"
394
- )
395
- else
396
- checks << check(
397
- category: "agent_registration", name: "hooks_exist", status: "fail",
398
- message: "#{missing_hooks.size} hook script(s) missing",
399
- details: missing_hooks.map { |h| "#{tilde(hooks_dir)}/#{h}" },
400
- fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
401
- )
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
- # hooks_executable
405
- existing_hooks = CLAUDE_HOOK_SCRIPTS
406
- .map { |h| File.join(hooks_dir, h) }
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
- non_executable = existing_hooks.reject { |p| File.executable?(p) }
580
+ # hooks_exist
581
+ missing_hooks = CLAUDE_HOOK_SCRIPTS.reject { |h| File.exist?(File.join(hooks_dir, h)) }
410
582
 
411
- if non_executable.empty?
412
- checks << check(
413
- category: "agent_registration", name: "hooks_executable", status: "pass",
414
- message: "All existing hook scripts are executable"
415
- )
416
- else
417
- checks << check(
418
- category: "agent_registration", name: "hooks_executable", status: "fail",
419
- message: "#{non_executable.size} hook script(s) not executable",
420
- details: non_executable.map { |p| tilde(p) },
421
- fixable: true, fix_hint: "chmod +x on the listed files"
422
- )
423
- end
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
- # hooks_registered — settings.json has Plastic hooks for required events
426
- settings_path = File.join(agent_dir, "settings.json")
427
- settings = read_json_safe(settings_path)
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
- if settings.nil?
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 missing_events.empty?
604
+ if non_executable.empty?
448
605
  checks << check(
449
- category: "agent_registration", name: "hooks_registered", status: "pass",
450
- message: "All #{CLAUDE_HOOK_EVENTS.size} hook events registered in settings.json"
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: "#{missing_events.size} hook event(s) not registered in settings.json",
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
- end
460
- end
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
- # skills_exist
463
- skills_dir = File.join(agent_dir, "skills", "plastic")
464
- if File.directory?(skills_dir) && !Dir.empty?(skills_dir)
465
- checks << check(
466
- category: "agent_registration", name: "skills_exist", status: "pass",
467
- message: "Skills directory exists at #{tilde(skills_dir)}"
468
- )
469
- else
470
- checks << check(
471
- category: "agent_registration", name: "skills_exist", status: "fail",
472
- message: "Skills directory missing or empty at #{tilde(skills_dir)}",
473
- fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
474
- )
475
- end
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
- checks
478
- end
655
+ # skills_exist — flat, hyphen-namespaced personal skills (plastic-<name>/)
656
+ checks << flat_skills_check(agent_dir, "--claude")
479
657
 
480
- def check_generic_agent_registration(agent_key, agent_dir)
481
- checks = []
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
- # For codex/hermes: just check skills exist (no settings.json hooks)
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
- checks
500
- end
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
- # --- Check category 4: Core files ---
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
- def check_core_files(agent_key)
505
- checks = []
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
- # plastic_md
508
- plastic_md = File.join(PLASTIC_HOME, "PLASTIC.md")
509
- if File.exist?(plastic_md)
510
- checks << check(
511
- category: "core_files", name: "plastic_md", status: "pass",
512
- message: "PLASTIC.md exists"
513
- )
514
- else
515
- checks << check(
516
- category: "core_files", name: "plastic_md", status: "fail",
517
- message: "PLASTIC.md not found at #{tilde(plastic_md)}",
518
- fixable: true, fix_hint: "Re-run the Plastic installer to restore core files"
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
- # version_file
523
- version_path = File.join(PLASTIC_HOME, "VERSION")
524
- if File.exist?(version_path)
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
- # scripts_present
538
- scripts_dir = File.join(PLASTIC_HOME, "scripts")
539
- missing_scripts = REQUIRED_SCRIPTS.reject { |s| File.exist?(File.join(scripts_dir, s)) }
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
- if missing_scripts.empty?
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
- # scripts_executable
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
- non_executable = script_files.reject { |f| File.executable?(f) }
717
+ def check_core_files(agent_key)
718
+ checks = []
562
719
 
563
- if non_executable.empty?
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: "scripts_executable", status: "pass",
566
- message: "All scripts in #{tilde(scripts_dir)} are executable"
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: "scripts_executable", status: "fail",
571
- message: "#{non_executable.size} script(s) not executable",
572
- details: non_executable.map { |f| tilde(f) },
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
- # version_match — compare global VERSION with agent-side VERSION
579
- global_version = read_version
580
- agent_config = AGENTS[agent_key]
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
- if global_version && agent_config
583
- agent_version_path = case agent_key
584
- when "claude" then File.join(agent_config[:dir], "plastic", "VERSION")
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 File.exist?(agent_version_path)
589
- agent_version = File.read(agent_version_path).strip
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 global_version == agent_version
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: "version_match", status: "pass",
594
- message: "Global VERSION (#{global_version}) matches agent-side VERSION"
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: "Version mismatch: global=#{global_version}, agent=#{agent_version}",
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
- checks << check(
609
- category: "core_files", name: "version_match", status: "warn",
610
- message: "Agent-side VERSION file not found at #{tilde(agent_version_path)}",
611
- fixable: false
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
- checks
617
- end
910
+ # --- Check category 5: Project stores ---
618
911
 
619
- # --- Check category 5: Project stores ---
912
+ def check_project_stores
913
+ checks = []
620
914
 
621
- def check_project_stores
622
- checks = []
915
+ projects_yml_path = File.join(plastic_home, "projects.yml")
916
+ projects_data = load_yaml_safe(projects_yml_path)
623
917
 
624
- projects_yml_path = File.join(PLASTIC_HOME, "projects.yml")
625
- projects_data = load_yaml_safe(projects_yml_path)
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
- if projects_data.nil?
628
- checks << check(
629
- category: "project_stores", name: "projects_yml", status: "warn",
630
- message: "projects.yml not found or invalid at #{tilde(projects_yml_path)}",
631
- fixable: true, fix_hint: "Re-run the Plastic installer to restore projects.yml"
632
- )
633
- return checks
634
- end
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
- projects = projects_data["projects"]
637
- unless projects.is_a?(Hash) && !projects.empty?
638
- # No projects registered — nothing to check
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
- # Load INDEX.md content for cross-reference checks
647
- index_path = File.join(PLASTIC_HOME, "INDEX.md")
648
- index_content = File.exist?(index_path) ? File.read(index_path) : ""
941
+ checks
942
+ end
649
943
 
650
- projects.each do |slug, project_info|
651
- project_dir = File.join(PLASTIC_HOME, "projects", slug)
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(PLASTIC_HOME, "projects", slug, "project.yml")
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:creating-project"
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
- next unless parent_id
1042
+ return checks unless parent_id
730
1043
 
731
1044
  # Find the intent directory for the parent ID
732
- store_dir = File.join(PLASTIC_HOME, "store")
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
- next
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
- next
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
- checks
778
- end
1092
+ # --- Check category 6: Deprecations ---
779
1093
 
780
- # --- Check category 6: Deprecations ---
1094
+ def check_deprecations
1095
+ checks = []
781
1096
 
782
- def check_deprecations
783
- checks = []
1097
+ deprecations_path = File.join(plastic_home, "deprecations.yml")
1098
+ data = load_yaml_safe(deprecations_path)
784
1099
 
785
- deprecations_path = File.join(PLASTIC_HOME, "deprecations.yml")
786
- data = load_yaml_safe(deprecations_path)
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
- if data.nil?
789
- checks << check(
790
- category: "deprecations", name: "deprecations_file", status: "pass",
791
- message: "No deprecations.yml found (nothing to report)"
792
- )
793
- return checks
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
- entries = data["deprecations"]
797
- unless entries.is_a?(Array) && !entries.empty?
798
- checks << check(
799
- category: "deprecations", name: "active_deprecations", status: "pass",
800
- message: "No deprecation entries found"
801
- )
802
- return checks
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
- current_version = read_version
806
- unless current_version
807
- checks << check(
808
- category: "deprecations", name: "active_deprecations", status: "warn",
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
- # Find deprecations where removal version is greater than current version
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?
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
- compare_versions(current_version, removal) < 0
1244
+ summarize(all_checks, agent_key)
822
1245
  end
823
1246
 
824
- if active.empty?
825
- checks << check(
826
- category: "deprecations", name: "active_deprecations", status: "pass",
827
- message: "No active deprecations for current version (#{current_version})"
828
- )
829
- else
830
- details = active.map do |entry|
831
- lines = ["[#{entry["severity"]}] #{entry["summary"]} (removal: #{entry["removal"]})"]
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
- checks << check(
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
- end
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
- # Compare two semver strings. Returns -1, 0, or 1.
850
- # Handles pre-release tags: 1.0.0-alpha.5 < 1.0.0 < 2.0.0
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
- # --- Run all checks ---
879
-
880
- def run_checks(agent_key)
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
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
- # --- Main ---
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
- def main
913
- flags = parse_args(ARGV)
1293
+ check_project_store(slug, projects[slug]) +
1294
+ check_conventions(scopes: ["project:#{slug}"])
1295
+ end
914
1296
 
915
- if flags[:help]
916
- show_help
917
- exit 0
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
- result = run_checks(flags[:agent])
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
- puts JSON.pretty_generate(result)
1344
+ puts JSON.pretty_generate(result)
923
1345
 
924
- case result[:status]
925
- when "fail" then exit 2
926
- when "warn" then exit 1
927
- else exit 0
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
- main
1357
+ Doctor.new.cli(ARGV) if $PROGRAM_NAME == __FILE__