@zalom/plastic 1.9.0 → 1.10.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.
- package/PLASTIC.md +23 -8
- package/README.md +15 -4
- package/agents/plastic-enforcer.md +3 -2
- package/agents/plastic-intent-discovery.md +7 -0
- package/bin/plastic.js +17 -8
- package/hooks/auto-arm +2 -2
- package/hooks/bash-gate +1 -1
- package/hooks/check-update +1 -1
- package/hooks/continue +2 -2
- package/hooks/edit-gates +1 -1
- package/hooks/future-intent-check +2 -2
- package/hooks/gate-check +3 -3
- package/hooks/power-tools +1 -1
- package/hooks/session-start +1 -1
- package/package.json +1 -1
- package/scripts/codex-hook +50 -106
- package/scripts/doctor.rb +132 -1080
- package/scripts/exec-worktree +103 -0
- package/scripts/hash-intent +1 -1
- package/scripts/hook-bash-gate +19 -0
- package/scripts/hook-code-gate +4 -1
- package/scripts/hook-continue +2 -2
- package/scripts/hook-create-gate +6 -3
- package/scripts/hook-gate-check +17 -0
- package/scripts/hook-links-gate +4 -1
- package/scripts/hook-lock-gate +7 -3
- package/scripts/hook-savepoint-pre +4 -1
- package/scripts/hook-session-start +21 -15
- package/scripts/lib/apply_patch_envelope.rb +46 -13
- package/scripts/lib/bridge.rb +83 -15
- package/scripts/lib/codex_edit_gates.rb +138 -0
- package/scripts/lib/doctor_core.rb +1087 -0
- package/scripts/lib/edit_gates.rb +61 -5
- package/scripts/lib/exec_worktree.rb +325 -0
- package/scripts/lib/harness_text.rb +57 -0
- package/scripts/lib/hook_registry.rb +32 -28
- package/scripts/lib/installer_core.rb +67 -7
- package/scripts/lib/lock.rb +196 -47
- package/scripts/lib/ruby_probe.rb +60 -0
- package/scripts/lib/scaffold_intent.rb +392 -0
- package/scripts/lib/spec_header.rb +83 -0
- package/scripts/lib/start_intent.rb +296 -0
- package/scripts/lib/verify_intent.rb +262 -0
- package/scripts/lib/worktree.rb +15 -1
- package/scripts/link-suggest +1 -1
- package/scripts/maintenance-run +5 -5
- package/scripts/migrate-to-global +2 -2
- package/scripts/restore-intent-v1 +1 -1
- package/scripts/scaffold-intent +120 -0
- package/scripts/start-intent +89 -0
- package/scripts/verify-intent +73 -0
- package/skills/agent-advisor/SKILL.md +5 -5
- package/skills/auto/SKILL.md +42 -32
- package/skills/auto/references/agent-architecture.md +1 -1
- package/skills/auto/references/agent-report-contract.md +1 -1
- package/skills/auto/references/human-report-contract.md +22 -3
- package/skills/auto/references/tiers.md +24 -2
- package/skills/conventions/references/completion-and-done.md +3 -0
- package/skills/conventions/references/gates-and-enforcement.md +28 -12
- package/skills/conventions/references/locks-and-worktrees.md +3 -3
- package/skills/conventions/references/tiers-and-dispatch.md +7 -6
- package/skills/dashboard/SKILL.md +1 -1
- package/skills/doctor/SKILL.md +6 -5
- package/skills/doctor/references/gates-stuck-detection.md +13 -8
- package/skills/doctor/report.md +1 -1
- package/skills/install/SKILL.md +1 -1
- package/skills/intent-brainstorming/SKILL.md +0 -2
- package/skills/intent-creating/SKILL.md +6 -6
- package/skills/intent-creating/references/lifecycle.md +1 -1
- package/skills/intent-discovering/SKILL.md +10 -3
- package/skills/intent-ending/SKILL.md +8 -7
- package/skills/intent-executing/SKILL.md +27 -19
- package/skills/intent-grilling/SKILL.md +5 -3
- package/skills/intent-planning/SKILL.md +7 -3
- package/skills/intent-researching/SKILL.md +0 -2
- package/skills/intent-starting/SKILL.md +10 -2
- package/skills/project-creating/SKILL.md +0 -2
- package/skills/project-creating/references/project-scaffolding.md +1 -1
- package/skills/releasing/SKILL.md +1 -1
- package/skills/releasing/references/promotion-and-tagging.md +14 -8
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/skill-creating/SKILL.md +5 -2
- package/skills/store-indexing/SKILL.md +8 -5
- package/skills/store-indexing/references/zettelkasten-linking.md +1 -1
- package/skills/tutorial/references/track-1-guided.md +2 -2
- package/skills/tutorial/references/track-2-auto.md +9 -6
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +1 -1
- package/skills/uninstall/SKILL.md +6 -9
- package/templates/agents.md +12 -12
- package/templates/config.yml +6 -7
- package/templates/index.md +6 -3
- package/templates/spec.md +1 -1
|
@@ -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
|