@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.
- package/PLASTIC.md +23 -8
- package/README.md +16 -5
- 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 +25 -8
- 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 +10 -9
- 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
- package/skills/humanizer/SKILL.md +0 -40
- package/skills/humanizer/evals/evals.json +0 -70
- package/skills/humanizer/references/always-on-snippet.md +0 -9
- package/skills/humanizer/references/examples.md +0 -48
package/scripts/doctor.rb
CHANGED
|
@@ -9,12 +9,9 @@
|
|
|
9
9
|
# Exit codes: 0 (all pass), 1 (warnings only), 2 (failures present)
|
|
10
10
|
# Read-only — never modifies files.
|
|
11
11
|
|
|
12
|
-
require "json"
|
|
13
|
-
require "yaml"
|
|
14
|
-
require "time"
|
|
15
12
|
require "date"
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
|
|
14
|
+
require_relative "lib/doctor_core"
|
|
18
15
|
|
|
19
16
|
require_relative "lib/qmd_sync"
|
|
20
17
|
require_relative "lib/intent_validator"
|
|
@@ -22,7 +19,6 @@ require_relative "lib/graph_rebuild"
|
|
|
22
19
|
require_relative "lib/store_discovery"
|
|
23
20
|
require_relative "lib/links_projection"
|
|
24
21
|
require_relative "lib/links_section"
|
|
25
|
-
require_relative "lib/hook_registry"
|
|
26
22
|
require_relative "lib/lock"
|
|
27
23
|
require_relative "lib/bridge"
|
|
28
24
|
require_relative "lib/agent_models"
|
|
@@ -30,18 +26,16 @@ require_relative "lib/outcome_guard"
|
|
|
30
26
|
require_relative "lib/skill_lint"
|
|
31
27
|
require_relative "lib/config_asks"
|
|
32
28
|
require_relative "lib/power_tools"
|
|
29
|
+
require_relative "lib/preflight"
|
|
30
|
+
require_relative "lib/ruby_probe"
|
|
33
31
|
|
|
34
32
|
# Diagnostic engine, instantiable with an injected store/agent map so tests can
|
|
35
33
|
# run it hermetically (no eval, no global-constant rewriting).
|
|
34
|
+
# Reopens the core class defined in lib/doctor_core.rb (intent 228): that file
|
|
35
|
+
# holds run_core_checks and its transitive dependencies, this file adds the
|
|
36
|
+
# store, conventions, intent and CLI halves, so Doctor stays one class with one
|
|
37
|
+
# public surface.
|
|
36
38
|
class Doctor
|
|
37
|
-
DEFAULT_PLASTIC_HOME = File.join(Dir.home, ".plastic")
|
|
38
|
-
|
|
39
|
-
DEFAULT_AGENTS = {
|
|
40
|
-
"claude" => { name: "Claude Code", dir: File.join(Dir.home, ".claude") },
|
|
41
|
-
"codex" => { name: "Codex CLI", dir: File.join(Dir.home, ".agents"),
|
|
42
|
-
home_dir: File.join(Dir.home, ".codex") },
|
|
43
|
-
"hermes" => { name: "Hermes", dir: File.join(Dir.home, ".hermes") },
|
|
44
|
-
}.freeze
|
|
45
39
|
|
|
46
40
|
REQUIRED_INDEX_SECTIONS = ["## Active", "## Future", "## Clusters", "## Abandoned", "## Completed"].freeze
|
|
47
41
|
|
|
@@ -49,51 +43,7 @@ class Doctor
|
|
|
49
43
|
# (intent 60). Alias it here so the two can never drift.
|
|
50
44
|
REQUIRED_FRONTMATTER_FIELDS = IntentValidator::REQUIRED_FIELDS
|
|
51
45
|
|
|
52
|
-
CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit].freeze
|
|
53
|
-
|
|
54
|
-
# Launchers the installer places in the agent's hooks dir that are NOT hooks
|
|
55
|
-
# (intent 204): plastic-statusline is the settings["statusLine"] command, wired
|
|
56
|
-
# outside HookRegistry.events entirely, so it must be excluded from the
|
|
57
|
-
# orphan-launcher scan below or a correct install would report a false orphan.
|
|
58
|
-
CLAUDE_NON_HOOK_LAUNCHERS = %w[plastic-statusline].freeze
|
|
59
|
-
|
|
60
|
-
REQUIRED_SCRIPTS = %w[
|
|
61
|
-
folgezettel-id
|
|
62
|
-
read-config
|
|
63
|
-
hook-session-start
|
|
64
|
-
hook-continue
|
|
65
|
-
hook-future-intent-check
|
|
66
|
-
hook-gate-check
|
|
67
|
-
validate-intent
|
|
68
|
-
doctor.rb
|
|
69
|
-
].freeze
|
|
70
|
-
|
|
71
|
-
# The apply_patch PreToolUse veto only fires from Codex v0.123.0 onward
|
|
72
|
-
# (PR #18391, "emit hooks for apply_patch edits"). Below it the veto
|
|
73
|
-
# silently no-ops (intent 184).
|
|
74
|
-
CODEX_HOOKS_FLOOR = "0.123.0"
|
|
75
|
-
|
|
76
|
-
attr_reader :plastic_home, :agents
|
|
77
|
-
|
|
78
|
-
# Testable shell-out default, mirroring QmdSync.default_runner: a real
|
|
79
|
-
# Open3.capture3 call in production, swappable for a fake in tests so no
|
|
80
|
-
# test needs a real codex binary or a PATH mutation.
|
|
81
|
-
def self.default_runner
|
|
82
|
-
lambda do |args|
|
|
83
|
-
require "open3"
|
|
84
|
-
out, _err, status = Open3.capture3("codex", *args)
|
|
85
|
-
[out, status.success?]
|
|
86
|
-
rescue Errno::ENOENT
|
|
87
|
-
["", false] # codex not on PATH: undetectable, fail open
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
46
|
|
|
91
|
-
def initialize(plastic_home: DEFAULT_PLASTIC_HOME, agents: DEFAULT_AGENTS,
|
|
92
|
-
runner: Doctor.default_runner)
|
|
93
|
-
@plastic_home = plastic_home
|
|
94
|
-
@agents = agents
|
|
95
|
-
@runner = runner
|
|
96
|
-
end
|
|
97
47
|
|
|
98
48
|
# --- Flag parsing ---
|
|
99
49
|
|
|
@@ -180,51 +130,6 @@ class Doctor
|
|
|
180
130
|
HELP
|
|
181
131
|
end
|
|
182
132
|
|
|
183
|
-
# --- Utility helpers ---
|
|
184
|
-
|
|
185
|
-
def read_version
|
|
186
|
-
version_path = File.join(plastic_home, "VERSION")
|
|
187
|
-
return nil unless File.exist?(version_path)
|
|
188
|
-
|
|
189
|
-
File.read(version_path).strip
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
def read_json_safe(path)
|
|
193
|
-
return nil unless File.exist?(path)
|
|
194
|
-
|
|
195
|
-
JSON.parse(File.read(path))
|
|
196
|
-
rescue JSON::ParserError
|
|
197
|
-
content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
|
|
198
|
-
JSON.parse(content)
|
|
199
|
-
rescue
|
|
200
|
-
nil
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
def load_yaml_safe(path)
|
|
204
|
-
return nil unless File.exist?(path)
|
|
205
|
-
|
|
206
|
-
YAML.safe_load(File.read(path)) || {}
|
|
207
|
-
rescue => e
|
|
208
|
-
$stderr.puts "Warning: failed to parse #{path}: #{e.message}"
|
|
209
|
-
nil
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
def tilde(path)
|
|
213
|
-
path.sub(Dir.home, "~")
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
def check(category:, name:, status:, message:, details: [], fixable: false, fix_hint: nil)
|
|
217
|
-
result = {
|
|
218
|
-
category: category,
|
|
219
|
-
name: name,
|
|
220
|
-
status: status,
|
|
221
|
-
message: message,
|
|
222
|
-
details: details,
|
|
223
|
-
fixable: fixable,
|
|
224
|
-
}
|
|
225
|
-
result[:fix_hint] = fix_hint if fix_hint
|
|
226
|
-
result
|
|
227
|
-
end
|
|
228
133
|
|
|
229
134
|
# --- Parse frontmatter from an intent markdown file ---
|
|
230
135
|
|
|
@@ -379,39 +284,6 @@ class Doctor
|
|
|
379
284
|
# Narrow, core-appropriate version of check_global_store: presence/readability only, zero
|
|
380
285
|
# content scanning. check_global_store (above) is store/full-scope and includes
|
|
381
286
|
# orphaned_intents / ghost_references, both explicitly forbidden at core by D1.
|
|
382
|
-
def check_global_store_available
|
|
383
|
-
checks = []
|
|
384
|
-
|
|
385
|
-
if File.directory?(plastic_home)
|
|
386
|
-
checks << check(
|
|
387
|
-
category: "global_store", name: "global_store_reachable", status: "pass",
|
|
388
|
-
message: "Global store directory reachable at #{tilde(plastic_home)}"
|
|
389
|
-
)
|
|
390
|
-
else
|
|
391
|
-
checks << check(
|
|
392
|
-
category: "global_store", name: "global_store_reachable", status: "fail",
|
|
393
|
-
message: "Global store directory not found at #{tilde(plastic_home)}",
|
|
394
|
-
fixable: true, fix_hint: "Run the Plastic installer to bootstrap the store"
|
|
395
|
-
)
|
|
396
|
-
return checks
|
|
397
|
-
end
|
|
398
|
-
|
|
399
|
-
index_path = File.join(plastic_home, "INDEX.md")
|
|
400
|
-
if File.exist?(index_path)
|
|
401
|
-
checks << check(
|
|
402
|
-
category: "global_store", name: "global_index_reachable", status: "pass",
|
|
403
|
-
message: "INDEX.md exists"
|
|
404
|
-
)
|
|
405
|
-
else
|
|
406
|
-
checks << check(
|
|
407
|
-
category: "global_store", name: "global_index_reachable", status: "fail",
|
|
408
|
-
message: "INDEX.md not found at #{tilde(index_path)}",
|
|
409
|
-
fixable: true, fix_hint: "Run the Plastic installer to bootstrap the store"
|
|
410
|
-
)
|
|
411
|
-
end
|
|
412
|
-
|
|
413
|
-
checks
|
|
414
|
-
end
|
|
415
287
|
|
|
416
288
|
# --- Check category 2: Conventions ---
|
|
417
289
|
|
|
@@ -1252,196 +1124,7 @@ end
|
|
|
1252
1124
|
|
|
1253
1125
|
# --- Check category 3: Agent registration ---
|
|
1254
1126
|
|
|
1255
|
-
def check_agent_registration(agent_key)
|
|
1256
|
-
checks = []
|
|
1257
|
-
config = agents[agent_key]
|
|
1258
|
-
agent_dir = config[:dir]
|
|
1259
|
-
|
|
1260
|
-
unless File.directory?(agent_dir)
|
|
1261
|
-
checks << check(
|
|
1262
|
-
category: "agent_registration", name: "agent_dir_exists", status: "fail",
|
|
1263
|
-
message: "Agent directory #{tilde(agent_dir)} not found — #{config[:name]} may not be installed",
|
|
1264
|
-
fixable: false
|
|
1265
|
-
)
|
|
1266
|
-
return checks
|
|
1267
|
-
end
|
|
1268
|
-
|
|
1269
|
-
case agent_key
|
|
1270
|
-
when "claude"
|
|
1271
|
-
checks += check_claude_registration(agent_dir)
|
|
1272
|
-
when "codex"
|
|
1273
|
-
checks += check_codex_registration(agent_key, agent_dir)
|
|
1274
|
-
else
|
|
1275
|
-
checks += check_generic_agent_registration(agent_key, agent_dir)
|
|
1276
|
-
end
|
|
1277
|
-
|
|
1278
|
-
checks
|
|
1279
|
-
end
|
|
1280
|
-
|
|
1281
|
-
def check_claude_registration(agent_dir)
|
|
1282
|
-
checks = []
|
|
1283
|
-
hooks_dir = File.join(agent_dir, "hooks")
|
|
1284
|
-
|
|
1285
|
-
# Derived from HookRegistry.events (intent 204), not a hand-kept list, so
|
|
1286
|
-
# every registered hook (all 15, including the enforcement gates) is checked.
|
|
1287
|
-
expected_launchers = HookRegistry.claude_launcher_names
|
|
1288
|
-
|
|
1289
|
-
# hooks_exist
|
|
1290
|
-
missing_hooks = expected_launchers.reject { |h| File.exist?(File.join(hooks_dir, h)) }
|
|
1291
|
-
|
|
1292
|
-
if missing_hooks.empty?
|
|
1293
|
-
checks << check(
|
|
1294
|
-
category: "agent_registration", name: "hooks_exist", status: "pass",
|
|
1295
|
-
message: "All #{expected_launchers.size} expected hook scripts exist"
|
|
1296
|
-
)
|
|
1297
|
-
else
|
|
1298
|
-
checks << check(
|
|
1299
|
-
category: "agent_registration", name: "hooks_exist", status: "fail",
|
|
1300
|
-
message: "#{missing_hooks.size} hook script(s) missing",
|
|
1301
|
-
details: missing_hooks.map { |h| "#{tilde(hooks_dir)}/#{h}" },
|
|
1302
|
-
fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
|
|
1303
|
-
)
|
|
1304
|
-
end
|
|
1305
|
-
|
|
1306
|
-
# hooks_executable
|
|
1307
|
-
existing_hooks = expected_launchers
|
|
1308
|
-
.map { |h| File.join(hooks_dir, h) }
|
|
1309
|
-
.select { |p| File.exist?(p) }
|
|
1310
|
-
|
|
1311
|
-
non_executable = existing_hooks.reject { |p| File.executable?(p) }
|
|
1312
|
-
|
|
1313
|
-
if non_executable.empty?
|
|
1314
|
-
checks << check(
|
|
1315
|
-
category: "agent_registration", name: "hooks_executable", status: "pass",
|
|
1316
|
-
message: "All existing hook scripts are executable"
|
|
1317
|
-
)
|
|
1318
|
-
else
|
|
1319
|
-
checks << check(
|
|
1320
|
-
category: "agent_registration", name: "hooks_executable", status: "fail",
|
|
1321
|
-
message: "#{non_executable.size} hook script(s) not executable",
|
|
1322
|
-
details: non_executable.map { |p| tilde(p) },
|
|
1323
|
-
fixable: true, fix_hint: "chmod +x on the listed files"
|
|
1324
|
-
)
|
|
1325
|
-
end
|
|
1326
|
-
|
|
1327
|
-
# hooks_no_orphans: the mirror of hooks_exist. A plastic-* launcher on disk
|
|
1328
|
-
# that HookRegistry does not know about is dead code the next reader would
|
|
1329
|
-
# trust as live (the same drift class as a missing launcher, just facing
|
|
1330
|
-
# the other way). plastic-statusline is a legitimate non-hook installer
|
|
1331
|
-
# artifact (see CLAUDE_NON_HOOK_LAUNCHERS) and is excluded here.
|
|
1332
|
-
present_launchers = Dir.glob(File.join(hooks_dir, "plastic-*")).map { |p| File.basename(p) }
|
|
1333
|
-
orphans = (present_launchers - expected_launchers - CLAUDE_NON_HOOK_LAUNCHERS).sort
|
|
1334
|
-
|
|
1335
|
-
if orphans.empty?
|
|
1336
|
-
checks << check(
|
|
1337
|
-
category: "agent_registration", name: "hooks_no_orphans", status: "pass",
|
|
1338
|
-
message: "No orphaned hook launchers in #{tilde(hooks_dir)}"
|
|
1339
|
-
)
|
|
1340
|
-
else
|
|
1341
|
-
checks << check(
|
|
1342
|
-
category: "agent_registration", name: "hooks_no_orphans", status: "warn",
|
|
1343
|
-
message: "#{orphans.size} hook launcher(s) on disk are not registered in HookRegistry",
|
|
1344
|
-
details: orphans.map { |h| "#{tilde(hooks_dir)}/#{h}" },
|
|
1345
|
-
fixable: true,
|
|
1346
|
-
fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude (prunes stale launchers)"
|
|
1347
|
-
)
|
|
1348
|
-
end
|
|
1349
|
-
|
|
1350
|
-
# claude_hooks_implemented (intent 244): the edit-path gates now live as
|
|
1351
|
-
# branches inside one dispatcher (scripts/hook-edit-gates), which is
|
|
1352
|
-
# exactly the shape that let links-gate ship registered and dead on
|
|
1353
|
-
# Codex (intent 200's failure class, one level up).
|
|
1354
|
-
checks << claude_hooks_implemented_check
|
|
1355
|
-
|
|
1356
|
-
# hooks_registered — settings.json has Plastic hooks for required events
|
|
1357
|
-
settings_path = File.join(agent_dir, "settings.json")
|
|
1358
|
-
settings = read_json_safe(settings_path)
|
|
1359
|
-
|
|
1360
|
-
if settings.nil?
|
|
1361
|
-
checks << check(
|
|
1362
|
-
category: "agent_registration", name: "hooks_registered", status: "fail",
|
|
1363
|
-
message: "Cannot read #{tilde(settings_path)} — file missing or invalid",
|
|
1364
|
-
fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
|
|
1365
|
-
)
|
|
1366
|
-
else
|
|
1367
|
-
hooks = settings["hooks"] || {}
|
|
1368
|
-
missing_events = CLAUDE_HOOK_EVENTS.reject do |event|
|
|
1369
|
-
groups = hooks[event]
|
|
1370
|
-
next false unless groups.is_a?(Array)
|
|
1371
|
-
|
|
1372
|
-
groups.any? do |group|
|
|
1373
|
-
group.is_a?(Hash) && group["hooks"].is_a?(Array) &&
|
|
1374
|
-
group["hooks"].any? { |h| h["command"].to_s.include?("plastic-") }
|
|
1375
|
-
end
|
|
1376
|
-
end
|
|
1377
|
-
|
|
1378
|
-
if missing_events.empty?
|
|
1379
|
-
checks << check(
|
|
1380
|
-
category: "agent_registration", name: "hooks_registered", status: "pass",
|
|
1381
|
-
message: "All #{CLAUDE_HOOK_EVENTS.size} hook events registered in settings.json"
|
|
1382
|
-
)
|
|
1383
|
-
else
|
|
1384
|
-
checks << check(
|
|
1385
|
-
category: "agent_registration", name: "hooks_registered", status: "fail",
|
|
1386
|
-
message: "#{missing_events.size} hook event(s) not registered in settings.json",
|
|
1387
|
-
details: missing_events,
|
|
1388
|
-
fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
|
|
1389
|
-
)
|
|
1390
|
-
end
|
|
1391
|
-
|
|
1392
|
-
# hooks_match_registry (intent 108, D7): the live settings must carry
|
|
1393
|
-
# EXACTLY the registrations HookRegistry defines; any drift (a missing
|
|
1394
|
-
# gate, a stray plastic hook, a stale matcher) is how bash-gate shipped
|
|
1395
|
-
# dead once already.
|
|
1396
|
-
expected = HookRegistry.claude_settings_hooks(hook_dir: hooks_dir)
|
|
1397
|
-
diffs = []
|
|
1398
|
-
expected.each do |event, group|
|
|
1399
|
-
groups = group.is_a?(Array) ? group : [group]
|
|
1400
|
-
live = settings.dig("hooks", event) || []
|
|
1401
|
-
groups.each do |g|
|
|
1402
|
-
matches = live.select { |h| h.is_a?(Hash) && h["matcher"] == g["matcher"] }
|
|
1403
|
-
wanted = g["hooks"].map { |h| h["command"] }
|
|
1404
|
-
got = matches.flat_map { |m| Array(m["hooks"]).map { |h| h["command"] } }
|
|
1405
|
-
missing = wanted - got
|
|
1406
|
-
diffs << "#{event}[#{g['matcher']}] missing: #{missing.join(', ')}" unless missing.empty?
|
|
1407
|
-
end
|
|
1408
|
-
end
|
|
1409
|
-
live_plastic = (settings["hooks"] || {}).flat_map do |event, groups|
|
|
1410
|
-
Array(groups).flat_map do |g|
|
|
1411
|
-
next [] unless g.is_a?(Hash) && g["hooks"].is_a?(Array)
|
|
1412
|
-
g["hooks"].map { |h| h["command"].to_s }.select { |c| c.include?("plastic-") }
|
|
1413
|
-
.map { |c| "#{event}: #{c}" }
|
|
1414
|
-
end
|
|
1415
|
-
end
|
|
1416
|
-
expected_cmds = expected.values.flat_map { |g| g.is_a?(Array) ? g : [g] }
|
|
1417
|
-
.flat_map { |g| g["hooks"].map { |h| h["command"] } }
|
|
1418
|
-
strays = live_plastic.reject { |lp| expected_cmds.any? { |c| lp.end_with?(c) } }
|
|
1419
|
-
diffs.concat(strays.map { |s| "stray: #{s}" })
|
|
1420
|
-
|
|
1421
|
-
checks << if diffs.empty?
|
|
1422
|
-
check(category: "agent_registration", name: "hooks_match_registry",
|
|
1423
|
-
status: "pass", message: "settings.json hooks match HookRegistry")
|
|
1424
|
-
else
|
|
1425
|
-
check(category: "agent_registration", name: "hooks_match_registry",
|
|
1426
|
-
status: "fail", message: "#{diffs.size} hook registration(s) diverge from HookRegistry",
|
|
1427
|
-
details: diffs, fixable: true,
|
|
1428
|
-
fix_hint: "Re-run the installer merge: npx @zalom/plastic update (or ruby ~/.plastic/scripts/install.rb)")
|
|
1429
|
-
end
|
|
1430
|
-
end
|
|
1431
|
-
|
|
1432
|
-
# skills_exist — flat, hyphen-namespaced personal skills (plastic-<name>/)
|
|
1433
|
-
checks << flat_skills_check(agent_dir, "--claude")
|
|
1434
|
-
|
|
1435
|
-
# stray_skills — installed plastic-* skill dir with no manifest entry (a leftover,
|
|
1436
|
-
# e.g. an old-name copy after a rename; intent 158a AC15)
|
|
1437
|
-
stray_check = stray_skills_check(agent_dir, "--claude", File.join(agent_dir, "plastic", "manifest.json"))
|
|
1438
|
-
checks << stray_check if stray_check
|
|
1439
1127
|
|
|
1440
|
-
# agents_exist — auto-mode role files (plastic-*.md) synced into <dir>/agents
|
|
1441
|
-
checks << flat_agents_check(agent_dir, "--claude")
|
|
1442
|
-
|
|
1443
|
-
checks
|
|
1444
|
-
end
|
|
1445
1128
|
|
|
1446
1129
|
# claude_hooks_implemented (intent 244, the Claude twin of intent 200's Codex
|
|
1447
1130
|
# check): the edit-path gates now live as branches inside one dispatcher,
|
|
@@ -1454,90 +1137,9 @@ end
|
|
|
1454
1137
|
# Read as plain text, never required: the dispatcher has top-level side effects
|
|
1455
1138
|
# (it reads $stdin and exits), the same reason codex_dispatcher_gate_names reads
|
|
1456
1139
|
# scripts/codex-hook as text.
|
|
1457
|
-
def claude_dispatcher_gate_names(source)
|
|
1458
|
-
case_start = source.index(/^\s*case gate\b/)
|
|
1459
|
-
return nil unless case_start
|
|
1460
|
-
|
|
1461
|
-
case_body = source[case_start..-1]
|
|
1462
|
-
end_idx = case_body.index(/^\s*end\b/)
|
|
1463
|
-
scanned = end_idx ? case_body[0...end_idx] : case_body
|
|
1464
|
-
names = scanned.scan(/^\s*when\s+"([^"]+)"/).flatten.uniq
|
|
1465
|
-
names.empty? ? nil : names
|
|
1466
|
-
end
|
|
1467
|
-
|
|
1468
|
-
def claude_hooks_implemented_check
|
|
1469
|
-
dispatcher_path = File.join(plastic_home, "scripts", "hook-edit-gates")
|
|
1470
|
-
|
|
1471
|
-
unless File.exist?(dispatcher_path)
|
|
1472
|
-
return check(
|
|
1473
|
-
category: "agent_registration", name: "claude_hooks_implemented", status: "fail",
|
|
1474
|
-
message: "scripts/hook-edit-gates not found at #{tilde(dispatcher_path)}; cannot verify " \
|
|
1475
|
-
"the edit-path gate registry and dispatcher agree",
|
|
1476
|
-
fixable: true, fix_hint: "Re-run the Plastic installer"
|
|
1477
|
-
)
|
|
1478
|
-
end
|
|
1479
|
-
|
|
1480
|
-
dispatcher_names = claude_dispatcher_gate_names(File.read(dispatcher_path))
|
|
1481
|
-
|
|
1482
|
-
if dispatcher_names.nil?
|
|
1483
|
-
return check(
|
|
1484
|
-
category: "agent_registration", name: "claude_hooks_implemented", status: "fail",
|
|
1485
|
-
message: "Could not read any gate names out of #{tilde(dispatcher_path)}: the `case gate` " \
|
|
1486
|
-
"statement no longer matches the shape this check expects, so the registry was " \
|
|
1487
|
-
"never actually checked against the real dispatcher. Update " \
|
|
1488
|
-
"claude_dispatcher_gate_names in doctor.rb to the file's new shape.",
|
|
1489
|
-
fixable: false
|
|
1490
|
-
)
|
|
1491
|
-
end
|
|
1492
|
-
|
|
1493
|
-
registry_names = HookRegistry::GATE_TOOLS.keys
|
|
1494
|
-
missing_branch = registry_names - dispatcher_names
|
|
1495
|
-
dead_branch = dispatcher_names - registry_names
|
|
1496
|
-
|
|
1497
|
-
if missing_branch.empty? && dead_branch.empty?
|
|
1498
|
-
return check(
|
|
1499
|
-
category: "agent_registration", name: "claude_hooks_implemented", status: "pass",
|
|
1500
|
-
message: "Every registered edit-path gate has a scripts/hook-edit-gates branch, and every " \
|
|
1501
|
-
"dispatcher branch is registered"
|
|
1502
|
-
)
|
|
1503
|
-
end
|
|
1504
|
-
|
|
1505
|
-
details = missing_branch.map do |name|
|
|
1506
|
-
"#{name} is in HookRegistry::GATE_TOOLS but scripts/hook-edit-gates has no branch for it, " \
|
|
1507
|
-
"so the dispatcher skips it on every edit and it never blocks anything"
|
|
1508
|
-
end
|
|
1509
|
-
details += dead_branch.map do |name|
|
|
1510
|
-
"#{name} has a branch in scripts/hook-edit-gates but is not in HookRegistry::GATE_TOOLS, so " \
|
|
1511
|
-
"the dispatcher never reaches it and it is dead code"
|
|
1512
|
-
end
|
|
1513
|
-
|
|
1514
|
-
check(
|
|
1515
|
-
category: "agent_registration", name: "claude_hooks_implemented", status: "fail",
|
|
1516
|
-
message: "The edit-path gate registry and scripts/hook-edit-gates disagree on " \
|
|
1517
|
-
"#{details.size} gate(s)",
|
|
1518
|
-
details: details, fixable: false
|
|
1519
|
-
)
|
|
1520
|
-
end
|
|
1521
1140
|
|
|
1522
1141
|
# Plastic skills install as ~/.claude/skills/plastic-<name>/SKILL.md. Pass if at
|
|
1523
1142
|
# least one such skill is present.
|
|
1524
|
-
def flat_skills_check(agent_dir, installer_flag)
|
|
1525
|
-
skills_root = File.join(agent_dir, "skills")
|
|
1526
|
-
found = Dir.glob(File.join(skills_root, "plastic-*", "SKILL.md"))
|
|
1527
|
-
|
|
1528
|
-
if !found.empty?
|
|
1529
|
-
check(
|
|
1530
|
-
category: "agent_registration", name: "skills_exist", status: "pass",
|
|
1531
|
-
message: "#{found.size} plastic-* skill(s) installed in #{tilde(skills_root)}"
|
|
1532
|
-
)
|
|
1533
|
-
else
|
|
1534
|
-
check(
|
|
1535
|
-
category: "agent_registration", name: "skills_exist", status: "fail",
|
|
1536
|
-
message: "No plastic-* skills found in #{tilde(skills_root)}",
|
|
1537
|
-
fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest #{installer_flag}"
|
|
1538
|
-
)
|
|
1539
|
-
end
|
|
1540
|
-
end
|
|
1541
1143
|
|
|
1542
1144
|
# Backstop for a rename or removal (intent 158a, AC15): an installed plastic-*
|
|
1543
1145
|
# skill directory under agent_dir/skills that has NO corresponding entry in the
|
|
@@ -1546,258 +1148,39 @@ end
|
|
|
1546
1148
|
# manifest predates it). Complements flat_skills_check (which only confirms at
|
|
1547
1149
|
# least one skill exists). Defers to the manifest check when the manifest itself
|
|
1548
1150
|
# is missing or malformed, so the two checks never double-report the same gap.
|
|
1549
|
-
def stray_skills_check(agent_dir, installer_flag, manifest_path)
|
|
1550
|
-
skills_root = File.join(agent_dir, "skills")
|
|
1551
|
-
manifest = read_json_safe(manifest_path)
|
|
1552
|
-
files = manifest.is_a?(Hash) ? manifest["files"] : nil
|
|
1553
|
-
return nil unless files.is_a?(Hash)
|
|
1554
|
-
|
|
1555
|
-
installed = Dir.glob(File.join(skills_root, "plastic-*", "SKILL.md"))
|
|
1556
|
-
.map { |f| File.basename(File.dirname(f)) }
|
|
1557
|
-
tracked = files.keys
|
|
1558
|
-
.select { |p| p.start_with?("#{skills_root}/") }
|
|
1559
|
-
.map { |p| p.sub("#{skills_root}/", "").split("/").first }
|
|
1560
|
-
.uniq
|
|
1561
|
-
|
|
1562
|
-
strays = (installed - tracked).sort
|
|
1563
|
-
|
|
1564
|
-
if strays.empty?
|
|
1565
|
-
check(
|
|
1566
|
-
category: "agent_registration", name: "stray_skills", status: "pass",
|
|
1567
|
-
message: "No stray plastic-* skill directories in #{tilde(skills_root)}"
|
|
1568
|
-
)
|
|
1569
|
-
else
|
|
1570
|
-
check(
|
|
1571
|
-
category: "agent_registration", name: "stray_skills", status: "warn",
|
|
1572
|
-
message: "#{strays.size} installed skill dir(s) not in the current manifest (stray, e.g. a leftover old-name copy)",
|
|
1573
|
-
details: strays,
|
|
1574
|
-
fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest #{installer_flag}"
|
|
1575
|
-
)
|
|
1576
|
-
end
|
|
1577
|
-
end
|
|
1578
1151
|
|
|
1579
1152
|
# Auto-mode role agents install as <dir>/agents/plastic-*.md. Pass if at least
|
|
1580
1153
|
# one such role file is present (the installer syncs agents/ on every install).
|
|
1581
|
-
def flat_agents_check(agent_dir, installer_flag)
|
|
1582
|
-
agents_root = File.join(agent_dir, "agents")
|
|
1583
|
-
found = Dir.glob(File.join(agents_root, "plastic-*.md"))
|
|
1584
|
-
|
|
1585
|
-
if !found.empty?
|
|
1586
|
-
check(
|
|
1587
|
-
category: "agent_registration", name: "agents_exist", status: "pass",
|
|
1588
|
-
message: "#{found.size} plastic-* agent(s) installed in #{tilde(agents_root)}"
|
|
1589
|
-
)
|
|
1590
|
-
else
|
|
1591
|
-
check(
|
|
1592
|
-
category: "agent_registration", name: "agents_exist", status: "fail",
|
|
1593
|
-
message: "No plastic-* agents found in #{tilde(agents_root)}",
|
|
1594
|
-
fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest #{installer_flag}"
|
|
1595
|
-
)
|
|
1596
|
-
end
|
|
1597
|
-
end
|
|
1598
1154
|
|
|
1599
1155
|
# Shared skills-presence plus stray-skills check, used by both the generic
|
|
1600
1156
|
# (hermes) agent-registration path and codex's TOML-based one. Neither the flat
|
|
1601
1157
|
# `.md` agents check nor anything agent-format-specific lives here.
|
|
1602
|
-
def check_flat_skills_and_stray(agent_key, agent_dir)
|
|
1603
|
-
checks = []
|
|
1604
|
-
|
|
1605
|
-
# For codex/hermes: just check skills exist (no settings.json hooks)
|
|
1606
|
-
checks << flat_skills_check(agent_dir, "--#{agent_key}")
|
|
1607
|
-
|
|
1608
|
-
# stray_skills — installed plastic-* skill dir with no manifest entry (a leftover,
|
|
1609
|
-
# e.g. an old-name copy after a rename; intent 158a AC15)
|
|
1610
|
-
stray_check = stray_skills_check(agent_dir, "--#{agent_key}", File.join(agent_dir, "plastic", "manifest.json"))
|
|
1611
|
-
checks << stray_check if stray_check
|
|
1612
|
-
|
|
1613
|
-
checks
|
|
1614
|
-
end
|
|
1615
|
-
|
|
1616
|
-
def check_generic_agent_registration(agent_key, agent_dir)
|
|
1617
|
-
checks = check_flat_skills_and_stray(agent_key, agent_dir)
|
|
1618
|
-
checks << flat_agents_check(agent_dir, "--#{agent_key}") # hermes: unchanged (.md copy)
|
|
1619
|
-
checks
|
|
1620
|
-
end
|
|
1621
|
-
|
|
1622
|
-
# Codex marker literals. Keep in sync with InstallerCore::CODEX_SECTION_BEGIN_PREFIX /
|
|
1623
|
-
# CODEX_SECTION_END (doctor does not require installer_core, so the literals are duplicated).
|
|
1624
|
-
CODEX_SECTION_BEGIN_PREFIX = "<!-- BEGIN PLASTIC INTEGRATION"
|
|
1625
|
-
CODEX_SECTION_END = "<!-- END PLASTIC INTEGRATION -->"
|
|
1626
|
-
|
|
1627
|
-
def check_codex_registration(agent_key, agent_dir)
|
|
1628
|
-
config = agents[agent_key]
|
|
1629
|
-
checks = check_flat_skills_and_stray(agent_key, agent_dir)
|
|
1630
|
-
checks << codex_agents_toml_check(config)
|
|
1631
|
-
|
|
1632
|
-
agents_md = File.join(config[:home_dir], "AGENTS.md")
|
|
1633
|
-
|
|
1634
|
-
if !File.exist?(agents_md)
|
|
1635
|
-
checks << check(
|
|
1636
|
-
category: "agent_registration", name: "codex_agents_md", status: "fail",
|
|
1637
|
-
message: "Codex AGENTS.md not found at #{tilde(agents_md)}",
|
|
1638
|
-
fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
|
|
1639
|
-
)
|
|
1640
|
-
else
|
|
1641
|
-
content = File.read(agents_md)
|
|
1642
|
-
b = content.index(CODEX_SECTION_BEGIN_PREFIX)
|
|
1643
|
-
e = content.index(CODEX_SECTION_END)
|
|
1644
|
-
well_formed = b && e && e > b && content[b...e].include?("-->")
|
|
1645
|
-
if well_formed
|
|
1646
|
-
checks << check(
|
|
1647
|
-
category: "agent_registration", name: "codex_agents_md", status: "pass",
|
|
1648
|
-
message: "Codex AGENTS.md carries the Plastic section"
|
|
1649
|
-
)
|
|
1650
|
-
else
|
|
1651
|
-
checks << check(
|
|
1652
|
-
category: "agent_registration", name: "codex_agents_md", status: "fail",
|
|
1653
|
-
message: "Codex AGENTS.md is missing or has a malformed Plastic section",
|
|
1654
|
-
fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
|
|
1655
|
-
)
|
|
1656
|
-
end
|
|
1657
|
-
end
|
|
1658
|
-
|
|
1659
|
-
hooks_check = codex_hooks_registered_check(config)
|
|
1660
|
-
checks << hooks_check
|
|
1661
|
-
checks << codex_hooks_implemented_check(config)
|
|
1662
|
-
checks << codex_hook_trust_advisory_check if hooks_check[:status] == "pass"
|
|
1663
|
-
codex_config_toml_advisory_check(config).tap { |c| checks << c if c }
|
|
1664
|
-
codex_version_floor_check(config).tap { |c| checks << c if c }
|
|
1665
|
-
|
|
1666
|
-
checks
|
|
1667
|
-
end
|
|
1668
|
-
|
|
1669
|
-
# Hooks being REGISTERED (hooks.json content matches HookRegistry) is not
|
|
1670
|
-
# the same as hooks being TRUSTED: Codex gates every non-managed hook
|
|
1671
|
-
# command behind a human /hooks review, keyed by the command's current
|
|
1672
|
-
# hash, so a release that changes a hook command re-arms the review
|
|
1673
|
-
# (intent 198, Decision D2). Whether Codex persists a queryable trust
|
|
1674
|
-
# record anywhere under ~/.codex is undocumented and unverified, so this
|
|
1675
|
-
# can never be a real pass or fail on trust state; it is an advisory that
|
|
1676
|
-
# always names the /hooks step, and it fires only once hooks are actually
|
|
1677
|
-
# registered (an unregistered hook is already reported by
|
|
1678
|
-
# codex_hooks_registered_check, so reminding about trust on top of that
|
|
1679
|
-
# would be noise, not signal).
|
|
1680
|
-
def codex_hook_trust_advisory_check
|
|
1681
|
-
check(
|
|
1682
|
-
category: "agent_registration", name: "codex_hooks_trust", status: "warn",
|
|
1683
|
-
message: "Open Codex, run /hooks, and trust the Plastic hook definitions. " \
|
|
1684
|
-
"Codex re-arms this review whenever a hook's command changes.",
|
|
1685
|
-
fixable: false
|
|
1686
|
-
)
|
|
1687
|
-
end
|
|
1688
|
-
|
|
1689
|
-
# Codex-specific agent presence + structural sanity: ~/.codex/agents/plastic-*.toml
|
|
1690
|
-
# exist and each carries the mandatory fields. No TOML parser (doctor depends on none):
|
|
1691
|
-
# "structural" means the mandatory keys appear as lines and the multi-line
|
|
1692
|
-
# developer_instructions string is balanced (an opening triple-quote has a later one).
|
|
1693
|
-
def codex_agents_toml_check(config)
|
|
1694
|
-
agents_root = File.join(config[:home_dir], "agents")
|
|
1695
|
-
found = Dir.glob(File.join(agents_root, "plastic-*.toml"))
|
|
1696
|
-
|
|
1697
|
-
if found.empty?
|
|
1698
|
-
return check(
|
|
1699
|
-
category: "agent_registration", name: "codex_agents_toml", status: "fail",
|
|
1700
|
-
message: "No plastic-* agent TOML files found in #{tilde(agents_root)}",
|
|
1701
|
-
fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
|
|
1702
|
-
)
|
|
1703
|
-
end
|
|
1704
1158
|
|
|
1705
|
-
malformed = found.reject { |f| codex_agent_toml_well_formed?(File.read(f)) }
|
|
1706
|
-
if malformed.empty?
|
|
1707
|
-
check(
|
|
1708
|
-
category: "agent_registration", name: "codex_agents_toml", status: "pass",
|
|
1709
|
-
message: "#{found.size} plastic-* agent TOML(s) installed in #{tilde(agents_root)}"
|
|
1710
|
-
)
|
|
1711
|
-
else
|
|
1712
|
-
check(
|
|
1713
|
-
category: "agent_registration", name: "codex_agents_toml", status: "fail",
|
|
1714
|
-
message: "#{malformed.size} Codex agent TOML(s) missing mandatory fields",
|
|
1715
|
-
details: malformed.map { |f| tilde(f) },
|
|
1716
|
-
fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
|
|
1717
|
-
)
|
|
1718
|
-
end
|
|
1719
|
-
end
|
|
1720
|
-
|
|
1721
|
-
def codex_agent_toml_well_formed?(content)
|
|
1722
|
-
marker = 'developer_instructions = """'
|
|
1723
|
-
di = content.index(marker)
|
|
1724
|
-
has_name = content.match?(/^name\s*=\s*"/)
|
|
1725
|
-
has_desc = content.match?(/^description\s*=\s*"/)
|
|
1726
|
-
balanced = di && content.index('"""', di + marker.length)
|
|
1727
|
-
has_name && has_desc && !di.nil? && !balanced.nil?
|
|
1728
|
-
end
|
|
1729
1159
|
|
|
1730
|
-
# Plain string extraction of the
|
|
1731
|
-
# Codex agent TOML
|
|
1732
|
-
#
|
|
1733
|
-
#
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1160
|
+
# Plain string extraction of the two model-selection lines a generated
|
|
1161
|
+
# Codex agent TOML can carry, read separately (intent 216). Per the
|
|
1162
|
+
# emission contract in installer_core.rb's codex_model_fields: a tier
|
|
1163
|
+
# alias emits BOTH `model` and `model_reasoning_effort` (model first); a
|
|
1164
|
+
# literal Codex model id override emits `model` only, no effort line; an
|
|
1165
|
+
# empty value emits neither. No TOML parser dependency, mirroring
|
|
1166
|
+
# codex_agent_toml_well_formed? above.
|
|
1167
|
+
#
|
|
1168
|
+
# Regex detail: `/^model\s*=\s*"([^"]*)"/` cannot match a
|
|
1169
|
+
# `model_reasoning_effort = "..."` line, because the character right
|
|
1170
|
+
# after `model` there is `_`, which matches neither `\s` nor `=`. The two
|
|
1171
|
+
# matchers are independent lookups and need no ordering trick, unlike the
|
|
1172
|
+
# single-value extractor this replaces. `^` anchors at line start in
|
|
1173
|
+
# Ruby by default, so no `/m` flag is needed.
|
|
1174
|
+
def codex_agent_toml_model_fields(content)
|
|
1175
|
+
model = content.match(/^model\s*=\s*"([^"]*)"/)
|
|
1176
|
+
effort = content.match(/^model_reasoning_effort\s*=\s*"([^"]*)"/)
|
|
1177
|
+
{ model: model && model[1], effort: effort && effort[1] }
|
|
1737
1178
|
end
|
|
1738
1179
|
|
|
1739
1180
|
# codex_hooks_registered (intent 102, the owner-facing first-run validation
|
|
1740
1181
|
# path, Decision 14): ~/.codex/hooks.json must carry EXACTLY the commands
|
|
1741
1182
|
# HookRegistry.codex_hooks_json defines, mirroring the Claude
|
|
1742
1183
|
# hooks_match_registry diff. Never writes.
|
|
1743
|
-
def codex_hooks_registered_check(config)
|
|
1744
|
-
hooks_json = File.join(config[:home_dir], "hooks.json")
|
|
1745
|
-
dispatcher = File.join(plastic_home, "scripts", "codex-hook")
|
|
1746
|
-
data = read_json_safe(hooks_json)
|
|
1747
|
-
|
|
1748
|
-
if data.nil?
|
|
1749
|
-
return check(
|
|
1750
|
-
category: "agent_registration", name: "codex_hooks_registered", status: "fail",
|
|
1751
|
-
message: "Codex hooks.json missing or unreadable at #{tilde(hooks_json)}",
|
|
1752
|
-
fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
|
|
1753
|
-
)
|
|
1754
|
-
end
|
|
1755
|
-
|
|
1756
|
-
expected = HookRegistry.codex_hooks_json(dispatcher_path: dispatcher)
|
|
1757
|
-
live = data["hooks"] || {}
|
|
1758
|
-
missing = []
|
|
1759
|
-
expected.each do |event, groups|
|
|
1760
|
-
want = Array(groups).flat_map { |g| g["hooks"].map { |h| h["command"] } }
|
|
1761
|
-
got = Array(live[event]).flat_map { |g| Array(g["hooks"]).map { |h| h["command"] } }
|
|
1762
|
-
missing.concat(want - got)
|
|
1763
|
-
end
|
|
1764
|
-
|
|
1765
|
-
if missing.empty?
|
|
1766
|
-
check(
|
|
1767
|
-
category: "agent_registration", name: "codex_hooks_registered", status: "pass",
|
|
1768
|
-
message: "Codex hooks registered in hooks.json"
|
|
1769
|
-
)
|
|
1770
|
-
else
|
|
1771
|
-
check(
|
|
1772
|
-
category: "agent_registration", name: "codex_hooks_registered", status: "fail",
|
|
1773
|
-
message: "Codex hooks.json missing #{missing.size} command(s)",
|
|
1774
|
-
details: missing,
|
|
1775
|
-
fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
|
|
1776
|
-
)
|
|
1777
|
-
end
|
|
1778
|
-
end
|
|
1779
|
-
|
|
1780
|
-
# codex_hooks_implemented (intent 200): codex_hooks_registered_check above proves
|
|
1781
|
-
# hooks.json content matches what HookRegistry would emit; both sides of THAT
|
|
1782
|
-
# comparison come from the registry, so a pass only proves the registry agrees
|
|
1783
|
-
# with itself. It never looks at the actual dispatcher, so it cannot see a
|
|
1784
|
-
# registered gate with no real branch there (links-gate shipped exactly this way,
|
|
1785
|
-
# dead, in v1.4.0/intent 192, invisible to doctor and the suite until 198 found it
|
|
1786
|
-
# by hand), or a dispatcher branch nobody registers (bash-gate's shape, intent
|
|
1787
|
-
# 203, in the opposite direction). This check closes both directions at once.
|
|
1788
|
-
|
|
1789
|
-
# The Codex gate names HookRegistry actually registers: the six apply_patch-gated
|
|
1790
|
-
# names (CODEX_PRE_HOOKS + CODEX_POST_HOOKS), the two Bash-matcher shell gates
|
|
1791
|
-
# (CODEX_BASH_HOOKS), and the live-state hook names Codex inherits whole from
|
|
1792
|
-
# `events` (CODEX_LIVE_STATE_EVENTS). No parsing needed: these are HookRegistry's
|
|
1793
|
-
# own Ruby constants.
|
|
1794
|
-
def codex_registry_gate_names
|
|
1795
|
-
live_state = HookRegistry::CODEX_LIVE_STATE_EVENTS.flat_map do |event|
|
|
1796
|
-
HookRegistry.events[event].flat_map { |g| g["hooks"].map { |h| h["name"] } }
|
|
1797
|
-
end
|
|
1798
|
-
(HookRegistry::CODEX_PRE_HOOKS + HookRegistry::CODEX_POST_HOOKS +
|
|
1799
|
-
HookRegistry::CODEX_BASH_HOOKS + live_state).uniq
|
|
1800
|
-
end
|
|
1801
1184
|
|
|
1802
1185
|
# Source-text extraction of scripts/codex-hook's supported gate names (D3): the
|
|
1803
1186
|
# dispatcher is an executable script with real top-level side effects (it reads
|
|
@@ -1817,280 +1200,13 @@ end
|
|
|
1817
1200
|
# finds nothing and calls that healthy IS the exact disease this whole check
|
|
1818
1201
|
# exists to catch, one level up, so nil forces the caller (below) to fail loudly
|
|
1819
1202
|
# instead of reporting health it never actually verified.
|
|
1820
|
-
def codex_dispatcher_gate_names(source)
|
|
1821
|
-
names = []
|
|
1822
|
-
%w[STATE_HOOKS SHELL_HOOKS].each do |const|
|
|
1823
|
-
m = source.match(/^#{const}\s*=\s*%w\[([^\]]*)\]/)
|
|
1824
|
-
names.concat(m[1].split(/\s+/)) if m
|
|
1825
|
-
end
|
|
1826
|
-
|
|
1827
|
-
case_start = source.index(/^case gate\b/)
|
|
1828
|
-
if case_start
|
|
1829
|
-
case_body = source[case_start..-1]
|
|
1830
|
-
else_idx = case_body.index(/^else\b/)
|
|
1831
|
-
scanned = else_idx ? case_body[0...else_idx] : case_body
|
|
1832
|
-
names.concat(scanned.scan(/^when\s+"([^"]+)"/).flatten)
|
|
1833
|
-
end
|
|
1834
|
-
|
|
1835
|
-
names.uniq!
|
|
1836
|
-
names.empty? ? nil : names
|
|
1837
|
-
end
|
|
1838
|
-
|
|
1839
|
-
# The both-direction diff. Each mismatch becomes one detail line naming the
|
|
1840
|
-
# hook, the direction, the harness ("Codex"), and the concrete runtime effect,
|
|
1841
|
-
# never a generic "Codex hooks drift" message (D4).
|
|
1842
|
-
def codex_hooks_implemented_check(config)
|
|
1843
|
-
dispatcher_path = File.join(plastic_home, "scripts", "codex-hook")
|
|
1844
|
-
|
|
1845
|
-
unless File.exist?(dispatcher_path)
|
|
1846
|
-
return check(
|
|
1847
|
-
category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
|
|
1848
|
-
message: "scripts/codex-hook not found at #{tilde(dispatcher_path)}; cannot verify " \
|
|
1849
|
-
"the Codex hook registry and dispatcher agree",
|
|
1850
|
-
fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
|
|
1851
|
-
)
|
|
1852
|
-
end
|
|
1853
|
-
|
|
1854
|
-
dispatcher_names = codex_dispatcher_gate_names(File.read(dispatcher_path))
|
|
1855
|
-
|
|
1856
|
-
if dispatcher_names.nil?
|
|
1857
|
-
return check(
|
|
1858
|
-
category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
|
|
1859
|
-
message: "Could not read any gate names out of #{tilde(dispatcher_path)}: the " \
|
|
1860
|
-
"STATE_HOOKS/SHELL_HOOKS constants and the `case gate` statement no longer " \
|
|
1861
|
-
"match the shape this check expects, so the registry could not be checked " \
|
|
1862
|
-
"against the real dispatcher. This is exactly the silent-pass failure this " \
|
|
1863
|
-
"check exists to prevent; update codex_dispatcher_gate_names in doctor.rb " \
|
|
1864
|
-
"to the file's new shape.",
|
|
1865
|
-
fixable: false
|
|
1866
|
-
)
|
|
1867
|
-
end
|
|
1868
|
-
|
|
1869
|
-
registry_names = codex_registry_gate_names
|
|
1870
|
-
missing_dispatcher_branch = registry_names - dispatcher_names
|
|
1871
|
-
dead_branch = dispatcher_names - registry_names
|
|
1872
|
-
|
|
1873
|
-
if missing_dispatcher_branch.empty? && dead_branch.empty?
|
|
1874
|
-
return check(
|
|
1875
|
-
category: "agent_registration", name: "codex_hooks_implemented", status: "pass",
|
|
1876
|
-
message: "Every Codex-registered gate has a scripts/codex-hook branch, and every " \
|
|
1877
|
-
"dispatcher branch is registered"
|
|
1878
|
-
)
|
|
1879
|
-
end
|
|
1880
|
-
|
|
1881
|
-
details = missing_dispatcher_branch.map do |name|
|
|
1882
|
-
"#{name} is registered in ~/.codex/hooks.json but scripts/codex-hook has no branch " \
|
|
1883
|
-
"for it, so it always falls through to the fail-open else and always allows the write"
|
|
1884
|
-
end
|
|
1885
|
-
details += dead_branch.map do |name|
|
|
1886
|
-
"#{name} has a branch in scripts/codex-hook but is not registered in HookRegistry for " \
|
|
1887
|
-
"Codex, so it is dead code Codex never reaches"
|
|
1888
|
-
end
|
|
1889
|
-
|
|
1890
|
-
check(
|
|
1891
|
-
category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
|
|
1892
|
-
message: "Codex's hook registry and scripts/codex-hook disagree on #{details.size} gate(s)",
|
|
1893
|
-
details: details,
|
|
1894
|
-
fixable: false
|
|
1895
|
-
)
|
|
1896
|
-
end
|
|
1897
1203
|
|
|
1898
1204
|
# config.toml advisory (intent 102, Decision 2, R2): READ ONLY. Warns on the two
|
|
1899
1205
|
# documented footguns (hooks disabled, sandbox read-only); never writes, and
|
|
1900
1206
|
# returns nil (no check emitted) when config.toml is absent or carries neither.
|
|
1901
|
-
def codex_config_toml_advisory_check(config)
|
|
1902
|
-
config_toml = File.join(config[:home_dir], "config.toml")
|
|
1903
|
-
return nil unless File.exist?(config_toml)
|
|
1904
|
-
|
|
1905
|
-
toml = File.read(config_toml) rescue ""
|
|
1906
|
-
warns = []
|
|
1907
|
-
# guide Part 3: `codex_hooks` is a deprecated alias for `[features] hooks`; catch both.
|
|
1908
|
-
warns << "hooks are disabled ([features] hooks = false); Plastic gates will not fire" if toml.match?(/^\s*(?:codex_)?hooks\s*=\s*false/)
|
|
1909
|
-
warns << "sandbox_mode = \"read-only\"; apply_patch writes (and gates) cannot run" if toml.match?(/^\s*sandbox_mode\s*=\s*["']read-only["']/)
|
|
1910
|
-
return nil if warns.empty?
|
|
1911
|
-
|
|
1912
|
-
check(
|
|
1913
|
-
category: "agent_registration", name: "codex_config_advisory", status: "warn",
|
|
1914
|
-
message: warns.join("; "),
|
|
1915
|
-
fixable: false,
|
|
1916
|
-
fix_hint: "Set [features] hooks = true and sandbox_mode = \"workspace-write\" in ~/.codex/config.toml"
|
|
1917
|
-
)
|
|
1918
|
-
end
|
|
1919
|
-
|
|
1920
|
-
# Codex version-floor advisory (intent 184): READ ONLY. The apply_patch PreToolUse
|
|
1921
|
-
# veto (scripts/lib/hook_registry.rb, scripts/codex-hook) only fires from Codex
|
|
1922
|
-
# v0.123.0 (PR #18391); below it the veto silently no-ops. version.json is Codex's
|
|
1923
|
-
# update-checker cache and holds no installed version, so the only install-method-
|
|
1924
|
-
# agnostic source is `codex --version`, shelled out through the injected runner.
|
|
1925
|
-
# Four honest branches (intent 208, no pass-by-construction): absent home -> nil;
|
|
1926
|
-
# present but undetectable -> distinct warn; below floor -> warn; at/above -> pass.
|
|
1927
|
-
def codex_version_floor_check(config)
|
|
1928
|
-
return nil unless File.exist?(config[:home_dir])
|
|
1929
|
-
|
|
1930
|
-
stdout, ok = @runner.call(["--version"])
|
|
1931
|
-
version = ok ? codex_version_from_output(stdout) : nil
|
|
1932
|
-
parsed = version && safe_version(version)
|
|
1933
|
-
|
|
1934
|
-
if parsed.nil?
|
|
1935
|
-
return check(
|
|
1936
|
-
category: "agent_registration", name: "codex_version_floor", status: "warn",
|
|
1937
|
-
message: "Could not determine the installed Codex version (`codex --version` did not " \
|
|
1938
|
-
"return a parseable version); Plastic cannot confirm the apply_patch hooks " \
|
|
1939
|
-
"floor v#{CODEX_HOOKS_FLOOR} is met, so its gate may silently no-op",
|
|
1940
|
-
fixable: false,
|
|
1941
|
-
fix_hint: "Ensure `codex` is on PATH and `codex --version` >= #{CODEX_HOOKS_FLOOR}"
|
|
1942
|
-
)
|
|
1943
|
-
end
|
|
1944
|
-
|
|
1945
|
-
if parsed < safe_version(CODEX_HOOKS_FLOOR)
|
|
1946
|
-
return check(
|
|
1947
|
-
category: "agent_registration", name: "codex_version_floor", status: "warn",
|
|
1948
|
-
message: "Installed Codex #{version} predates v#{CODEX_HOOKS_FLOOR}; the apply_patch " \
|
|
1949
|
-
"PreToolUse veto only exists from v#{CODEX_HOOKS_FLOOR} (PR #18391), so Plastic's " \
|
|
1950
|
-
"gate silently no-ops on this install",
|
|
1951
|
-
fixable: false,
|
|
1952
|
-
fix_hint: "Upgrade Codex to v#{CODEX_HOOKS_FLOOR} or newer with your install method " \
|
|
1953
|
-
"(npm i -g @openai/codex, mise, homebrew, or cargo)"
|
|
1954
|
-
)
|
|
1955
|
-
end
|
|
1956
|
-
|
|
1957
|
-
check(
|
|
1958
|
-
category: "agent_registration", name: "codex_version_floor", status: "pass",
|
|
1959
|
-
message: "Codex #{version} meets the apply_patch hooks floor v#{CODEX_HOOKS_FLOOR}"
|
|
1960
|
-
)
|
|
1961
|
-
end
|
|
1962
|
-
|
|
1963
|
-
def codex_version_from_output(stdout)
|
|
1964
|
-
m = stdout.to_s.match(/(\d+\.\d+\.\d+(?:[.\-+][0-9A-Za-z.\-+]*)?)/)
|
|
1965
|
-
m && m[1]
|
|
1966
|
-
end
|
|
1967
|
-
|
|
1968
|
-
def safe_version(str)
|
|
1969
|
-
Gem::Version.new(str.to_s)
|
|
1970
|
-
rescue ArgumentError
|
|
1971
|
-
nil
|
|
1972
|
-
end
|
|
1973
1207
|
|
|
1974
1208
|
# --- Check category 4: Core files ---
|
|
1975
1209
|
|
|
1976
|
-
def check_core_files(agent_key, include_drift: true)
|
|
1977
|
-
checks = []
|
|
1978
|
-
|
|
1979
|
-
# plastic_md
|
|
1980
|
-
plastic_md = File.join(plastic_home, "PLASTIC.md")
|
|
1981
|
-
if File.exist?(plastic_md)
|
|
1982
|
-
checks << check(
|
|
1983
|
-
category: "core_files", name: "plastic_md", status: "pass",
|
|
1984
|
-
message: "PLASTIC.md exists"
|
|
1985
|
-
)
|
|
1986
|
-
else
|
|
1987
|
-
checks << check(
|
|
1988
|
-
category: "core_files", name: "plastic_md", status: "fail",
|
|
1989
|
-
message: "PLASTIC.md not found at #{tilde(plastic_md)}",
|
|
1990
|
-
fixable: true, fix_hint: "Re-run the Plastic installer to restore core files"
|
|
1991
|
-
)
|
|
1992
|
-
end
|
|
1993
|
-
|
|
1994
|
-
# version_file
|
|
1995
|
-
version_path = File.join(plastic_home, "VERSION")
|
|
1996
|
-
if File.exist?(version_path)
|
|
1997
|
-
checks << check(
|
|
1998
|
-
category: "core_files", name: "version_file", status: "pass",
|
|
1999
|
-
message: "VERSION file exists"
|
|
2000
|
-
)
|
|
2001
|
-
else
|
|
2002
|
-
checks << check(
|
|
2003
|
-
category: "core_files", name: "version_file", status: "fail",
|
|
2004
|
-
message: "VERSION file not found at #{tilde(version_path)}",
|
|
2005
|
-
fixable: true, fix_hint: "Re-run the Plastic installer to restore core files"
|
|
2006
|
-
)
|
|
2007
|
-
end
|
|
2008
|
-
|
|
2009
|
-
# scripts_present
|
|
2010
|
-
scripts_dir = File.join(plastic_home, "scripts")
|
|
2011
|
-
missing_scripts = REQUIRED_SCRIPTS.reject { |s| File.exist?(File.join(scripts_dir, s)) }
|
|
2012
|
-
|
|
2013
|
-
if missing_scripts.empty?
|
|
2014
|
-
checks << check(
|
|
2015
|
-
category: "core_files", name: "scripts_present", status: "pass",
|
|
2016
|
-
message: "All #{REQUIRED_SCRIPTS.size} required scripts present"
|
|
2017
|
-
)
|
|
2018
|
-
else
|
|
2019
|
-
checks << check(
|
|
2020
|
-
category: "core_files", name: "scripts_present", status: "fail",
|
|
2021
|
-
message: "#{missing_scripts.size} required script(s) missing from #{tilde(scripts_dir)}",
|
|
2022
|
-
details: missing_scripts,
|
|
2023
|
-
fixable: true, fix_hint: "Re-run the Plastic installer to restore scripts"
|
|
2024
|
-
)
|
|
2025
|
-
end
|
|
2026
|
-
|
|
2027
|
-
# scripts_executable
|
|
2028
|
-
if File.directory?(scripts_dir)
|
|
2029
|
-
script_files = Dir.children(scripts_dir)
|
|
2030
|
-
.map { |f| File.join(scripts_dir, f) }
|
|
2031
|
-
.select { |f| File.file?(f) }
|
|
2032
|
-
|
|
2033
|
-
non_executable = script_files.reject { |f| File.executable?(f) }
|
|
2034
|
-
|
|
2035
|
-
if non_executable.empty?
|
|
2036
|
-
checks << check(
|
|
2037
|
-
category: "core_files", name: "scripts_executable", status: "pass",
|
|
2038
|
-
message: "All scripts in #{tilde(scripts_dir)} are executable"
|
|
2039
|
-
)
|
|
2040
|
-
else
|
|
2041
|
-
checks << check(
|
|
2042
|
-
category: "core_files", name: "scripts_executable", status: "fail",
|
|
2043
|
-
message: "#{non_executable.size} script(s) not executable",
|
|
2044
|
-
details: non_executable.map { |f| tilde(f) },
|
|
2045
|
-
fixable: true, fix_hint: "chmod +x on the listed files"
|
|
2046
|
-
)
|
|
2047
|
-
end
|
|
2048
|
-
end
|
|
2049
|
-
|
|
2050
|
-
# version_match — compare global VERSION with agent-side VERSION
|
|
2051
|
-
global_version = read_version
|
|
2052
|
-
agent_config = agents[agent_key]
|
|
2053
|
-
|
|
2054
|
-
if global_version && agent_config
|
|
2055
|
-
agent_version_path = case agent_key
|
|
2056
|
-
when "claude" then File.join(agent_config[:dir], "plastic", "VERSION")
|
|
2057
|
-
else File.join(agent_config[:dir], "plastic", "VERSION")
|
|
2058
|
-
end
|
|
2059
|
-
|
|
2060
|
-
if File.exist?(agent_version_path)
|
|
2061
|
-
agent_version = File.read(agent_version_path).strip
|
|
2062
|
-
|
|
2063
|
-
if global_version == agent_version
|
|
2064
|
-
checks << check(
|
|
2065
|
-
category: "core_files", name: "version_match", status: "pass",
|
|
2066
|
-
message: "Global VERSION (#{global_version}) matches agent-side VERSION"
|
|
2067
|
-
)
|
|
2068
|
-
else
|
|
2069
|
-
checks << check(
|
|
2070
|
-
category: "core_files", name: "version_match", status: "warn",
|
|
2071
|
-
message: "Version mismatch: global=#{global_version}, agent=#{agent_version}",
|
|
2072
|
-
details: [
|
|
2073
|
-
"#{tilde(File.join(plastic_home, "VERSION"))}: #{global_version}",
|
|
2074
|
-
"#{tilde(agent_version_path)}: #{agent_version}",
|
|
2075
|
-
],
|
|
2076
|
-
fixable: true,
|
|
2077
|
-
fix_hint: "Re-sync the stale harness: npx @zalom/plastic@latest install --reinstall <flag>, or plastic-rollback to a prior version"
|
|
2078
|
-
)
|
|
2079
|
-
end
|
|
2080
|
-
else
|
|
2081
|
-
checks << check(
|
|
2082
|
-
category: "core_files", name: "version_match", status: "warn",
|
|
2083
|
-
message: "Agent-side VERSION file not found at #{tilde(agent_version_path)}",
|
|
2084
|
-
fixable: true,
|
|
2085
|
-
fix_hint: "Re-sync the stale harness: npx @zalom/plastic@latest install --reinstall <flag>, or plastic-rollback to a prior version"
|
|
2086
|
-
)
|
|
2087
|
-
end
|
|
2088
|
-
end
|
|
2089
|
-
|
|
2090
|
-
checks += check_agent_model_drift(agent_key) if include_drift
|
|
2091
|
-
|
|
2092
|
-
checks
|
|
2093
|
-
end
|
|
2094
1210
|
|
|
2095
1211
|
# agent_model_drift - advisory (never fail) config-vs-frontmatter comparison
|
|
2096
1212
|
# for every installed <agent_dir>/agents/plastic-*.md file (intent 170, D1;
|
|
@@ -2205,18 +1321,39 @@ end
|
|
|
2205
1321
|
end
|
|
2206
1322
|
end
|
|
2207
1323
|
|
|
2208
|
-
# Codex leg of agent_model_drift (intent 198, Decision D4
|
|
2209
|
-
#
|
|
2210
|
-
#
|
|
2211
|
-
#
|
|
2212
|
-
#
|
|
2213
|
-
#
|
|
2214
|
-
#
|
|
1324
|
+
# Codex leg of agent_model_drift (intent 198, Decision D4; widened intent
|
|
1325
|
+
# 216 to cover both TOML lines). The shared .md path above is structurally
|
|
1326
|
+
# blind on Codex (codex agents are TOML under ~/.codex/agents/, never
|
|
1327
|
+
# ~/.agents/agents/*.md), so it always passed on an empty glob without
|
|
1328
|
+
# opening a single Codex file. This mirrors the same four buckets
|
|
1329
|
+
# (sanctioned override, matches default, drifted, unclassified) against
|
|
1330
|
+
# ~/.codex/agents/plastic-*.toml instead, reading `model` and
|
|
1331
|
+
# `model_reasoning_effort` as two SEPARATE lines via
|
|
1332
|
+
# codex_agent_toml_model_fields, the same plain string matching
|
|
2215
1333
|
# codex_agent_toml_well_formed? already uses (no TOML parser dependency).
|
|
2216
|
-
#
|
|
2217
|
-
#
|
|
1334
|
+
#
|
|
1335
|
+
# Per the intent-186 emission contract (installer_core.rb's
|
|
1336
|
+
# codex_model_fields): a tier alias writes BOTH lines, a literal Codex
|
|
1337
|
+
# model id override writes `model` only. Bucket 2 (no override, basename
|
|
1338
|
+
# is a TIER_DEFAULTS key) therefore compares BOTH fields, each against its
|
|
1339
|
+
# own config-resolved default: the model value against
|
|
1340
|
+
# AgentModels.codex_model_for(tier), the effort value against
|
|
1341
|
+
# AgentModels.effort_for(tier), honoring the Codex-scoped
|
|
2218
1342
|
# agents.models.codex.<name> override precedence install_codex's own
|
|
2219
|
-
# agent_model_overrides(harness: "codex") already applies.
|
|
1343
|
+
# agent_model_overrides(harness: "codex") already applies. Either field
|
|
1344
|
+
# disagreeing is drift; the detail line names exactly which field(s)
|
|
1345
|
+
# drifted so a model mismatch never reads as an effort mismatch or vice
|
|
1346
|
+
# versa. An alias with no mapped Codex model id (codex_model_for returns
|
|
1347
|
+
# nil) skips the model comparison rather than reporting drift, mirroring
|
|
1348
|
+
# the installer, which still emits the effort line alone in that case.
|
|
1349
|
+
#
|
|
1350
|
+
# Bucket 1 (an agents.models.codex.<name> override is configured) stays
|
|
1351
|
+
# sanctioned and is NEVER compared: per-agent model and effort are user
|
|
1352
|
+
# configuration, per harness and per project, and can be edited after
|
|
1353
|
+
# install without a reinstall, so an override must always read as
|
|
1354
|
+
# healthy. This also keeps the Codex leg identical in shape to the Claude
|
|
1355
|
+
# leg's four-bucket contract from intent 191.
|
|
1356
|
+
#
|
|
2220
1357
|
# AgentModels::CONSULTATION_AGENTS need no special-case bucket here:
|
|
2221
1358
|
# generate_codex_agents already skips writing them for Codex entirely, so
|
|
2222
1359
|
# the glob below never finds them and there is nothing to classify.
|
|
@@ -2240,21 +1377,30 @@ end
|
|
|
2240
1377
|
|
|
2241
1378
|
installed.each do |path|
|
|
2242
1379
|
basename = File.basename(path, ".toml")
|
|
2243
|
-
|
|
1380
|
+
fields = codex_agent_toml_model_fields(File.read(path))
|
|
2244
1381
|
override = overrides[basename]
|
|
2245
1382
|
|
|
2246
1383
|
if override
|
|
2247
|
-
sanctioned << "#{basename}: toml=#{
|
|
1384
|
+
sanctioned << "#{basename}: toml model=#{fields[:model].inspect}, " \
|
|
1385
|
+
"toml effort=#{fields[:effort].inspect}, sanctioned override=#{override.inspect}"
|
|
2248
1386
|
elsif AgentModels::TIER_DEFAULTS.key?(basename)
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
1387
|
+
tier = AgentModels::TIER_DEFAULTS[basename]
|
|
1388
|
+
expected_model = AgentModels.codex_model_for(tier)
|
|
1389
|
+
expected_effort = AgentModels.effort_for(tier)
|
|
1390
|
+
mismatches = []
|
|
1391
|
+
if expected_model && fields[:model] != expected_model
|
|
1392
|
+
mismatches << "model=#{fields[:model].inspect}, resolved default model=#{expected_model.inspect}"
|
|
1393
|
+
end
|
|
1394
|
+
if expected_effort && fields[:effort] != expected_effort
|
|
1395
|
+
mismatches << "effort=#{fields[:effort].inspect}, resolved default effort=#{expected_effort.inspect}"
|
|
2252
1396
|
end
|
|
1397
|
+
drifted << "#{basename}: #{mismatches.join("; ")}" if mismatches.any?
|
|
2253
1398
|
else
|
|
2254
|
-
unclassified << "#{basename}: toml=#{
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
1399
|
+
unclassified << "#{basename}: toml model=#{fields[:model].inspect}, " \
|
|
1400
|
+
"toml effort=#{fields[:effort].inspect}, no resolved default (basename is in " \
|
|
1401
|
+
"neither AgentModels::TIER_DEFAULTS nor AgentModels::CONSULTATION_AGENTS in " \
|
|
1402
|
+
"scripts/lib/agent_models.rb; add it there, or set agents.models.codex.#{basename} " \
|
|
1403
|
+
"to sanction a model explicitly)"
|
|
2258
1404
|
end
|
|
2259
1405
|
end
|
|
2260
1406
|
|
|
@@ -2271,7 +1417,7 @@ end
|
|
|
2271
1417
|
)]
|
|
2272
1418
|
else
|
|
2273
1419
|
parts = []
|
|
2274
|
-
parts << "#{drifted.size} installed Codex agent TOML(s) have unsanctioned model drift vs the config-resolved default" if drifted.any?
|
|
1420
|
+
parts << "#{drifted.size} installed Codex agent TOML(s) have unsanctioned model or effort drift vs the config-resolved default" if drifted.any?
|
|
2275
1421
|
parts << "#{unclassified.size} installed Codex agent TOML(s) have no resolved default in scripts/lib/agent_models.rb" if unclassified.any?
|
|
2276
1422
|
[check(
|
|
2277
1423
|
category: "core_files", name: "agent_model_drift", status: "warn",
|
|
@@ -2291,69 +1437,6 @@ end
|
|
|
2291
1437
|
# Manifest format: { "version", "created", "files": { abs_path => sha256 } }.
|
|
2292
1438
|
# A missing manifest is a fail; any missing/mismatched listed file is a fail;
|
|
2293
1439
|
# otherwise a single pass per manifest.
|
|
2294
|
-
def check_manifest_sync(agent_key)
|
|
2295
|
-
checks = []
|
|
2296
|
-
|
|
2297
|
-
global_manifest = File.join(plastic_home, "manifest.json")
|
|
2298
|
-
checks << verify_manifest(global_manifest, "global")
|
|
2299
|
-
|
|
2300
|
-
agent_dir = agents[agent_key][:dir]
|
|
2301
|
-
agent_manifest = File.join(agent_dir, "plastic", "manifest.json")
|
|
2302
|
-
checks << verify_manifest(agent_manifest, "agent")
|
|
2303
|
-
|
|
2304
|
-
checks
|
|
2305
|
-
end
|
|
2306
|
-
|
|
2307
|
-
# Check one manifest file. Returns a single check (pass or fail).
|
|
2308
|
-
def verify_manifest(manifest_path, label)
|
|
2309
|
-
unless File.exist?(manifest_path)
|
|
2310
|
-
return check(
|
|
2311
|
-
category: "manifest_sync", name: "#{label}_manifest", status: "fail",
|
|
2312
|
-
message: "#{label} core manifest missing — re-run the Plastic installer",
|
|
2313
|
-
details: [tilde(manifest_path)],
|
|
2314
|
-
fixable: true, fix_hint: "Re-run the Plastic installer"
|
|
2315
|
-
)
|
|
2316
|
-
end
|
|
2317
|
-
|
|
2318
|
-
data = read_json_safe(manifest_path)
|
|
2319
|
-
files = data.is_a?(Hash) ? data["files"] : nil
|
|
2320
|
-
unless files.is_a?(Hash)
|
|
2321
|
-
return check(
|
|
2322
|
-
category: "manifest_sync", name: "#{label}_manifest", status: "fail",
|
|
2323
|
-
message: "#{label} core manifest unreadable or malformed — re-run the Plastic installer",
|
|
2324
|
-
details: [tilde(manifest_path)],
|
|
2325
|
-
fixable: true, fix_hint: "Re-run the Plastic installer"
|
|
2326
|
-
)
|
|
2327
|
-
end
|
|
2328
|
-
|
|
2329
|
-
missing = []
|
|
2330
|
-
mismatched = []
|
|
2331
|
-
files.each do |path, recorded|
|
|
2332
|
-
unless File.exist?(path)
|
|
2333
|
-
missing << tilde(path)
|
|
2334
|
-
next
|
|
2335
|
-
end
|
|
2336
|
-
actual = Digest::SHA256.file(path).hexdigest
|
|
2337
|
-
mismatched << tilde(path) if actual != recorded
|
|
2338
|
-
end
|
|
2339
|
-
|
|
2340
|
-
if missing.empty? && mismatched.empty?
|
|
2341
|
-
check(
|
|
2342
|
-
category: "manifest_sync", name: "#{label}_manifest", status: "pass",
|
|
2343
|
-
message: "#{label} manifest: all #{files.size} tracked file(s) present and matching"
|
|
2344
|
-
)
|
|
2345
|
-
else
|
|
2346
|
-
details = []
|
|
2347
|
-
details += missing.map { |p| "missing: #{p}" }
|
|
2348
|
-
details += mismatched.map { |p| "modified: #{p}" }
|
|
2349
|
-
check(
|
|
2350
|
-
category: "manifest_sync", name: "#{label}_manifest", status: "fail",
|
|
2351
|
-
message: "#{label} manifest out of sync: #{missing.size} missing, #{mismatched.size} modified",
|
|
2352
|
-
details: details,
|
|
2353
|
-
fixable: true, fix_hint: "Re-run the Plastic installer to restore tracked files"
|
|
2354
|
-
)
|
|
2355
|
-
end
|
|
2356
|
-
end
|
|
2357
1440
|
|
|
2358
1441
|
# install_integrity (intent 210, D4): FULL tier only, warn-only, never writes. For each
|
|
2359
1442
|
# installed agent, re-hash its manifest-listed files against disk and WARN on drift
|
|
@@ -2422,50 +1505,6 @@ end
|
|
|
2422
1505
|
# mirror (~/.plastic/projects/<slug>/{store,INDEX.md,project.yml}); this method validates the
|
|
2423
1506
|
# registered repo path itself (projects.yml's "path" key), decoupled from 175's deeper
|
|
2424
1507
|
# repo-root-versus-registered-path question (219 D9).
|
|
2425
|
-
def check_registered_project_paths
|
|
2426
|
-
checks = []
|
|
2427
|
-
|
|
2428
|
-
projects_yml_path = File.join(plastic_home, "projects.yml")
|
|
2429
|
-
projects_data = load_yaml_safe(projects_yml_path)
|
|
2430
|
-
|
|
2431
|
-
if projects_data.nil?
|
|
2432
|
-
checks << check(
|
|
2433
|
-
category: "project_stores", name: "registered_project_paths", status: "fail",
|
|
2434
|
-
message: "projects.yml not found or invalid at #{tilde(projects_yml_path)}",
|
|
2435
|
-
fixable: true, fix_hint: "Re-run the Plastic installer to restore projects.yml"
|
|
2436
|
-
)
|
|
2437
|
-
return checks
|
|
2438
|
-
end
|
|
2439
|
-
|
|
2440
|
-
projects = projects_data["projects"]
|
|
2441
|
-
unless projects.is_a?(Hash) && !projects.empty?
|
|
2442
|
-
checks << check(
|
|
2443
|
-
category: "project_stores", name: "registered_project_paths", status: "pass",
|
|
2444
|
-
message: "No projects registered"
|
|
2445
|
-
)
|
|
2446
|
-
return checks
|
|
2447
|
-
end
|
|
2448
|
-
|
|
2449
|
-
projects.each do |slug, project_info|
|
|
2450
|
-
path = project_info.is_a?(Hash) ? project_info["path"] : nil
|
|
2451
|
-
|
|
2452
|
-
if path && File.directory?(path)
|
|
2453
|
-
checks << check(
|
|
2454
|
-
category: "project_stores", name: "project_path_resolves", status: "pass",
|
|
2455
|
-
message: "Registered path for '#{slug}' resolves to a real directory"
|
|
2456
|
-
)
|
|
2457
|
-
else
|
|
2458
|
-
checks << check(
|
|
2459
|
-
category: "project_stores", name: "project_path_resolves", status: "fail",
|
|
2460
|
-
message: "Registered path for '#{slug}' does not resolve to a real directory",
|
|
2461
|
-
details: [path ? tilde(path) : "(no path set in projects.yml)"],
|
|
2462
|
-
fixable: false
|
|
2463
|
-
)
|
|
2464
|
-
end
|
|
2465
|
-
end
|
|
2466
|
-
|
|
2467
|
-
checks
|
|
2468
|
-
end
|
|
2469
1508
|
|
|
2470
1509
|
# Per-project validation extracted from check_project_stores so a single
|
|
2471
1510
|
# project can be checked in isolation (used by `--store <slug>`).
|
|
@@ -2858,6 +1897,61 @@ end
|
|
|
2858
1897
|
end
|
|
2859
1898
|
end
|
|
2860
1899
|
|
|
1900
|
+
# --- Check category: runtime (which ruby a spawned hook resolves) ---
|
|
1901
|
+
#
|
|
1902
|
+
# Intent 235, D6. REPORT ONLY: this check never pins an interpreter and never repairs.
|
|
1903
|
+
# A hook is launched by the agent application, not by a login shell, so a version
|
|
1904
|
+
# manager that activates on shell prompt render (mise, rbenv, asdf) may never reach it
|
|
1905
|
+
# and bare `ruby` can still resolve to the system interpreter long after the user
|
|
1906
|
+
# installs a modern Ruby.
|
|
1907
|
+
#
|
|
1908
|
+
# Below the floor is a WARN, never a fail. Doctor cannot know that the PATH it sees is
|
|
1909
|
+
# the PATH the agent application will hand its hooks, so it reports the risk with a
|
|
1910
|
+
# precise fix hint instead of blocking. "Could not determine" is the same warn: an
|
|
1911
|
+
# honest unknown, not a silent pass.
|
|
1912
|
+
#
|
|
1913
|
+
# The probe is injected as a keyword with a real default (see check_serena for the same
|
|
1914
|
+
# shape), so tests never spawn a process and never touch ENV.
|
|
1915
|
+
def check_ruby_runtime(probe: RubyProbe.method(:resolve))
|
|
1916
|
+
resolved = probe.call
|
|
1917
|
+
version = resolved[:version]
|
|
1918
|
+
parsed = version && safe_version(version)
|
|
1919
|
+
|
|
1920
|
+
if parsed.nil?
|
|
1921
|
+
return [check(
|
|
1922
|
+
category: "runtime", name: "ruby_floor", status: "warn",
|
|
1923
|
+
message: "Could not determine which ruby a Plastic hook would resolve on PATH " \
|
|
1924
|
+
"(no runnable `ruby` answered), so Plastic cannot confirm hook processes " \
|
|
1925
|
+
"meet the Ruby #{Preflight::RUBY_FLOOR} floor",
|
|
1926
|
+
fixable: false,
|
|
1927
|
+
fix_hint: "Make sure `ruby -v` works, then pin one for the whole machine: " \
|
|
1928
|
+
"mise use --global ruby@#{Preflight::RUBY_PIN}"
|
|
1929
|
+
)]
|
|
1930
|
+
end
|
|
1931
|
+
|
|
1932
|
+
where = resolved[:path].to_s.empty? ? "ruby on PATH" : resolved[:path]
|
|
1933
|
+
|
|
1934
|
+
if parsed < safe_version(Preflight::RUBY_FLOOR)
|
|
1935
|
+
return [check(
|
|
1936
|
+
category: "runtime", name: "ruby_floor", status: "warn",
|
|
1937
|
+
message: "Hooks would resolve Ruby #{version} at #{where}, below Plastic's floor of " \
|
|
1938
|
+
"#{Preflight::RUBY_FLOOR}. Hook scripts can fail on this interpreter even when " \
|
|
1939
|
+
"your own shell has a newer Ruby, because a shell-activated version manager " \
|
|
1940
|
+
"does not reach a hook process spawned by the agent application",
|
|
1941
|
+
details: [where],
|
|
1942
|
+
fixable: false,
|
|
1943
|
+
fix_hint: "Pin a Ruby the whole machine sees: mise use --global ruby@#{Preflight::RUBY_PIN}"
|
|
1944
|
+
)]
|
|
1945
|
+
end
|
|
1946
|
+
|
|
1947
|
+
[check(
|
|
1948
|
+
category: "runtime", name: "ruby_floor", status: "pass",
|
|
1949
|
+
message: "Hooks would resolve Ruby #{version} at #{where}, at or above Plastic's floor " \
|
|
1950
|
+
"of #{Preflight::RUBY_FLOOR}",
|
|
1951
|
+
details: [where]
|
|
1952
|
+
)]
|
|
1953
|
+
end
|
|
1954
|
+
|
|
2861
1955
|
# --- Check category: skill-lint (advisory only; intent 85b) ---
|
|
2862
1956
|
#
|
|
2863
1957
|
# Reports SkillLint's five structural checks over the skills/ tree as a
|
|
@@ -2911,6 +2005,7 @@ end
|
|
|
2911
2005
|
all_checks += check_deprecations
|
|
2912
2006
|
all_checks += check_config_asks(agent_key)
|
|
2913
2007
|
all_checks += check_qmd
|
|
2008
|
+
all_checks += check_ruby_runtime
|
|
2914
2009
|
all_checks += check_done_signals(scopes: ["global"])
|
|
2915
2010
|
all_checks += check_skill_lint
|
|
2916
2011
|
all_checks += check_install_integrity
|
|
@@ -2918,22 +2013,6 @@ end
|
|
|
2918
2013
|
summarize(all_checks, agent_key)
|
|
2919
2014
|
end
|
|
2920
2015
|
|
|
2921
|
-
# Binary core sync check: agent registration + core files (drift excluded) + manifest
|
|
2922
|
-
# sync + registered project paths + global store availability, rolled up with binary:
|
|
2923
|
-
# true so ANY warn or fail makes the overall status "fail" (and "warn" is never
|
|
2924
|
-
# emitted). Used by `doctor.rb --core` (219 D1/D2: operational readiness only, no
|
|
2925
|
-
# agent_model_drift, no store-content scanning).
|
|
2926
|
-
def run_core_checks(agent_key)
|
|
2927
|
-
all_checks = []
|
|
2928
|
-
all_checks += check_agent_registration(agent_key)
|
|
2929
|
-
all_checks += check_core_files(agent_key, include_drift: false)
|
|
2930
|
-
all_checks += check_manifest_sync(agent_key)
|
|
2931
|
-
all_checks += check_registered_project_paths
|
|
2932
|
-
all_checks += check_global_store_available
|
|
2933
|
-
|
|
2934
|
-
summarize(all_checks, agent_key, binary: true)
|
|
2935
|
-
end
|
|
2936
|
-
|
|
2937
2016
|
# Per-intent structure gate for `doctor.rb --intent <id> [--store <key>] [--disposition ...]`
|
|
2938
2017
|
# (intent 222). `store`, here, is already the resolved scope key (see
|
|
2939
2018
|
# normalize_intent_store_scope, the CLI-boundary translator that turns the loosely-typed
|
|
@@ -3027,33 +2106,6 @@ end
|
|
|
3027
2106
|
check_enola(cwd: probe_cwd, path_probe: enola_path_probe, marker_finder: enola_marker_finder)
|
|
3028
2107
|
end
|
|
3029
2108
|
|
|
3030
|
-
# Roll a list of checks up into the standard result envelope.
|
|
3031
|
-
# When binary: true, the overall status is "pass" only if there are zero warn
|
|
3032
|
-
# AND zero fail; any warn or fail yields "fail" (never "warn"). When false
|
|
3033
|
-
# (the default) the classic 3-state pass/warn/fail roll-up is used.
|
|
3034
|
-
def summarize(all_checks, agent_key, binary: false)
|
|
3035
|
-
summary = { pass: 0, warn: 0, fail: 0, total: all_checks.size }
|
|
3036
|
-
all_checks.each { |c| summary[c[:status].to_sym] += 1 }
|
|
3037
|
-
|
|
3038
|
-
overall = if binary
|
|
3039
|
-
(summary[:fail] > 0 || summary[:warn] > 0) ? "fail" : "pass"
|
|
3040
|
-
elsif summary[:fail] > 0
|
|
3041
|
-
"fail"
|
|
3042
|
-
elsif summary[:warn] > 0
|
|
3043
|
-
"warn"
|
|
3044
|
-
else
|
|
3045
|
-
"pass"
|
|
3046
|
-
end
|
|
3047
|
-
|
|
3048
|
-
{
|
|
3049
|
-
version: read_version || "unknown",
|
|
3050
|
-
timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
3051
|
-
status: overall,
|
|
3052
|
-
agent: agent_key,
|
|
3053
|
-
checks: all_checks,
|
|
3054
|
-
summary: summary,
|
|
3055
|
-
}
|
|
3056
|
-
end
|
|
3057
2109
|
|
|
3058
2110
|
# --- Main ---
|
|
3059
2111
|
|