@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.
Files changed (115) hide show
  1. package/PLASTIC.md +170 -469
  2. package/README.md +95 -58
  3. package/agents/plastic-brainstorming.md +45 -0
  4. package/agents/plastic-enforcer.md +36 -0
  5. package/agents/plastic-executor.md +47 -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} +8 -6
  8. package/agents/plastic-planner.md +47 -0
  9. package/agents/plastic-spec-specialist.md +45 -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/hooks/statusline +150 -41
  22. package/package.json +2 -2
  23. package/scripts/agent-report +142 -0
  24. package/scripts/dashboard.rb +687 -0
  25. package/scripts/doctor.rb +1219 -621
  26. package/scripts/hook-auto-arm +51 -0
  27. package/scripts/hook-bash-gate +41 -0
  28. package/scripts/hook-code-gate +27 -0
  29. package/scripts/hook-continue +15 -114
  30. package/scripts/hook-create-gate +59 -0
  31. package/scripts/hook-gate-check +47 -32
  32. package/scripts/hook-qmd-search +44 -0
  33. package/scripts/hook-session-start +106 -38
  34. package/scripts/install.rb +91 -529
  35. package/scripts/lib/boot_banner.rb +28 -0
  36. package/scripts/lib/bridge.rb +452 -19
  37. package/scripts/lib/frontmatter_writer.rb +130 -0
  38. package/scripts/lib/graph_rebuild.rb +328 -0
  39. package/scripts/lib/installer_core.rb +812 -0
  40. package/scripts/lib/intent_validator.rb +235 -0
  41. package/scripts/lib/links_projection.rb +160 -0
  42. package/scripts/lib/links_section.rb +207 -0
  43. package/scripts/lib/power_tools.rb +76 -0
  44. package/scripts/lib/qmd_hook.rb +57 -0
  45. package/scripts/lib/qmd_sync.rb +230 -0
  46. package/scripts/lib/store_provisioning.rb +100 -0
  47. package/scripts/migrate-to-global +1 -1
  48. package/scripts/new-intent +327 -0
  49. package/scripts/project-links +287 -0
  50. package/scripts/provision-project-store +53 -0
  51. package/scripts/qmd-sync +139 -0
  52. package/scripts/rebuild-graph +244 -0
  53. package/scripts/select-update-target +93 -0
  54. package/scripts/spawn-preamble +138 -0
  55. package/scripts/uninstall.rb +53 -0
  56. package/scripts/update.rb +164 -0
  57. package/scripts/validate-intent +54 -0
  58. package/scripts/versions.rb +141 -0
  59. package/skills/_active-intent-gate.md +1 -1
  60. package/skills/add-project-store/SKILL.md +54 -0
  61. package/skills/auto/SKILL.md +87 -7
  62. package/skills/auto/evals/evals.json +255 -0
  63. package/skills/auto/references/agent-architecture.md +155 -0
  64. package/skills/auto/references/agent-report-contract.md +86 -0
  65. package/skills/brainstorming/SKILL.md +10 -9
  66. package/skills/brainstorming/evals/evals.json +22 -0
  67. package/skills/brainstorming-grill-me/SKILL.md +6 -6
  68. package/skills/continuing/SKILL.md +99 -82
  69. package/skills/continuing/evals/evals.json +145 -0
  70. package/skills/continuing/references/context-management.md +32 -0
  71. package/skills/creating-intent/SKILL.md +82 -36
  72. package/skills/creating-intent/evals/evals.json +72 -0
  73. package/skills/creating-intent/references/lifecycle.md +81 -0
  74. package/skills/creating-intent/references/wikilinks.md +8 -0
  75. package/skills/creating-project/SKILL.md +40 -8
  76. package/skills/creating-project/references/hubs-projects.md +55 -0
  77. package/skills/dashboard/SKILL.md +126 -0
  78. package/skills/dashboard/evals/evals.json +22 -0
  79. package/skills/dashboard/templates/dashboard-global.md +31 -0
  80. package/skills/dashboard/templates/dashboard-project.md +40 -0
  81. package/skills/doctor/SKILL.md +51 -4
  82. package/skills/doctor/references/gates-stuck-detection.md +38 -0
  83. package/skills/doctor/report.md +4 -0
  84. package/skills/evaluating-skills/SKILL.md +140 -0
  85. package/skills/evaluating-skills/assets/eval-template.json +12 -0
  86. package/skills/evaluating-skills/evals/evals.json +75 -0
  87. package/skills/evaluating-skills/references/convention-checks.md +76 -0
  88. package/skills/evaluating-skills/references/eval-methodology.md +154 -0
  89. package/skills/executing-plan/SKILL.md +5 -3
  90. package/skills/install/SKILL.md +69 -8
  91. package/skills/intent-curator/SKILL.md +6 -4
  92. package/skills/intent-curator/evals/evals.json +22 -0
  93. package/skills/linking-intents/SKILL.md +22 -7
  94. package/skills/linking-intents/evals/evals.json +22 -0
  95. package/skills/linking-intents/references/zettelkasten.md +45 -0
  96. package/skills/managing-index/SKILL.md +11 -1
  97. package/skills/managing-index/evals/evals.json +22 -0
  98. package/skills/managing-index/references/zettelkasten-linking.md +7 -2
  99. package/skills/releasing/SKILL.md +80 -23
  100. package/skills/releasing/references/deprecations.md +60 -0
  101. package/skills/research/SKILL.md +10 -2
  102. package/skills/research/evals/evals.json +22 -0
  103. package/skills/savepoint/SKILL.md +46 -37
  104. package/skills/savepoint/references/context-management.md +32 -0
  105. package/skills/uninstall/SKILL.md +39 -28
  106. package/skills/update/SKILL.md +41 -44
  107. package/skills/versions/SKILL.md +65 -0
  108. package/skills/writing-instructions/SKILL.md +159 -0
  109. package/skills/writing-instructions/references/agentskills-spec.md +135 -0
  110. package/skills/writing-plans/SKILL.md +5 -5
  111. package/templates/agents.md +7 -7
  112. package/templates/outcome.md +13 -0
  113. package/templates/savepoint.md +14 -13
  114. package/templates/spec.md +25 -0
  115. 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
- 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
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
- $stderr.puts "Error: --agent requires one of: #{AGENTS.keys.join(", ")}"
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
- { agent: agent, help: help }
75
- end
118
+ def show_help
119
+ $stderr.puts <<~HELP
76
120
 
77
- def show_help
78
- $stderr.puts <<~HELP
121
+ plastic doctor — diagnose Plastic installation health
79
122
 
80
- plastic doctor — diagnose Plastic installation health
123
+ Usage:
124
+ ruby ~/.plastic/scripts/doctor.rb [options]
81
125
 
82
- Usage:
83
- ruby ~/.plastic/scripts/doctor.rb [options]
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
- Options:
86
- --agent NAME Agent to check: claude (default), codex, hermes
87
- -h, --help Show this help
136
+ Output:
137
+ JSON to stdout with check results.
138
+ Exit 0 = all pass, 1 = warnings only, 2 = failures present.
88
139
 
89
- Output:
90
- JSON to stdout with check results.
91
- Exit 0 = all pass, 1 = warnings only, 2 = failures present.
140
+ HELP
141
+ end
92
142
 
93
- HELP
94
- end
143
+ # --- Utility helpers ---
95
144
 
96
- # --- Utility helpers ---
145
+ def read_version
146
+ version_path = File.join(plastic_home, "VERSION")
147
+ return nil unless File.exist?(version_path)
97
148
 
98
- def read_version
99
- version_path = File.join(PLASTIC_HOME, "VERSION")
100
- return nil unless File.exist?(version_path)
149
+ File.read(version_path).strip
150
+ end
101
151
 
102
- File.read(version_path).strip
103
- end
152
+ def read_json_safe(path)
153
+ return nil unless File.exist?(path)
104
154
 
105
- def read_json_safe(path)
106
- return nil unless File.exist?(path)
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
- 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
163
+ def load_yaml_safe(path)
164
+ return nil unless File.exist?(path)
115
165
 
116
- def load_yaml_safe(path)
117
- return nil unless File.exist?(path)
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
- YAML.safe_load(File.read(path)) || {}
120
- rescue => e
121
- $stderr.puts "Warning: failed to parse #{path}: #{e.message}"
122
- nil
123
- end
172
+ def tilde(path)
173
+ path.sub(Dir.home, "~")
174
+ end
124
175
 
125
- def tilde(path)
126
- path.sub(Dir.home, "~")
127
- end
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
- 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
189
+ # --- Parse frontmatter from an intent markdown file ---
141
190
 
142
- # --- Parse frontmatter from an intent markdown file ---
191
+ def parse_frontmatter(path)
192
+ return nil unless File.exist?(path)
143
193
 
144
- def parse_frontmatter(path)
145
- return nil unless File.exist?(path)
194
+ content = File.read(path)
195
+ return nil unless content.start_with?("---")
146
196
 
147
- content = File.read(path)
148
- return nil unless content.start_with?("---")
197
+ parts = content.split("---", 3)
198
+ return nil if parts.length < 3
149
199
 
150
- parts = content.split("---", 3)
151
- return nil if parts.length < 3
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
- YAML.safe_load(parts[1]) || {}
154
- rescue
155
- nil
156
- end
207
+ # --- Collect all intent directories (global + project stores) ---
157
208
 
158
- # --- Collect all intent directories (global + project stores) ---
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
- dirs = []
217
+ def all_intent_dirs
218
+ dirs = []
162
219
 
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)
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
- 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)
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
- 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)
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
- dirs
185
- end
244
+ # --- Check category 1: Global store ---
186
245
 
187
- # --- Check category 1: Global store ---
246
+ def check_global_store
247
+ checks = []
188
248
 
189
- def check_global_store
190
- checks = []
249
+ index_path = File.join(plastic_home, "INDEX.md")
191
250
 
192
- index_path = File.join(PLASTIC_HOME, "INDEX.md")
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
- # 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
266
+ # index_sections
267
+ content = File.read(index_path)
268
+ missing_sections = REQUIRED_INDEX_SECTIONS.reject { |s| content.include?(s) }
208
269
 
209
- # index_sections
210
- content = File.read(index_path)
211
- missing_sections = REQUIRED_INDEX_SECTIONS.reject { |s| content.include?(s) }
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
- 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
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
- # 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}") }
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 orphans.empty?
315
+ if ghosts.empty?
234
316
  checks << check(
235
- category: "global_store", name: "orphaned_intents", status: "pass",
236
- message: "No orphaned intent directories"
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: "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"
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
- # 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
332
+ # --- Check category 2: Conventions ---
252
333
 
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
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
- 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
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
- checks
273
- end
344
+ # intent_dirname
345
+ bad_dirnames = intent_dirs.reject { |d| d[:name].match?(dirname_pattern) }
274
346
 
275
- # --- Check category 2: Conventions ---
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
- def check_conventions
278
- checks = []
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
- intent_dirs = all_intent_dirs
281
- dirname_pattern = /^\w+--[\w-]+$/
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
- # intent_dirname
284
- bad_dirnames = intent_dirs.reject { |d| d[:name].match?(dirname_pattern) }
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
- 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
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
- # 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 }
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
- 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
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
- # 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)
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
- fm = parse_frontmatter(md_path)
331
- if fm.nil?
332
- bad_frontmatter << { dir: tilde(d[:path]), missing: ["(no frontmatter found)"] }
333
- next
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
- missing = REQUIRED_FRONTMATTER_FIELDS.reject { |f| fm.key?(f) }
337
- bad_frontmatter << { dir: tilde(d[:path]), missing: missing } unless missing.empty?
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
- 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
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
- checks
355
- end
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
- # --- Check category 3: Agent registration ---
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
- def check_agent_registration(agent_key)
360
- checks = []
361
- config = AGENTS[agent_key]
362
- agent_dir = config[:dir]
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
- 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
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
- 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)
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
- checks
381
- end
382
-
383
- def check_claude_registration(agent_dir)
384
- checks = []
385
- hooks_dir = File.join(agent_dir, "hooks")
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
- # hooks_exist
388
- missing_hooks = CLAUDE_HOOK_SCRIPTS.reject { |h| File.exist?(File.join(hooks_dir, h)) }
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
- 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"
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
- 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"
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
- # hooks_executable
405
- existing_hooks = CLAUDE_HOOK_SCRIPTS
406
- .map { |h| File.join(hooks_dir, h) }
407
- .select { |p| File.exist?(p) }
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
- non_executable = existing_hooks.reject { |p| File.executable?(p) }
722
+ # --- Check category 3: Agent registration ---
410
723
 
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
- )
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
- # 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)
748
+ def check_claude_registration(agent_dir)
749
+ checks = []
750
+ hooks_dir = File.join(agent_dir, "hooks")
428
751
 
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
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
- if missing_events.empty?
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: "hooks_registered", status: "pass",
450
- message: "All #{CLAUDE_HOOK_EVENTS.size} hook events registered in settings.json"
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: "#{missing_events.size} hook event(s) not registered in settings.json",
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
- end
460
- end
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
- # 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
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
- checks
478
- end
827
+ # skills_exist — flat, hyphen-namespaced personal skills (plastic-<name>/)
828
+ checks << flat_skills_check(agent_dir, "--claude")
479
829
 
480
- def check_generic_agent_registration(agent_key, agent_dir)
481
- checks = []
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
- # 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
- )
833
+ checks
497
834
  end
498
835
 
499
- checks
500
- end
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
- # --- Check category 4: Core files ---
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
- def check_core_files(agent_key)
505
- checks = []
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
- # 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
- )
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
- # 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
876
+ def check_generic_agent_registration(agent_key, agent_dir)
877
+ checks = []
878
+ config = agents[agent_key]
536
879
 
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)) }
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
- 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
- )
884
+ checks
553
885
  end
554
886
 
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) }
887
+ # --- Check category 4: Core files ---
560
888
 
561
- non_executable = script_files.reject { |f| File.executable?(f) }
889
+ def check_core_files(agent_key)
890
+ checks = []
562
891
 
563
- if non_executable.empty?
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: "scripts_executable", status: "pass",
566
- message: "All scripts in #{tilde(scripts_dir)} are executable"
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: "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"
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
- # version_match — compare global VERSION with agent-side VERSION
579
- global_version = read_version
580
- agent_config = AGENTS[agent_key]
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
- 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
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 File.exist?(agent_version_path)
589
- agent_version = File.read(agent_version_path).strip
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
- if global_version == agent_version
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: "version_match", status: "pass",
594
- message: "Global VERSION (#{global_version}) matches agent-side VERSION"
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: "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
- ],
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
- 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
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
- checks
617
- end
1082
+ # --- Check category 5: Project stores ---
618
1083
 
619
- # --- Check category 5: Project stores ---
1084
+ def check_project_stores
1085
+ checks = []
620
1086
 
621
- def check_project_stores
622
- checks = []
1087
+ projects_yml_path = File.join(plastic_home, "projects.yml")
1088
+ projects_data = load_yaml_safe(projects_yml_path)
623
1089
 
624
- projects_yml_path = File.join(PLASTIC_HOME, "projects.yml")
625
- projects_data = load_yaml_safe(projects_yml_path)
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
- 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
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
- 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
1109
+ projects.each do |slug, project_info|
1110
+ checks += check_project_store(slug, project_info)
1111
+ end
645
1112
 
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) : ""
1113
+ checks
1114
+ end
649
1115
 
650
- projects.each do |slug, project_info|
651
- project_dir = File.join(PLASTIC_HOME, "projects", slug)
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(PLASTIC_HOME, "projects", slug, "project.yml")
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:creating-project"
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
- next unless parent_id
1214
+ return checks unless parent_id
730
1215
 
731
1216
  # Find the intent directory for the parent ID
732
- store_dir = File.join(PLASTIC_HOME, "store")
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
- next
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
- next
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
- checks
778
- end
1264
+ # --- Check category 6: Deprecations ---
779
1265
 
780
- # --- Check category 6: Deprecations ---
1266
+ def check_deprecations
1267
+ checks = []
781
1268
 
782
- def check_deprecations
783
- checks = []
1269
+ deprecations_path = File.join(plastic_home, "deprecations.yml")
1270
+ data = load_yaml_safe(deprecations_path)
784
1271
 
785
- deprecations_path = File.join(PLASTIC_HOME, "deprecations.yml")
786
- data = load_yaml_safe(deprecations_path)
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
- 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
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
- 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
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
- 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
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
- # 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?
1404
+ # --- Run all checks ---
820
1405
 
821
- compare_versions(current_version, removal) < 0
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
- 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}" }
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
- 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
- )
1448
+ summarize(all_checks, "claude", binary: false)
844
1449
  end
845
1450
 
846
- checks
847
- end
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
- # 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
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
- # --- 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
1465
+ check_project_store(slug, projects[slug]) +
1466
+ check_conventions(scopes: ["project:#{slug}"])
1467
+ end
899
1468
 
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
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 main
913
- flags = parse_args(ARGV)
1499
+ def cli(argv = ARGV)
1500
+ flags = parse_args(argv)
914
1501
 
915
- if flags[:help]
916
- show_help
917
- exit 0
918
- end
1502
+ if flags[:help]
1503
+ show_help
1504
+ exit 0
1505
+ end
919
1506
 
920
- result = run_checks(flags[:agent])
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
- puts JSON.pretty_generate(result)
1516
+ puts JSON.pretty_generate(result)
923
1517
 
924
- case result[:status]
925
- when "fail" then exit 2
926
- when "warn" then exit 1
927
- else exit 0
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
- main
1529
+ Doctor.new.cli(ARGV) if $PROGRAM_NAME == __FILE__