@zalom/plastic 1.9.0 → 1.11.0

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 (96) hide show
  1. package/PLASTIC.md +23 -8
  2. package/README.md +16 -5
  3. package/agents/plastic-enforcer.md +3 -2
  4. package/agents/plastic-intent-discovery.md +7 -0
  5. package/bin/plastic.js +17 -8
  6. package/hooks/auto-arm +2 -2
  7. package/hooks/bash-gate +1 -1
  8. package/hooks/check-update +1 -1
  9. package/hooks/continue +2 -2
  10. package/hooks/edit-gates +1 -1
  11. package/hooks/future-intent-check +2 -2
  12. package/hooks/gate-check +3 -3
  13. package/hooks/power-tools +1 -1
  14. package/hooks/session-start +1 -1
  15. package/package.json +1 -1
  16. package/scripts/codex-hook +50 -106
  17. package/scripts/doctor.rb +132 -1080
  18. package/scripts/exec-worktree +103 -0
  19. package/scripts/hash-intent +1 -1
  20. package/scripts/hook-bash-gate +19 -0
  21. package/scripts/hook-code-gate +4 -1
  22. package/scripts/hook-continue +2 -2
  23. package/scripts/hook-create-gate +6 -3
  24. package/scripts/hook-gate-check +17 -0
  25. package/scripts/hook-links-gate +4 -1
  26. package/scripts/hook-lock-gate +7 -3
  27. package/scripts/hook-savepoint-pre +4 -1
  28. package/scripts/hook-session-start +21 -15
  29. package/scripts/lib/apply_patch_envelope.rb +46 -13
  30. package/scripts/lib/bridge.rb +83 -15
  31. package/scripts/lib/codex_edit_gates.rb +138 -0
  32. package/scripts/lib/doctor_core.rb +1087 -0
  33. package/scripts/lib/edit_gates.rb +61 -5
  34. package/scripts/lib/exec_worktree.rb +325 -0
  35. package/scripts/lib/harness_text.rb +57 -0
  36. package/scripts/lib/hook_registry.rb +32 -28
  37. package/scripts/lib/installer_core.rb +67 -7
  38. package/scripts/lib/lock.rb +196 -47
  39. package/scripts/lib/ruby_probe.rb +60 -0
  40. package/scripts/lib/scaffold_intent.rb +392 -0
  41. package/scripts/lib/spec_header.rb +83 -0
  42. package/scripts/lib/start_intent.rb +296 -0
  43. package/scripts/lib/verify_intent.rb +262 -0
  44. package/scripts/lib/worktree.rb +15 -1
  45. package/scripts/link-suggest +1 -1
  46. package/scripts/maintenance-run +5 -5
  47. package/scripts/migrate-to-global +2 -2
  48. package/scripts/restore-intent-v1 +1 -1
  49. package/scripts/scaffold-intent +120 -0
  50. package/scripts/start-intent +89 -0
  51. package/scripts/verify-intent +73 -0
  52. package/skills/agent-advisor/SKILL.md +5 -5
  53. package/skills/auto/SKILL.md +42 -32
  54. package/skills/auto/references/agent-architecture.md +1 -1
  55. package/skills/auto/references/agent-report-contract.md +1 -1
  56. package/skills/auto/references/human-report-contract.md +25 -8
  57. package/skills/auto/references/tiers.md +24 -2
  58. package/skills/conventions/references/completion-and-done.md +3 -0
  59. package/skills/conventions/references/gates-and-enforcement.md +28 -12
  60. package/skills/conventions/references/locks-and-worktrees.md +3 -3
  61. package/skills/conventions/references/tiers-and-dispatch.md +10 -9
  62. package/skills/dashboard/SKILL.md +1 -1
  63. package/skills/doctor/SKILL.md +6 -5
  64. package/skills/doctor/references/gates-stuck-detection.md +13 -8
  65. package/skills/doctor/report.md +1 -1
  66. package/skills/install/SKILL.md +1 -1
  67. package/skills/intent-brainstorming/SKILL.md +0 -2
  68. package/skills/intent-creating/SKILL.md +6 -6
  69. package/skills/intent-creating/references/lifecycle.md +1 -1
  70. package/skills/intent-discovering/SKILL.md +10 -3
  71. package/skills/intent-ending/SKILL.md +8 -7
  72. package/skills/intent-executing/SKILL.md +27 -19
  73. package/skills/intent-grilling/SKILL.md +5 -3
  74. package/skills/intent-planning/SKILL.md +7 -3
  75. package/skills/intent-researching/SKILL.md +0 -2
  76. package/skills/intent-starting/SKILL.md +10 -2
  77. package/skills/project-creating/SKILL.md +0 -2
  78. package/skills/project-creating/references/project-scaffolding.md +1 -1
  79. package/skills/releasing/SKILL.md +1 -1
  80. package/skills/releasing/references/promotion-and-tagging.md +14 -8
  81. package/skills/releasing/references/release-lines.md +1 -1
  82. package/skills/skill-creating/SKILL.md +5 -2
  83. package/skills/store-indexing/SKILL.md +8 -5
  84. package/skills/store-indexing/references/zettelkasten-linking.md +1 -1
  85. package/skills/tutorial/references/track-1-guided.md +2 -2
  86. package/skills/tutorial/references/track-2-auto.md +9 -6
  87. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +1 -1
  88. package/skills/uninstall/SKILL.md +6 -9
  89. package/templates/agents.md +12 -12
  90. package/templates/config.yml +6 -7
  91. package/templates/index.md +6 -3
  92. package/templates/spec.md +1 -1
  93. package/skills/humanizer/SKILL.md +0 -40
  94. package/skills/humanizer/evals/evals.json +0 -70
  95. package/skills/humanizer/references/always-on-snippet.md +0 -9
  96. package/skills/humanizer/references/examples.md +0 -48
@@ -0,0 +1,1087 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # Core half of the Plastic doctor (intent 228). Holds run_core_checks and every
5
+ # method, constant and require it reaches, and nothing else, so the SessionStart
6
+ # hook can load the boot check without pulling in the full 3,100-line diagnostic
7
+ # engine. scripts/doctor.rb requires this file and reopens the same class to add
8
+ # the store, conventions, intent and CLI halves, so Doctor stays one class with
9
+ # one public surface and one definition of which checks are core (intent 36a).
10
+
11
+ require "json"
12
+ require "yaml"
13
+ require "time"
14
+ require "digest"
15
+ require "rubygems"
16
+
17
+ require_relative "hook_registry"
18
+
19
+ class Doctor
20
+ DEFAULT_PLASTIC_HOME = File.join(Dir.home, ".plastic")
21
+
22
+ DEFAULT_AGENTS = {
23
+ "claude" => { name: "Claude Code", dir: File.join(Dir.home, ".claude") },
24
+ "codex" => { name: "Codex CLI", dir: File.join(Dir.home, ".agents"),
25
+ home_dir: File.join(Dir.home, ".codex") },
26
+ "hermes" => { name: "Hermes", dir: File.join(Dir.home, ".hermes") },
27
+ }.freeze
28
+
29
+ CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit].freeze
30
+
31
+ # Launchers the installer places in the agent's hooks dir that are NOT hooks
32
+ # (intent 204): plastic-statusline is the settings["statusLine"] command, wired
33
+ # outside HookRegistry.events entirely, so it must be excluded from the
34
+ # orphan-launcher scan below or a correct install would report a false orphan.
35
+ CLAUDE_NON_HOOK_LAUNCHERS = %w[plastic-statusline].freeze
36
+
37
+ REQUIRED_SCRIPTS = %w[
38
+ folgezettel-id
39
+ read-config
40
+ hook-session-start
41
+ hook-continue
42
+ hook-future-intent-check
43
+ hook-gate-check
44
+ validate-intent
45
+ doctor.rb
46
+ ].freeze
47
+
48
+ # The apply_patch PreToolUse veto only fires from Codex v0.123.0 onward
49
+ # (PR #18391, "emit hooks for apply_patch edits"). Below it the veto
50
+ # silently no-ops (intent 184).
51
+ CODEX_HOOKS_FLOOR = "0.123.0"
52
+
53
+ attr_reader :plastic_home, :agents
54
+
55
+ # Testable shell-out default, mirroring QmdSync.default_runner: a real
56
+ # Open3.capture3 call in production, swappable for a fake in tests so no
57
+ # test needs a real codex binary or a PATH mutation.
58
+ def self.default_runner
59
+ lambda do |args|
60
+ require "open3"
61
+ out, _err, status = Open3.capture3("codex", *args)
62
+ [out, status.success?]
63
+ rescue Errno::ENOENT
64
+ ["", false] # codex not on PATH: undetectable, fail open
65
+ end
66
+ end
67
+
68
+ def initialize(plastic_home: DEFAULT_PLASTIC_HOME, agents: DEFAULT_AGENTS,
69
+ runner: Doctor.default_runner)
70
+ @plastic_home = plastic_home
71
+ @agents = agents
72
+ @runner = runner
73
+ end
74
+
75
+ # --- Utility helpers ---
76
+
77
+ def read_version
78
+ version_path = File.join(plastic_home, "VERSION")
79
+ return nil unless File.exist?(version_path)
80
+
81
+ File.read(version_path).strip
82
+ end
83
+
84
+ def read_json_safe(path)
85
+ return nil unless File.exist?(path)
86
+
87
+ JSON.parse(File.read(path))
88
+ rescue JSON::ParserError
89
+ content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
90
+ JSON.parse(content)
91
+ rescue
92
+ nil
93
+ end
94
+
95
+ def load_yaml_safe(path)
96
+ return nil unless File.exist?(path)
97
+
98
+ YAML.safe_load(File.read(path)) || {}
99
+ rescue => e
100
+ $stderr.puts "Warning: failed to parse #{path}: #{e.message}"
101
+ nil
102
+ end
103
+
104
+ def tilde(path)
105
+ path.sub(Dir.home, "~")
106
+ end
107
+
108
+ def check(category:, name:, status:, message:, details: [], fixable: false, fix_hint: nil)
109
+ result = {
110
+ category: category,
111
+ name: name,
112
+ status: status,
113
+ message: message,
114
+ details: details,
115
+ fixable: fixable,
116
+ }
117
+ result[:fix_hint] = fix_hint if fix_hint
118
+ result
119
+ end
120
+
121
+ def check_global_store_available
122
+ checks = []
123
+
124
+ if File.directory?(plastic_home)
125
+ checks << check(
126
+ category: "global_store", name: "global_store_reachable", status: "pass",
127
+ message: "Global store directory reachable at #{tilde(plastic_home)}"
128
+ )
129
+ else
130
+ checks << check(
131
+ category: "global_store", name: "global_store_reachable", status: "fail",
132
+ message: "Global store directory not found at #{tilde(plastic_home)}",
133
+ fixable: true, fix_hint: "Run the Plastic installer to bootstrap the store"
134
+ )
135
+ return checks
136
+ end
137
+
138
+ index_path = File.join(plastic_home, "INDEX.md")
139
+ if File.exist?(index_path)
140
+ checks << check(
141
+ category: "global_store", name: "global_index_reachable", status: "pass",
142
+ message: "INDEX.md exists"
143
+ )
144
+ else
145
+ checks << check(
146
+ category: "global_store", name: "global_index_reachable", status: "fail",
147
+ message: "INDEX.md not found at #{tilde(index_path)}",
148
+ fixable: true, fix_hint: "Run the Plastic installer to bootstrap the store"
149
+ )
150
+ end
151
+
152
+ checks
153
+ end
154
+
155
+ def check_agent_registration(agent_key)
156
+ checks = []
157
+ config = agents[agent_key]
158
+ agent_dir = config[:dir]
159
+
160
+ unless File.directory?(agent_dir)
161
+ checks << check(
162
+ category: "agent_registration", name: "agent_dir_exists", status: "fail",
163
+ message: "Agent directory #{tilde(agent_dir)} not found — #{config[:name]} may not be installed",
164
+ fixable: false
165
+ )
166
+ return checks
167
+ end
168
+
169
+ case agent_key
170
+ when "claude"
171
+ checks += check_claude_registration(agent_dir)
172
+ when "codex"
173
+ checks += check_codex_registration(agent_key, agent_dir)
174
+ else
175
+ checks += check_generic_agent_registration(agent_key, agent_dir)
176
+ end
177
+
178
+ checks
179
+ end
180
+
181
+ def check_claude_registration(agent_dir)
182
+ checks = []
183
+ hooks_dir = File.join(agent_dir, "hooks")
184
+
185
+ # Derived from HookRegistry.events (intent 204), not a hand-kept list, so
186
+ # every registered hook (all 15, including the enforcement gates) is checked.
187
+ expected_launchers = HookRegistry.claude_launcher_names
188
+
189
+ # hooks_exist
190
+ missing_hooks = expected_launchers.reject { |h| File.exist?(File.join(hooks_dir, h)) }
191
+
192
+ if missing_hooks.empty?
193
+ checks << check(
194
+ category: "agent_registration", name: "hooks_exist", status: "pass",
195
+ message: "All #{expected_launchers.size} expected hook scripts exist"
196
+ )
197
+ else
198
+ checks << check(
199
+ category: "agent_registration", name: "hooks_exist", status: "fail",
200
+ message: "#{missing_hooks.size} hook script(s) missing",
201
+ details: missing_hooks.map { |h| "#{tilde(hooks_dir)}/#{h}" },
202
+ fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
203
+ )
204
+ end
205
+
206
+ # hooks_executable
207
+ existing_hooks = expected_launchers
208
+ .map { |h| File.join(hooks_dir, h) }
209
+ .select { |p| File.exist?(p) }
210
+
211
+ non_executable = existing_hooks.reject { |p| File.executable?(p) }
212
+
213
+ if non_executable.empty?
214
+ checks << check(
215
+ category: "agent_registration", name: "hooks_executable", status: "pass",
216
+ message: "All existing hook scripts are executable"
217
+ )
218
+ else
219
+ checks << check(
220
+ category: "agent_registration", name: "hooks_executable", status: "fail",
221
+ message: "#{non_executable.size} hook script(s) not executable",
222
+ details: non_executable.map { |p| tilde(p) },
223
+ fixable: true, fix_hint: "chmod +x on the listed files"
224
+ )
225
+ end
226
+
227
+ # hooks_no_orphans: the mirror of hooks_exist. A plastic-* launcher on disk
228
+ # that HookRegistry does not know about is dead code the next reader would
229
+ # trust as live (the same drift class as a missing launcher, just facing
230
+ # the other way). plastic-statusline is a legitimate non-hook installer
231
+ # artifact (see CLAUDE_NON_HOOK_LAUNCHERS) and is excluded here.
232
+ present_launchers = Dir.glob(File.join(hooks_dir, "plastic-*")).map { |p| File.basename(p) }
233
+ orphans = (present_launchers - expected_launchers - CLAUDE_NON_HOOK_LAUNCHERS).sort
234
+
235
+ if orphans.empty?
236
+ checks << check(
237
+ category: "agent_registration", name: "hooks_no_orphans", status: "pass",
238
+ message: "No orphaned hook launchers in #{tilde(hooks_dir)}"
239
+ )
240
+ else
241
+ checks << check(
242
+ category: "agent_registration", name: "hooks_no_orphans", status: "warn",
243
+ message: "#{orphans.size} hook launcher(s) on disk are not registered in HookRegistry",
244
+ details: orphans.map { |h| "#{tilde(hooks_dir)}/#{h}" },
245
+ fixable: true,
246
+ fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude (prunes stale launchers)"
247
+ )
248
+ end
249
+
250
+ # claude_hooks_implemented (intent 244): the edit-path gates now live as
251
+ # branches inside one dispatcher (scripts/hook-edit-gates), which is
252
+ # exactly the shape that let links-gate ship registered and dead on
253
+ # Codex (intent 200's failure class, one level up).
254
+ checks << claude_hooks_implemented_check
255
+
256
+ # hooks_registered — settings.json has Plastic hooks for required events
257
+ settings_path = File.join(agent_dir, "settings.json")
258
+ settings = read_json_safe(settings_path)
259
+
260
+ if settings.nil?
261
+ checks << check(
262
+ category: "agent_registration", name: "hooks_registered", status: "fail",
263
+ message: "Cannot read #{tilde(settings_path)} — file missing or invalid",
264
+ fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
265
+ )
266
+ else
267
+ hooks = settings["hooks"] || {}
268
+ missing_events = CLAUDE_HOOK_EVENTS.reject do |event|
269
+ groups = hooks[event]
270
+ next false unless groups.is_a?(Array)
271
+
272
+ groups.any? do |group|
273
+ group.is_a?(Hash) && group["hooks"].is_a?(Array) &&
274
+ group["hooks"].any? { |h| h["command"].to_s.include?("plastic-") }
275
+ end
276
+ end
277
+
278
+ if missing_events.empty?
279
+ checks << check(
280
+ category: "agent_registration", name: "hooks_registered", status: "pass",
281
+ message: "All #{CLAUDE_HOOK_EVENTS.size} hook events registered in settings.json"
282
+ )
283
+ else
284
+ checks << check(
285
+ category: "agent_registration", name: "hooks_registered", status: "fail",
286
+ message: "#{missing_events.size} hook event(s) not registered in settings.json",
287
+ details: missing_events,
288
+ fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
289
+ )
290
+ end
291
+
292
+ # hooks_match_registry (intent 108, D7): the live settings must carry
293
+ # EXACTLY the registrations HookRegistry defines; any drift (a missing
294
+ # gate, a stray plastic hook, a stale matcher) is how bash-gate shipped
295
+ # dead once already.
296
+ expected = HookRegistry.claude_settings_hooks(hook_dir: hooks_dir)
297
+ diffs = []
298
+ expected.each do |event, group|
299
+ groups = group.is_a?(Array) ? group : [group]
300
+ live = settings.dig("hooks", event) || []
301
+ groups.each do |g|
302
+ matches = live.select { |h| h.is_a?(Hash) && h["matcher"] == g["matcher"] }
303
+ wanted = g["hooks"].map { |h| h["command"] }
304
+ got = matches.flat_map { |m| Array(m["hooks"]).map { |h| h["command"] } }
305
+ missing = wanted - got
306
+ diffs << "#{event}[#{g['matcher']}] missing: #{missing.join(', ')}" unless missing.empty?
307
+ end
308
+ end
309
+ live_plastic = (settings["hooks"] || {}).flat_map do |event, groups|
310
+ Array(groups).flat_map do |g|
311
+ next [] unless g.is_a?(Hash) && g["hooks"].is_a?(Array)
312
+ g["hooks"].map { |h| h["command"].to_s }.select { |c| c.include?("plastic-") }
313
+ .map { |c| "#{event}: #{c}" }
314
+ end
315
+ end
316
+ expected_cmds = expected.values.flat_map { |g| g.is_a?(Array) ? g : [g] }
317
+ .flat_map { |g| g["hooks"].map { |h| h["command"] } }
318
+ strays = live_plastic.reject { |lp| expected_cmds.any? { |c| lp.end_with?(c) } }
319
+ diffs.concat(strays.map { |s| "stray: #{s}" })
320
+
321
+ checks << if diffs.empty?
322
+ check(category: "agent_registration", name: "hooks_match_registry",
323
+ status: "pass", message: "settings.json hooks match HookRegistry")
324
+ else
325
+ check(category: "agent_registration", name: "hooks_match_registry",
326
+ status: "fail", message: "#{diffs.size} hook registration(s) diverge from HookRegistry",
327
+ details: diffs, fixable: true,
328
+ fix_hint: "Re-run the installer merge: npx @zalom/plastic update (or ruby ~/.plastic/scripts/install.rb)")
329
+ end
330
+ end
331
+
332
+ # skills_exist — flat, hyphen-namespaced personal skills (plastic-<name>/)
333
+ checks << flat_skills_check(agent_dir, "--claude")
334
+
335
+ # stray_skills — installed plastic-* skill dir with no manifest entry (a leftover,
336
+ # e.g. an old-name copy after a rename; intent 158a AC15)
337
+ stray_check = stray_skills_check(agent_dir, "--claude", File.join(agent_dir, "plastic", "manifest.json"))
338
+ checks << stray_check if stray_check
339
+
340
+ # agents_exist — auto-mode role files (plastic-*.md) synced into <dir>/agents
341
+ checks << flat_agents_check(agent_dir, "--claude")
342
+
343
+ checks
344
+ end
345
+
346
+ def claude_dispatcher_gate_names(source)
347
+ case_start = source.index(/^\s*case gate\b/)
348
+ return nil unless case_start
349
+
350
+ case_body = source[case_start..-1]
351
+ end_idx = case_body.index(/^\s*end\b/)
352
+ scanned = end_idx ? case_body[0...end_idx] : case_body
353
+ names = scanned.scan(/^\s*when\s+"([^"]+)"/).flatten.uniq
354
+ names.empty? ? nil : names
355
+ end
356
+
357
+ def claude_hooks_implemented_check
358
+ dispatcher_path = File.join(plastic_home, "scripts", "hook-edit-gates")
359
+
360
+ unless File.exist?(dispatcher_path)
361
+ return check(
362
+ category: "agent_registration", name: "claude_hooks_implemented", status: "fail",
363
+ message: "scripts/hook-edit-gates not found at #{tilde(dispatcher_path)}; cannot verify " \
364
+ "the edit-path gate registry and dispatcher agree",
365
+ fixable: true, fix_hint: "Re-run the Plastic installer"
366
+ )
367
+ end
368
+
369
+ dispatcher_names = claude_dispatcher_gate_names(File.read(dispatcher_path))
370
+
371
+ if dispatcher_names.nil?
372
+ return check(
373
+ category: "agent_registration", name: "claude_hooks_implemented", status: "fail",
374
+ message: "Could not read any gate names out of #{tilde(dispatcher_path)}: the `case gate` " \
375
+ "statement no longer matches the shape this check expects, so the registry was " \
376
+ "never actually checked against the real dispatcher. Update " \
377
+ "claude_dispatcher_gate_names in doctor.rb to the file's new shape.",
378
+ fixable: false
379
+ )
380
+ end
381
+
382
+ registry_names = HookRegistry::GATE_TOOLS.keys
383
+ missing_branch = registry_names - dispatcher_names
384
+ dead_branch = dispatcher_names - registry_names
385
+
386
+ if missing_branch.empty? && dead_branch.empty?
387
+ return check(
388
+ category: "agent_registration", name: "claude_hooks_implemented", status: "pass",
389
+ message: "Every registered edit-path gate has a scripts/hook-edit-gates branch, and every " \
390
+ "dispatcher branch is registered"
391
+ )
392
+ end
393
+
394
+ details = missing_branch.map do |name|
395
+ "#{name} is in HookRegistry::GATE_TOOLS but scripts/hook-edit-gates has no branch for it, " \
396
+ "so the dispatcher skips it on every edit and it never blocks anything"
397
+ end
398
+ details += dead_branch.map do |name|
399
+ "#{name} has a branch in scripts/hook-edit-gates but is not in HookRegistry::GATE_TOOLS, so " \
400
+ "the dispatcher never reaches it and it is dead code"
401
+ end
402
+
403
+ check(
404
+ category: "agent_registration", name: "claude_hooks_implemented", status: "fail",
405
+ message: "The edit-path gate registry and scripts/hook-edit-gates disagree on " \
406
+ "#{details.size} gate(s)",
407
+ details: details, fixable: false
408
+ )
409
+ end
410
+
411
+ def flat_skills_check(agent_dir, installer_flag)
412
+ skills_root = File.join(agent_dir, "skills")
413
+ found = Dir.glob(File.join(skills_root, "plastic-*", "SKILL.md"))
414
+
415
+ if !found.empty?
416
+ check(
417
+ category: "agent_registration", name: "skills_exist", status: "pass",
418
+ message: "#{found.size} plastic-* skill(s) installed in #{tilde(skills_root)}"
419
+ )
420
+ else
421
+ check(
422
+ category: "agent_registration", name: "skills_exist", status: "fail",
423
+ message: "No plastic-* skills found in #{tilde(skills_root)}",
424
+ fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest #{installer_flag}"
425
+ )
426
+ end
427
+ end
428
+
429
+ def stray_skills_check(agent_dir, installer_flag, manifest_path)
430
+ skills_root = File.join(agent_dir, "skills")
431
+ manifest = read_json_safe(manifest_path)
432
+ files = manifest.is_a?(Hash) ? manifest["files"] : nil
433
+ return nil unless files.is_a?(Hash)
434
+
435
+ installed = Dir.glob(File.join(skills_root, "plastic-*", "SKILL.md"))
436
+ .map { |f| File.basename(File.dirname(f)) }
437
+ tracked = files.keys
438
+ .select { |p| p.start_with?("#{skills_root}/") }
439
+ .map { |p| p.sub("#{skills_root}/", "").split("/").first }
440
+ .uniq
441
+
442
+ strays = (installed - tracked).sort
443
+
444
+ if strays.empty?
445
+ check(
446
+ category: "agent_registration", name: "stray_skills", status: "pass",
447
+ message: "No stray plastic-* skill directories in #{tilde(skills_root)}"
448
+ )
449
+ else
450
+ check(
451
+ category: "agent_registration", name: "stray_skills", status: "warn",
452
+ message: "#{strays.size} installed skill dir(s) not in the current manifest (stray, e.g. a leftover old-name copy)",
453
+ details: strays,
454
+ fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest #{installer_flag}"
455
+ )
456
+ end
457
+ end
458
+
459
+ def flat_agents_check(agent_dir, installer_flag)
460
+ agents_root = File.join(agent_dir, "agents")
461
+ found = Dir.glob(File.join(agents_root, "plastic-*.md"))
462
+
463
+ if !found.empty?
464
+ check(
465
+ category: "agent_registration", name: "agents_exist", status: "pass",
466
+ message: "#{found.size} plastic-* agent(s) installed in #{tilde(agents_root)}"
467
+ )
468
+ else
469
+ check(
470
+ category: "agent_registration", name: "agents_exist", status: "fail",
471
+ message: "No plastic-* agents found in #{tilde(agents_root)}",
472
+ fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest #{installer_flag}"
473
+ )
474
+ end
475
+ end
476
+
477
+ def check_flat_skills_and_stray(agent_key, agent_dir)
478
+ checks = []
479
+
480
+ # For codex/hermes: just check skills exist (no settings.json hooks)
481
+ checks << flat_skills_check(agent_dir, "--#{agent_key}")
482
+
483
+ # stray_skills — installed plastic-* skill dir with no manifest entry (a leftover,
484
+ # e.g. an old-name copy after a rename; intent 158a AC15)
485
+ stray_check = stray_skills_check(agent_dir, "--#{agent_key}", File.join(agent_dir, "plastic", "manifest.json"))
486
+ checks << stray_check if stray_check
487
+
488
+ checks
489
+ end
490
+
491
+ def check_generic_agent_registration(agent_key, agent_dir)
492
+ checks = check_flat_skills_and_stray(agent_key, agent_dir)
493
+ checks << flat_agents_check(agent_dir, "--#{agent_key}") # hermes: unchanged (.md copy)
494
+ checks
495
+ end
496
+
497
+ # Codex marker literals. Keep in sync with InstallerCore::CODEX_SECTION_BEGIN_PREFIX /
498
+ # CODEX_SECTION_END (doctor does not require installer_core, so the literals are duplicated).
499
+ CODEX_SECTION_BEGIN_PREFIX = "<!-- BEGIN PLASTIC INTEGRATION"
500
+ CODEX_SECTION_END = "<!-- END PLASTIC INTEGRATION -->"
501
+
502
+ def check_codex_registration(agent_key, agent_dir)
503
+ config = agents[agent_key]
504
+ checks = check_flat_skills_and_stray(agent_key, agent_dir)
505
+ checks << codex_agents_toml_check(config)
506
+
507
+ agents_md = File.join(config[:home_dir], "AGENTS.md")
508
+
509
+ if !File.exist?(agents_md)
510
+ checks << check(
511
+ category: "agent_registration", name: "codex_agents_md", status: "fail",
512
+ message: "Codex AGENTS.md not found at #{tilde(agents_md)}",
513
+ fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
514
+ )
515
+ else
516
+ content = File.read(agents_md)
517
+ b = content.index(CODEX_SECTION_BEGIN_PREFIX)
518
+ e = content.index(CODEX_SECTION_END)
519
+ well_formed = b && e && e > b && content[b...e].include?("-->")
520
+ if well_formed
521
+ checks << check(
522
+ category: "agent_registration", name: "codex_agents_md", status: "pass",
523
+ message: "Codex AGENTS.md carries the Plastic section"
524
+ )
525
+ else
526
+ checks << check(
527
+ category: "agent_registration", name: "codex_agents_md", status: "fail",
528
+ message: "Codex AGENTS.md is missing or has a malformed Plastic section",
529
+ fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
530
+ )
531
+ end
532
+ end
533
+
534
+ hooks_check = codex_hooks_registered_check(config)
535
+ checks << hooks_check
536
+ checks << codex_hooks_implemented_check(config)
537
+ checks << codex_hook_trust_advisory_check if hooks_check[:status] == "pass"
538
+ codex_config_toml_advisory_check(config).tap { |c| checks << c if c }
539
+ codex_version_floor_check(config).tap { |c| checks << c if c }
540
+
541
+ checks
542
+ end
543
+
544
+ # Hooks being REGISTERED (hooks.json content matches HookRegistry) is not
545
+ # the same as hooks being TRUSTED: Codex gates every non-managed hook
546
+ # command behind a human /hooks review, keyed by the command's current
547
+ # hash, so a release that changes a hook command re-arms the review
548
+ # (intent 198, Decision D2). Whether Codex persists a queryable trust
549
+ # record anywhere under ~/.codex is undocumented and unverified, so this
550
+ # can never be a real pass or fail on trust state; it is an advisory that
551
+ # always names the /hooks step, and it fires only once hooks are actually
552
+ # registered (an unregistered hook is already reported by
553
+ # codex_hooks_registered_check, so reminding about trust on top of that
554
+ # would be noise, not signal).
555
+ def codex_hook_trust_advisory_check
556
+ check(
557
+ category: "agent_registration", name: "codex_hooks_trust", status: "warn",
558
+ message: "Open Codex, run /hooks, and trust the Plastic hook definitions. " \
559
+ "Codex re-arms this review whenever a hook's command changes.",
560
+ fixable: false
561
+ )
562
+ end
563
+
564
+ # Codex-specific agent presence + structural sanity: ~/.codex/agents/plastic-*.toml
565
+ # exist and each carries the mandatory fields. No TOML parser (doctor depends on none):
566
+ # "structural" means the mandatory keys appear as lines and the multi-line
567
+ # developer_instructions string is balanced (an opening triple-quote has a later one).
568
+ def codex_agents_toml_check(config)
569
+ agents_root = File.join(config[:home_dir], "agents")
570
+ found = Dir.glob(File.join(agents_root, "plastic-*.toml"))
571
+
572
+ if found.empty?
573
+ return check(
574
+ category: "agent_registration", name: "codex_agents_toml", status: "fail",
575
+ message: "No plastic-* agent TOML files found in #{tilde(agents_root)}",
576
+ fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
577
+ )
578
+ end
579
+
580
+ malformed = found.reject { |f| codex_agent_toml_well_formed?(File.read(f)) }
581
+ if malformed.empty?
582
+ check(
583
+ category: "agent_registration", name: "codex_agents_toml", status: "pass",
584
+ message: "#{found.size} plastic-* agent TOML(s) installed in #{tilde(agents_root)}"
585
+ )
586
+ else
587
+ check(
588
+ category: "agent_registration", name: "codex_agents_toml", status: "fail",
589
+ message: "#{malformed.size} Codex agent TOML(s) missing mandatory fields",
590
+ details: malformed.map { |f| tilde(f) },
591
+ fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
592
+ )
593
+ end
594
+ end
595
+
596
+ def codex_agent_toml_well_formed?(content)
597
+ marker = 'developer_instructions = """'
598
+ di = content.index(marker)
599
+ has_name = content.match?(/^name\s*=\s*"/)
600
+ has_desc = content.match?(/^description\s*=\s*"/)
601
+ balanced = di && content.index('"""', di + marker.length)
602
+ has_name && has_desc && !di.nil? && !balanced.nil?
603
+ end
604
+
605
+ def codex_hooks_registered_check(config)
606
+ hooks_json = File.join(config[:home_dir], "hooks.json")
607
+ dispatcher = File.join(plastic_home, "scripts", "codex-hook")
608
+ data = read_json_safe(hooks_json)
609
+
610
+ if data.nil?
611
+ return check(
612
+ category: "agent_registration", name: "codex_hooks_registered", status: "fail",
613
+ message: "Codex hooks.json missing or unreadable at #{tilde(hooks_json)}",
614
+ fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
615
+ )
616
+ end
617
+
618
+ expected = HookRegistry.codex_hooks_json(dispatcher_path: dispatcher)
619
+ live = data["hooks"] || {}
620
+ missing = []
621
+ expected.each do |event, groups|
622
+ want = Array(groups).flat_map { |g| g["hooks"].map { |h| h["command"] } }
623
+ got = Array(live[event]).flat_map { |g| Array(g["hooks"]).map { |h| h["command"] } }
624
+ missing.concat(want - got)
625
+ end
626
+
627
+ if missing.empty?
628
+ check(
629
+ category: "agent_registration", name: "codex_hooks_registered", status: "pass",
630
+ message: "Codex hooks registered in hooks.json"
631
+ )
632
+ else
633
+ check(
634
+ category: "agent_registration", name: "codex_hooks_registered", status: "fail",
635
+ message: "Codex hooks.json missing #{missing.size} command(s)",
636
+ details: missing,
637
+ fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
638
+ )
639
+ end
640
+ end
641
+
642
+ # codex_hooks_implemented (intent 200): codex_hooks_registered_check above proves
643
+ # hooks.json content matches what HookRegistry would emit; both sides of THAT
644
+ # comparison come from the registry, so a pass only proves the registry agrees
645
+ # with itself. It never looks at the actual dispatcher, so it cannot see a
646
+ # registered gate with no real branch there (links-gate shipped exactly this way,
647
+ # dead, in v1.4.0/intent 192, invisible to doctor and the suite until 198 found it
648
+ # by hand), or a dispatcher branch nobody registers (bash-gate's shape, intent
649
+ # 203, in the opposite direction). This check closes both directions at once.
650
+
651
+ # The Codex gate names HookRegistry actually registers: the six apply_patch-gated
652
+ # names (CODEX_PRE_HOOKS + CODEX_POST_HOOKS), the two Bash-matcher shell gates
653
+ # (CODEX_BASH_HOOKS), and the live-state hook names Codex inherits whole from
654
+ # `events` (CODEX_LIVE_STATE_EVENTS). No parsing needed: these are HookRegistry's
655
+ # own Ruby constants.
656
+ def codex_registry_gate_names
657
+ live_state = HookRegistry::CODEX_LIVE_STATE_EVENTS.flat_map do |event|
658
+ HookRegistry.events[event].flat_map { |g| g["hooks"].map { |h| h["name"] } }
659
+ end
660
+ (HookRegistry::CODEX_PRE_HOOKS + HookRegistry::CODEX_POST_HOOKS +
661
+ HookRegistry::CODEX_BASH_HOOKS + live_state).uniq
662
+ end
663
+
664
+ def codex_dispatcher_gate_names(source)
665
+ names = []
666
+ %w[STATE_HOOKS SHELL_HOOKS].each do |const|
667
+ m = source.match(/^#{const}\s*=\s*%w\[([^\]]*)\]/)
668
+ names.concat(m[1].split(/\s+/)) if m
669
+ end
670
+
671
+ case_start = source.index(/^case gate\b/)
672
+ if case_start
673
+ case_body = source[case_start..-1]
674
+ else_idx = case_body.index(/^else\b/)
675
+ scanned = else_idx ? case_body[0...else_idx] : case_body
676
+ names.concat(scanned.scan(/^when\s+"([^"]+)"/).flatten)
677
+ end
678
+
679
+ names.uniq!
680
+ names.empty? ? nil : names
681
+ end
682
+
683
+ # The both-direction diff. Each mismatch becomes one detail line naming the
684
+ # hook, the direction, the harness ("Codex"), and the concrete runtime effect,
685
+ # never a generic "Codex hooks drift" message (D4).
686
+ def codex_hooks_implemented_check(config)
687
+ dispatcher_path = File.join(plastic_home, "scripts", "codex-hook")
688
+
689
+ unless File.exist?(dispatcher_path)
690
+ return check(
691
+ category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
692
+ message: "scripts/codex-hook not found at #{tilde(dispatcher_path)}; cannot verify " \
693
+ "the Codex hook registry and dispatcher agree",
694
+ fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
695
+ )
696
+ end
697
+
698
+ dispatcher_names = codex_dispatcher_gate_names(File.read(dispatcher_path))
699
+
700
+ if dispatcher_names.nil?
701
+ return check(
702
+ category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
703
+ message: "Could not read any gate names out of #{tilde(dispatcher_path)}: the " \
704
+ "STATE_HOOKS/SHELL_HOOKS constants and the `case gate` statement no longer " \
705
+ "match the shape this check expects, so the registry could not be checked " \
706
+ "against the real dispatcher. This is exactly the silent-pass failure this " \
707
+ "check exists to prevent; update codex_dispatcher_gate_names in doctor.rb " \
708
+ "to the file's new shape.",
709
+ fixable: false
710
+ )
711
+ end
712
+
713
+ registry_names = codex_registry_gate_names
714
+ missing_dispatcher_branch = registry_names - dispatcher_names
715
+ dead_branch = dispatcher_names - registry_names
716
+
717
+ if missing_dispatcher_branch.empty? && dead_branch.empty?
718
+ return check(
719
+ category: "agent_registration", name: "codex_hooks_implemented", status: "pass",
720
+ message: "Every Codex-registered gate has a scripts/codex-hook branch, and every " \
721
+ "dispatcher branch is registered"
722
+ )
723
+ end
724
+
725
+ details = missing_dispatcher_branch.map do |name|
726
+ "#{name} is registered in ~/.codex/hooks.json but scripts/codex-hook has no branch " \
727
+ "for it, so it always falls through to the fail-open else and always allows the write"
728
+ end
729
+ details += dead_branch.map do |name|
730
+ "#{name} has a branch in scripts/codex-hook but is not registered in HookRegistry for " \
731
+ "Codex, so it is dead code Codex never reaches"
732
+ end
733
+
734
+ check(
735
+ category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
736
+ message: "Codex's hook registry and scripts/codex-hook disagree on #{details.size} gate(s)",
737
+ details: details,
738
+ fixable: false
739
+ )
740
+ end
741
+
742
+ def codex_config_toml_advisory_check(config)
743
+ config_toml = File.join(config[:home_dir], "config.toml")
744
+ return nil unless File.exist?(config_toml)
745
+
746
+ toml = File.read(config_toml) rescue ""
747
+ warns = []
748
+ # guide Part 3: `codex_hooks` is a deprecated alias for `[features] hooks`; catch both.
749
+ warns << "hooks are disabled ([features] hooks = false); Plastic gates will not fire" if toml.match?(/^\s*(?:codex_)?hooks\s*=\s*false/)
750
+ warns << "sandbox_mode = \"read-only\"; apply_patch writes (and gates) cannot run" if toml.match?(/^\s*sandbox_mode\s*=\s*["']read-only["']/)
751
+ return nil if warns.empty?
752
+
753
+ check(
754
+ category: "agent_registration", name: "codex_config_advisory", status: "warn",
755
+ message: warns.join("; "),
756
+ fixable: false,
757
+ fix_hint: "Set [features] hooks = true and sandbox_mode = \"workspace-write\" in ~/.codex/config.toml"
758
+ )
759
+ end
760
+
761
+ # Codex version-floor advisory (intent 184): READ ONLY. The apply_patch PreToolUse
762
+ # veto (scripts/lib/hook_registry.rb, scripts/codex-hook) only fires from Codex
763
+ # v0.123.0 (PR #18391); below it the veto silently no-ops. version.json is Codex's
764
+ # update-checker cache and holds no installed version, so the only install-method-
765
+ # agnostic source is `codex --version`, shelled out through the injected runner.
766
+ # Four honest branches (intent 208, no pass-by-construction): absent home -> nil;
767
+ # present but undetectable -> distinct warn; below floor -> warn; at/above -> pass.
768
+ def codex_version_floor_check(config)
769
+ return nil unless File.exist?(config[:home_dir])
770
+
771
+ stdout, ok = @runner.call(["--version"])
772
+ version = ok ? codex_version_from_output(stdout) : nil
773
+ parsed = version && safe_version(version)
774
+
775
+ if parsed.nil?
776
+ return check(
777
+ category: "agent_registration", name: "codex_version_floor", status: "warn",
778
+ message: "Could not determine the installed Codex version (`codex --version` did not " \
779
+ "return a parseable version); Plastic cannot confirm the apply_patch hooks " \
780
+ "floor v#{CODEX_HOOKS_FLOOR} is met, so its gate may silently no-op",
781
+ fixable: false,
782
+ fix_hint: "Ensure `codex` is on PATH and `codex --version` >= #{CODEX_HOOKS_FLOOR}"
783
+ )
784
+ end
785
+
786
+ if parsed < safe_version(CODEX_HOOKS_FLOOR)
787
+ return check(
788
+ category: "agent_registration", name: "codex_version_floor", status: "warn",
789
+ message: "Installed Codex #{version} predates v#{CODEX_HOOKS_FLOOR}; the apply_patch " \
790
+ "PreToolUse veto only exists from v#{CODEX_HOOKS_FLOOR} (PR #18391), so Plastic's " \
791
+ "gate silently no-ops on this install",
792
+ fixable: false,
793
+ fix_hint: "Upgrade Codex to v#{CODEX_HOOKS_FLOOR} or newer with your install method " \
794
+ "(npm i -g @openai/codex, mise, homebrew, or cargo)"
795
+ )
796
+ end
797
+
798
+ check(
799
+ category: "agent_registration", name: "codex_version_floor", status: "pass",
800
+ message: "Codex #{version} meets the apply_patch hooks floor v#{CODEX_HOOKS_FLOOR}"
801
+ )
802
+ end
803
+
804
+ def codex_version_from_output(stdout)
805
+ m = stdout.to_s.match(/(\d+\.\d+\.\d+(?:[.\-+][0-9A-Za-z.\-+]*)?)/)
806
+ m && m[1]
807
+ end
808
+
809
+ def safe_version(str)
810
+ Gem::Version.new(str.to_s)
811
+ rescue ArgumentError
812
+ nil
813
+ end
814
+
815
+ def check_core_files(agent_key, include_drift: true)
816
+ checks = []
817
+
818
+ # plastic_md
819
+ plastic_md = File.join(plastic_home, "PLASTIC.md")
820
+ if File.exist?(plastic_md)
821
+ checks << check(
822
+ category: "core_files", name: "plastic_md", status: "pass",
823
+ message: "PLASTIC.md exists"
824
+ )
825
+ else
826
+ checks << check(
827
+ category: "core_files", name: "plastic_md", status: "fail",
828
+ message: "PLASTIC.md not found at #{tilde(plastic_md)}",
829
+ fixable: true, fix_hint: "Re-run the Plastic installer to restore core files"
830
+ )
831
+ end
832
+
833
+ # version_file
834
+ version_path = File.join(plastic_home, "VERSION")
835
+ if File.exist?(version_path)
836
+ checks << check(
837
+ category: "core_files", name: "version_file", status: "pass",
838
+ message: "VERSION file exists"
839
+ )
840
+ else
841
+ checks << check(
842
+ category: "core_files", name: "version_file", status: "fail",
843
+ message: "VERSION file not found at #{tilde(version_path)}",
844
+ fixable: true, fix_hint: "Re-run the Plastic installer to restore core files"
845
+ )
846
+ end
847
+
848
+ # scripts_present
849
+ scripts_dir = File.join(plastic_home, "scripts")
850
+ missing_scripts = REQUIRED_SCRIPTS.reject { |s| File.exist?(File.join(scripts_dir, s)) }
851
+
852
+ if missing_scripts.empty?
853
+ checks << check(
854
+ category: "core_files", name: "scripts_present", status: "pass",
855
+ message: "All #{REQUIRED_SCRIPTS.size} required scripts present"
856
+ )
857
+ else
858
+ checks << check(
859
+ category: "core_files", name: "scripts_present", status: "fail",
860
+ message: "#{missing_scripts.size} required script(s) missing from #{tilde(scripts_dir)}",
861
+ details: missing_scripts,
862
+ fixable: true, fix_hint: "Re-run the Plastic installer to restore scripts"
863
+ )
864
+ end
865
+
866
+ # scripts_executable
867
+ if File.directory?(scripts_dir)
868
+ script_files = Dir.children(scripts_dir)
869
+ .map { |f| File.join(scripts_dir, f) }
870
+ .select { |f| File.file?(f) }
871
+
872
+ non_executable = script_files.reject { |f| File.executable?(f) }
873
+
874
+ if non_executable.empty?
875
+ checks << check(
876
+ category: "core_files", name: "scripts_executable", status: "pass",
877
+ message: "All scripts in #{tilde(scripts_dir)} are executable"
878
+ )
879
+ else
880
+ checks << check(
881
+ category: "core_files", name: "scripts_executable", status: "fail",
882
+ message: "#{non_executable.size} script(s) not executable",
883
+ details: non_executable.map { |f| tilde(f) },
884
+ fixable: true, fix_hint: "chmod +x on the listed files"
885
+ )
886
+ end
887
+ end
888
+
889
+ # version_match — compare global VERSION with agent-side VERSION
890
+ global_version = read_version
891
+ agent_config = agents[agent_key]
892
+
893
+ if global_version && agent_config
894
+ agent_version_path = case agent_key
895
+ when "claude" then File.join(agent_config[:dir], "plastic", "VERSION")
896
+ else File.join(agent_config[:dir], "plastic", "VERSION")
897
+ end
898
+
899
+ if File.exist?(agent_version_path)
900
+ agent_version = File.read(agent_version_path).strip
901
+
902
+ if global_version == agent_version
903
+ checks << check(
904
+ category: "core_files", name: "version_match", status: "pass",
905
+ message: "Global VERSION (#{global_version}) matches agent-side VERSION"
906
+ )
907
+ else
908
+ checks << check(
909
+ category: "core_files", name: "version_match", status: "warn",
910
+ message: "Version mismatch: global=#{global_version}, agent=#{agent_version}",
911
+ details: [
912
+ "#{tilde(File.join(plastic_home, "VERSION"))}: #{global_version}",
913
+ "#{tilde(agent_version_path)}: #{agent_version}",
914
+ ],
915
+ fixable: true,
916
+ fix_hint: "Re-sync the stale harness: npx @zalom/plastic@latest install --reinstall <flag>, or plastic-rollback to a prior version"
917
+ )
918
+ end
919
+ else
920
+ checks << check(
921
+ category: "core_files", name: "version_match", status: "warn",
922
+ message: "Agent-side VERSION file not found at #{tilde(agent_version_path)}",
923
+ fixable: true,
924
+ fix_hint: "Re-sync the stale harness: npx @zalom/plastic@latest install --reinstall <flag>, or plastic-rollback to a prior version"
925
+ )
926
+ end
927
+ end
928
+
929
+ checks += check_agent_model_drift(agent_key) if include_drift
930
+
931
+ checks
932
+ end
933
+
934
+ def check_manifest_sync(agent_key)
935
+ checks = []
936
+
937
+ global_manifest = File.join(plastic_home, "manifest.json")
938
+ checks << verify_manifest(global_manifest, "global")
939
+
940
+ agent_dir = agents[agent_key][:dir]
941
+ agent_manifest = File.join(agent_dir, "plastic", "manifest.json")
942
+ checks << verify_manifest(agent_manifest, "agent")
943
+
944
+ checks
945
+ end
946
+
947
+ # Check one manifest file. Returns a single check (pass or fail).
948
+ def verify_manifest(manifest_path, label)
949
+ unless File.exist?(manifest_path)
950
+ return check(
951
+ category: "manifest_sync", name: "#{label}_manifest", status: "fail",
952
+ message: "#{label} core manifest missing — re-run the Plastic installer",
953
+ details: [tilde(manifest_path)],
954
+ fixable: true, fix_hint: "Re-run the Plastic installer"
955
+ )
956
+ end
957
+
958
+ data = read_json_safe(manifest_path)
959
+ files = data.is_a?(Hash) ? data["files"] : nil
960
+ unless files.is_a?(Hash)
961
+ return check(
962
+ category: "manifest_sync", name: "#{label}_manifest", status: "fail",
963
+ message: "#{label} core manifest unreadable or malformed — re-run the Plastic installer",
964
+ details: [tilde(manifest_path)],
965
+ fixable: true, fix_hint: "Re-run the Plastic installer"
966
+ )
967
+ end
968
+
969
+ missing = []
970
+ mismatched = []
971
+ files.each do |path, recorded|
972
+ unless File.exist?(path)
973
+ missing << tilde(path)
974
+ next
975
+ end
976
+ actual = Digest::SHA256.file(path).hexdigest
977
+ mismatched << tilde(path) if actual != recorded
978
+ end
979
+
980
+ if missing.empty? && mismatched.empty?
981
+ check(
982
+ category: "manifest_sync", name: "#{label}_manifest", status: "pass",
983
+ message: "#{label} manifest: all #{files.size} tracked file(s) present and matching"
984
+ )
985
+ else
986
+ details = []
987
+ details += missing.map { |p| "missing: #{p}" }
988
+ details += mismatched.map { |p| "modified: #{p}" }
989
+ check(
990
+ category: "manifest_sync", name: "#{label}_manifest", status: "fail",
991
+ message: "#{label} manifest out of sync: #{missing.size} missing, #{mismatched.size} modified",
992
+ details: details,
993
+ fixable: true, fix_hint: "Re-run the Plastic installer to restore tracked files"
994
+ )
995
+ end
996
+ end
997
+
998
+
999
+ # Binary core sync check: agent registration + core files (drift excluded) + manifest
1000
+ # sync + registered project paths + global store availability, rolled up with binary:
1001
+ # true so ANY warn or fail makes the overall status "fail" (and "warn" is never
1002
+ # emitted). Used by `doctor.rb --core` (219 D1/D2: operational readiness only, no
1003
+ # agent_model_drift, no store-content scanning).
1004
+ def run_core_checks(agent_key)
1005
+ all_checks = []
1006
+ all_checks += check_agent_registration(agent_key)
1007
+ all_checks += check_core_files(agent_key, include_drift: false)
1008
+ all_checks += check_manifest_sync(agent_key)
1009
+ all_checks += check_registered_project_paths
1010
+ all_checks += check_global_store_available
1011
+
1012
+ summarize(all_checks, agent_key, binary: true)
1013
+ end
1014
+
1015
+ def check_registered_project_paths
1016
+ checks = []
1017
+
1018
+ projects_yml_path = File.join(plastic_home, "projects.yml")
1019
+ projects_data = load_yaml_safe(projects_yml_path)
1020
+
1021
+ if projects_data.nil?
1022
+ checks << check(
1023
+ category: "project_stores", name: "registered_project_paths", status: "fail",
1024
+ message: "projects.yml not found or invalid at #{tilde(projects_yml_path)}",
1025
+ fixable: true, fix_hint: "Re-run the Plastic installer to restore projects.yml"
1026
+ )
1027
+ return checks
1028
+ end
1029
+
1030
+ projects = projects_data["projects"]
1031
+ unless projects.is_a?(Hash) && !projects.empty?
1032
+ checks << check(
1033
+ category: "project_stores", name: "registered_project_paths", status: "pass",
1034
+ message: "No projects registered"
1035
+ )
1036
+ return checks
1037
+ end
1038
+
1039
+ projects.each do |slug, project_info|
1040
+ path = project_info.is_a?(Hash) ? project_info["path"] : nil
1041
+
1042
+ if path && File.directory?(path)
1043
+ checks << check(
1044
+ category: "project_stores", name: "project_path_resolves", status: "pass",
1045
+ message: "Registered path for '#{slug}' resolves to a real directory"
1046
+ )
1047
+ else
1048
+ checks << check(
1049
+ category: "project_stores", name: "project_path_resolves", status: "fail",
1050
+ message: "Registered path for '#{slug}' does not resolve to a real directory",
1051
+ details: [path ? tilde(path) : "(no path set in projects.yml)"],
1052
+ fixable: false
1053
+ )
1054
+ end
1055
+ end
1056
+
1057
+ checks
1058
+ end
1059
+
1060
+ # Roll a list of checks up into the standard result envelope.
1061
+ # When binary: true, the overall status is "pass" only if there are zero warn
1062
+ # AND zero fail; any warn or fail yields "fail" (never "warn"). When false
1063
+ # (the default) the classic 3-state pass/warn/fail roll-up is used.
1064
+ def summarize(all_checks, agent_key, binary: false)
1065
+ summary = { pass: 0, warn: 0, fail: 0, total: all_checks.size }
1066
+ all_checks.each { |c| summary[c[:status].to_sym] += 1 }
1067
+
1068
+ overall = if binary
1069
+ (summary[:fail] > 0 || summary[:warn] > 0) ? "fail" : "pass"
1070
+ elsif summary[:fail] > 0
1071
+ "fail"
1072
+ elsif summary[:warn] > 0
1073
+ "warn"
1074
+ else
1075
+ "pass"
1076
+ end
1077
+
1078
+ {
1079
+ version: read_version || "unknown",
1080
+ timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
1081
+ status: overall,
1082
+ agent: agent_key,
1083
+ checks: all_checks,
1084
+ summary: summary,
1085
+ }
1086
+ end
1087
+ end