@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.
Files changed (92) hide show
  1. package/PLASTIC.md +23 -8
  2. package/README.md +15 -4
  3. package/agents/plastic-enforcer.md +3 -2
  4. package/agents/plastic-intent-discovery.md +7 -0
  5. package/bin/plastic.js +17 -8
  6. package/hooks/auto-arm +2 -2
  7. package/hooks/bash-gate +1 -1
  8. package/hooks/check-update +1 -1
  9. package/hooks/continue +2 -2
  10. package/hooks/edit-gates +1 -1
  11. package/hooks/future-intent-check +2 -2
  12. package/hooks/gate-check +3 -3
  13. package/hooks/power-tools +1 -1
  14. package/hooks/session-start +1 -1
  15. package/package.json +1 -1
  16. package/scripts/codex-hook +50 -106
  17. package/scripts/doctor.rb +132 -1080
  18. package/scripts/exec-worktree +103 -0
  19. package/scripts/hash-intent +1 -1
  20. package/scripts/hook-bash-gate +19 -0
  21. package/scripts/hook-code-gate +4 -1
  22. package/scripts/hook-continue +2 -2
  23. package/scripts/hook-create-gate +6 -3
  24. package/scripts/hook-gate-check +17 -0
  25. package/scripts/hook-links-gate +4 -1
  26. package/scripts/hook-lock-gate +7 -3
  27. package/scripts/hook-savepoint-pre +4 -1
  28. package/scripts/hook-session-start +21 -15
  29. package/scripts/lib/apply_patch_envelope.rb +46 -13
  30. package/scripts/lib/bridge.rb +83 -15
  31. package/scripts/lib/codex_edit_gates.rb +138 -0
  32. package/scripts/lib/doctor_core.rb +1087 -0
  33. package/scripts/lib/edit_gates.rb +61 -5
  34. package/scripts/lib/exec_worktree.rb +325 -0
  35. package/scripts/lib/harness_text.rb +57 -0
  36. package/scripts/lib/hook_registry.rb +32 -28
  37. package/scripts/lib/installer_core.rb +67 -7
  38. package/scripts/lib/lock.rb +196 -47
  39. package/scripts/lib/ruby_probe.rb +60 -0
  40. package/scripts/lib/scaffold_intent.rb +392 -0
  41. package/scripts/lib/spec_header.rb +83 -0
  42. package/scripts/lib/start_intent.rb +296 -0
  43. package/scripts/lib/verify_intent.rb +262 -0
  44. package/scripts/lib/worktree.rb +15 -1
  45. package/scripts/link-suggest +1 -1
  46. package/scripts/maintenance-run +5 -5
  47. package/scripts/migrate-to-global +2 -2
  48. package/scripts/restore-intent-v1 +1 -1
  49. package/scripts/scaffold-intent +120 -0
  50. package/scripts/start-intent +89 -0
  51. package/scripts/verify-intent +73 -0
  52. package/skills/agent-advisor/SKILL.md +5 -5
  53. package/skills/auto/SKILL.md +42 -32
  54. package/skills/auto/references/agent-architecture.md +1 -1
  55. package/skills/auto/references/agent-report-contract.md +1 -1
  56. package/skills/auto/references/human-report-contract.md +22 -3
  57. package/skills/auto/references/tiers.md +24 -2
  58. package/skills/conventions/references/completion-and-done.md +3 -0
  59. package/skills/conventions/references/gates-and-enforcement.md +28 -12
  60. package/skills/conventions/references/locks-and-worktrees.md +3 -3
  61. package/skills/conventions/references/tiers-and-dispatch.md +7 -6
  62. package/skills/dashboard/SKILL.md +1 -1
  63. package/skills/doctor/SKILL.md +6 -5
  64. package/skills/doctor/references/gates-stuck-detection.md +13 -8
  65. package/skills/doctor/report.md +1 -1
  66. package/skills/install/SKILL.md +1 -1
  67. package/skills/intent-brainstorming/SKILL.md +0 -2
  68. package/skills/intent-creating/SKILL.md +6 -6
  69. package/skills/intent-creating/references/lifecycle.md +1 -1
  70. package/skills/intent-discovering/SKILL.md +10 -3
  71. package/skills/intent-ending/SKILL.md +8 -7
  72. package/skills/intent-executing/SKILL.md +27 -19
  73. package/skills/intent-grilling/SKILL.md +5 -3
  74. package/skills/intent-planning/SKILL.md +7 -3
  75. package/skills/intent-researching/SKILL.md +0 -2
  76. package/skills/intent-starting/SKILL.md +10 -2
  77. package/skills/project-creating/SKILL.md +0 -2
  78. package/skills/project-creating/references/project-scaffolding.md +1 -1
  79. package/skills/releasing/SKILL.md +1 -1
  80. package/skills/releasing/references/promotion-and-tagging.md +14 -8
  81. package/skills/releasing/references/release-lines.md +1 -1
  82. package/skills/skill-creating/SKILL.md +5 -2
  83. package/skills/store-indexing/SKILL.md +8 -5
  84. package/skills/store-indexing/references/zettelkasten-linking.md +1 -1
  85. package/skills/tutorial/references/track-1-guided.md +2 -2
  86. package/skills/tutorial/references/track-2-auto.md +9 -6
  87. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +1 -1
  88. package/skills/uninstall/SKILL.md +6 -9
  89. package/templates/agents.md +12 -12
  90. package/templates/config.yml +6 -7
  91. package/templates/index.md +6 -3
  92. package/templates/spec.md +1 -1
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
- require "digest"
17
- require "rubygems"
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 single model-selection line a generated
1731
- # Codex agent TOML carries (model_reasoning_effort for a tier alias, model
1732
- # for a literal override id; codex_model_fields never emits both). No TOML
1733
- # parser dependency, mirroring codex_agent_toml_well_formed? above.
1734
- def codex_agent_toml_model_value(content)
1735
- m = content.match(/^model_reasoning_effort\s*=\s*"([^"]*)"/) || content.match(/^model\s*=\s*"([^"]*)"/)
1736
- m && m[1]
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): the shared .md
2209
- # path above is structurally blind on Codex (codex agents are TOML under
2210
- # ~/.codex/agents/, never ~/.agents/agents/*.md), so it always passed on an
2211
- # empty glob without opening a single Codex file. This mirrors the same
2212
- # four buckets (sanctioned override, matches default, drifted,
2213
- # unclassified) against ~/.codex/agents/plastic-*.toml instead, reading
2214
- # model / model_reasoning_effort with the same plain string matching
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
- # The expected value resolves through AgentModels::TIER_DEFAULTS mapped
2217
- # through AgentModels.effort_for, honoring the Codex-scoped
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
- installed_value = codex_agent_toml_model_value(File.read(path))
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=#{installed_value.inspect}, sanctioned override=#{override.inspect}"
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
- expected_effort = AgentModels.effort_for(AgentModels::TIER_DEFAULTS[basename])
2250
- if installed_value != expected_effort
2251
- drifted << "#{basename}: toml=#{installed_value.inspect}, resolved default effort=#{expected_effort.inspect}"
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=#{installed_value.inspect}, no resolved default (basename is in " \
2255
- "neither AgentModels::TIER_DEFAULTS nor AgentModels::CONSULTATION_AGENTS in " \
2256
- "scripts/lib/agent_models.rb; add it there, or set agents.models.codex.#{basename} " \
2257
- "to sanction a model explicitly)"
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