@zalom/plastic 1.8.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 (127) hide show
  1. package/PLASTIC.md +94 -556
  2. package/README.md +18 -6
  3. package/agents/plastic-enforcer.md +3 -2
  4. package/agents/plastic-intent-curator.md +2 -2
  5. package/agents/plastic-intent-discovery.md +7 -0
  6. package/bin/plastic.js +17 -8
  7. package/deprecations.yml +10 -2
  8. package/hooks/auto-arm +2 -2
  9. package/hooks/bash-gate +1 -1
  10. package/hooks/check-update +1 -1
  11. package/hooks/continue +2 -2
  12. package/hooks/edit-gates +3 -0
  13. package/hooks/future-intent-check +2 -2
  14. package/hooks/gate-check +3 -3
  15. package/hooks/hooks.json +4 -44
  16. package/hooks/power-tools +8 -0
  17. package/hooks/session-start +1 -1
  18. package/package.json +1 -2
  19. package/scripts/codex-hook +57 -118
  20. package/scripts/doctor.rb +146 -1012
  21. package/scripts/exec-worktree +103 -0
  22. package/scripts/hash-intent +1 -1
  23. package/scripts/hook-bash-gate +19 -0
  24. package/scripts/hook-code-gate +16 -28
  25. package/scripts/hook-continue +2 -2
  26. package/scripts/hook-create-gate +13 -57
  27. package/scripts/hook-edit-gates +58 -0
  28. package/scripts/hook-gate-check +17 -0
  29. package/scripts/hook-links-gate +11 -31
  30. package/scripts/hook-lock-gate +19 -60
  31. package/scripts/hook-power-tools +38 -0
  32. package/scripts/hook-savepoint-pre +11 -11
  33. package/scripts/hook-session-start +21 -15
  34. package/scripts/lib/apply_patch_envelope.rb +46 -13
  35. package/scripts/lib/bridge.rb +85 -17
  36. package/scripts/lib/codex_edit_gates.rb +138 -0
  37. package/scripts/lib/doctor_core.rb +1087 -0
  38. package/scripts/lib/edit_gates.rb +398 -0
  39. package/scripts/lib/exec_worktree.rb +325 -0
  40. package/scripts/lib/harness_text.rb +57 -0
  41. package/scripts/lib/hook_registry.rb +64 -28
  42. package/scripts/lib/installer_core.rb +166 -24
  43. package/scripts/lib/lock.rb +196 -47
  44. package/scripts/lib/qmd_hook.rb +21 -41
  45. package/scripts/lib/qmd_sync.rb +0 -15
  46. package/scripts/lib/revisions_writer.rb +1 -1
  47. package/scripts/lib/ruby_probe.rb +60 -0
  48. package/scripts/lib/scaffold_intent.rb +392 -0
  49. package/scripts/lib/spec_header.rb +83 -0
  50. package/scripts/lib/start_intent.rb +296 -0
  51. package/scripts/lib/verify_intent.rb +262 -0
  52. package/scripts/lib/worktree.rb +15 -1
  53. package/scripts/link-suggest +1 -1
  54. package/scripts/maintenance-run +5 -5
  55. package/scripts/migrate-to-global +2 -2
  56. package/scripts/restore-intent-v1 +17 -12
  57. package/scripts/scaffold-intent +120 -0
  58. package/scripts/start-intent +89 -0
  59. package/scripts/verify-intent +73 -0
  60. package/skills/agent-advisor/SKILL.md +5 -5
  61. package/skills/auto/SKILL.md +52 -32
  62. package/skills/auto/evals/evals.json +2 -2
  63. package/skills/auto/references/agent-architecture.md +1 -1
  64. package/skills/auto/references/agent-report-contract.md +1 -1
  65. package/skills/auto/references/human-report-contract.md +22 -3
  66. package/skills/auto/references/tiers.md +24 -2
  67. package/skills/conventions/SKILL.md +31 -0
  68. package/skills/conventions/references/completion-and-done.md +43 -0
  69. package/skills/conventions/references/gates-and-enforcement.md +39 -0
  70. package/skills/conventions/references/knowledge-graph.md +47 -0
  71. package/skills/conventions/references/lifecycle-and-savepoints.md +11 -0
  72. package/skills/conventions/references/locks-and-worktrees.md +113 -0
  73. package/skills/conventions/references/maintenance-and-revisions.md +170 -0
  74. package/skills/conventions/references/roadmaps.md +44 -0
  75. package/skills/conventions/references/tiers-and-dispatch.md +135 -0
  76. package/skills/dashboard/SKILL.md +1 -1
  77. package/skills/doctor/SKILL.md +15 -6
  78. package/skills/doctor/references/gates-stuck-detection.md +13 -8
  79. package/skills/doctor/report.md +1 -1
  80. package/skills/install/SKILL.md +1 -1
  81. package/skills/intent-brainstorming/SKILL.md +4 -2
  82. package/skills/intent-continuing/SKILL.md +4 -0
  83. package/skills/intent-creating/SKILL.md +13 -8
  84. package/skills/intent-creating/references/lifecycle.md +1 -1
  85. package/skills/intent-discovering/SKILL.md +10 -3
  86. package/skills/intent-ending/SKILL.md +12 -7
  87. package/skills/intent-executing/SKILL.md +37 -19
  88. package/skills/intent-grilling/SKILL.md +5 -3
  89. package/skills/intent-linking/SKILL.md +4 -0
  90. package/skills/intent-locking/SKILL.md +4 -0
  91. package/skills/intent-planning/SKILL.md +14 -3
  92. package/skills/intent-researching/SKILL.md +0 -2
  93. package/skills/intent-savepoint/SKILL.md +4 -0
  94. package/skills/intent-speccing/SKILL.md +4 -0
  95. package/skills/intent-starting/SKILL.md +20 -2
  96. package/skills/project-creating/SKILL.md +0 -2
  97. package/skills/project-creating/references/project-scaffolding.md +3 -3
  98. package/skills/releasing/SKILL.md +1 -1
  99. package/skills/releasing/references/promotion-and-tagging.md +14 -8
  100. package/skills/releasing/references/release-lines.md +1 -1
  101. package/skills/roadmap/SKILL.md +4 -0
  102. package/skills/roadmap-continuing/SKILL.md +4 -0
  103. package/skills/skill-creating/SKILL.md +8 -2
  104. package/skills/skill-creating/references/defaults-first.md +23 -0
  105. package/skills/skill-creating/references/hooks.md +4 -1
  106. package/skills/store-curating/SKILL.md +8 -0
  107. package/skills/store-indexing/SKILL.md +16 -5
  108. package/skills/store-indexing/references/zettelkasten-linking.md +1 -1
  109. package/skills/tutorial/references/track-1-guided.md +2 -2
  110. package/skills/tutorial/references/track-2-auto.md +10 -8
  111. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +1 -1
  112. package/skills/uninstall/SKILL.md +6 -9
  113. package/templates/agents.md +12 -12
  114. package/templates/config.yml +6 -7
  115. package/templates/index.md +6 -3
  116. package/templates/spec.md +1 -1
  117. package/PLASTIC-reference.md +0 -138
  118. package/hooks/code-gate +0 -27
  119. package/hooks/create-gate +0 -3
  120. package/hooks/links-gate +0 -3
  121. package/hooks/lock-gate +0 -21
  122. package/hooks/qmd-search +0 -8
  123. package/hooks/retrieval-gate +0 -10
  124. package/hooks/savepoint-pre +0 -10
  125. package/scripts/hook-qmd-search +0 -44
  126. package/scripts/hook-retrieval-gate +0 -148
  127. package/scripts/lib/retrieval_gate.rb +0 -211
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
 
@@ -560,7 +432,7 @@ class Doctor
560
432
  fixable: true,
561
433
  fix_hint: "Dispatch plastic-store-curating to relocate each unsanctioned section into the " \
562
434
  "intent's revisions.md via move-and-record (a missing required section is restored " \
563
- "or reprojected instead); see PLASTIC.md > Structural maintenance and revisions.md"
435
+ "or reprojected instead); see plastic-conventions > references/maintenance-and-revisions.md"
564
436
  )
565
437
  end
566
438
 
@@ -929,7 +801,7 @@ end
929
801
  "project-links --intent <id> --apply` over running project-links directly: it detects " \
930
802
  "(never acquires) the target's delivery lock, requires a clean store working tree, and " \
931
803
  "commits the scoped change plus its revisions.md receipt as one merged operation " \
932
- "(PLASTIC.md > WORK vs MAINTENANCE)."
804
+ "(plastic-conventions > references/maintenance-and-revisions.md)."
933
805
  )
934
806
  end
935
807
 
@@ -1229,7 +1101,7 @@ end
1229
1101
  "Every sources/chain id resolves to a real intent (I4)",
1230
1102
  "Dispatch plastic-store-curating to record the dangling sources/chain edge as a " \
1231
1103
  "broken-source/broken-chain move-and-record entry in the intent's revisions.md (see " \
1232
- "PLASTIC.md > Structural maintenance and revisions.md), or restore the missing intent"
1104
+ "plastic-conventions > references/maintenance-and-revisions.md), or restore the missing intent"
1233
1105
  )
1234
1106
  checks
1235
1107
  end
@@ -1252,210 +1124,22 @@ 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
1127
 
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
1128
 
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
- # hooks_registered — settings.json has Plastic hooks for required events
1351
- settings_path = File.join(agent_dir, "settings.json")
1352
- settings = read_json_safe(settings_path)
1353
-
1354
- if settings.nil?
1355
- checks << check(
1356
- category: "agent_registration", name: "hooks_registered", status: "fail",
1357
- message: "Cannot read #{tilde(settings_path)} — file missing or invalid",
1358
- fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
1359
- )
1360
- else
1361
- hooks = settings["hooks"] || {}
1362
- missing_events = CLAUDE_HOOK_EVENTS.reject do |event|
1363
- groups = hooks[event]
1364
- next false unless groups.is_a?(Array)
1365
-
1366
- groups.any? do |group|
1367
- group.is_a?(Hash) && group["hooks"].is_a?(Array) &&
1368
- group["hooks"].any? { |h| h["command"].to_s.include?("plastic-") }
1369
- end
1370
- end
1371
-
1372
- if missing_events.empty?
1373
- checks << check(
1374
- category: "agent_registration", name: "hooks_registered", status: "pass",
1375
- message: "All #{CLAUDE_HOOK_EVENTS.size} hook events registered in settings.json"
1376
- )
1377
- else
1378
- checks << check(
1379
- category: "agent_registration", name: "hooks_registered", status: "fail",
1380
- message: "#{missing_events.size} hook event(s) not registered in settings.json",
1381
- details: missing_events,
1382
- fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
1383
- )
1384
- end
1385
-
1386
- # hooks_match_registry (intent 108, D7): the live settings must carry
1387
- # EXACTLY the registrations HookRegistry defines; any drift (a missing
1388
- # gate, a stray plastic hook, a stale matcher) is how bash-gate shipped
1389
- # dead once already.
1390
- expected = HookRegistry.claude_settings_hooks(hook_dir: hooks_dir)
1391
- diffs = []
1392
- expected.each do |event, group|
1393
- groups = group.is_a?(Array) ? group : [group]
1394
- live = settings.dig("hooks", event) || []
1395
- groups.each do |g|
1396
- matches = live.select { |h| h.is_a?(Hash) && h["matcher"] == g["matcher"] }
1397
- wanted = g["hooks"].map { |h| h["command"] }
1398
- got = matches.flat_map { |m| Array(m["hooks"]).map { |h| h["command"] } }
1399
- missing = wanted - got
1400
- diffs << "#{event}[#{g['matcher']}] missing: #{missing.join(', ')}" unless missing.empty?
1401
- end
1402
- end
1403
- live_plastic = (settings["hooks"] || {}).flat_map do |event, groups|
1404
- Array(groups).flat_map do |g|
1405
- next [] unless g.is_a?(Hash) && g["hooks"].is_a?(Array)
1406
- g["hooks"].map { |h| h["command"].to_s }.select { |c| c.include?("plastic-") }
1407
- .map { |c| "#{event}: #{c}" }
1408
- end
1409
- end
1410
- expected_cmds = expected.values.flat_map { |g| g.is_a?(Array) ? g : [g] }
1411
- .flat_map { |g| g["hooks"].map { |h| h["command"] } }
1412
- strays = live_plastic.reject { |lp| expected_cmds.any? { |c| lp.end_with?(c) } }
1413
- diffs.concat(strays.map { |s| "stray: #{s}" })
1414
-
1415
- checks << if diffs.empty?
1416
- check(category: "agent_registration", name: "hooks_match_registry",
1417
- status: "pass", message: "settings.json hooks match HookRegistry")
1418
- else
1419
- check(category: "agent_registration", name: "hooks_match_registry",
1420
- status: "fail", message: "#{diffs.size} hook registration(s) diverge from HookRegistry",
1421
- details: diffs, fixable: true,
1422
- fix_hint: "Re-run the installer merge: npx @zalom/plastic update (or ruby ~/.plastic/scripts/install.rb)")
1423
- end
1424
- end
1425
-
1426
- # skills_exist — flat, hyphen-namespaced personal skills (plastic-<name>/)
1427
- checks << flat_skills_check(agent_dir, "--claude")
1428
-
1429
- # stray_skills — installed plastic-* skill dir with no manifest entry (a leftover,
1430
- # e.g. an old-name copy after a rename; intent 158a AC15)
1431
- stray_check = stray_skills_check(agent_dir, "--claude", File.join(agent_dir, "plastic", "manifest.json"))
1432
- checks << stray_check if stray_check
1433
-
1434
- # agents_exist — auto-mode role files (plastic-*.md) synced into <dir>/agents
1435
- checks << flat_agents_check(agent_dir, "--claude")
1436
-
1437
- checks
1438
- end
1129
+ # claude_hooks_implemented (intent 244, the Claude twin of intent 200's Codex
1130
+ # check): the edit-path gates now live as branches inside one dispatcher,
1131
+ # which is exactly the shape that let links-gate ship registered and dead on
1132
+ # Codex. HookRegistry::GATE_TOOLS is the registry side; scripts/hook-edit-gates
1133
+ # is the implementation side. Checked in BOTH directions, so a registered gate
1134
+ # with no branch (always allows, silently) and a branch nobody registers
1135
+ # (dead code) are both reported.
1136
+ #
1137
+ # Read as plain text, never required: the dispatcher has top-level side effects
1138
+ # (it reads $stdin and exits), the same reason codex_dispatcher_gate_names reads
1139
+ # scripts/codex-hook as text.
1439
1140
 
1440
1141
  # Plastic skills install as ~/.claude/skills/plastic-<name>/SKILL.md. Pass if at
1441
1142
  # least one such skill is present.
1442
- def flat_skills_check(agent_dir, installer_flag)
1443
- skills_root = File.join(agent_dir, "skills")
1444
- found = Dir.glob(File.join(skills_root, "plastic-*", "SKILL.md"))
1445
-
1446
- if !found.empty?
1447
- check(
1448
- category: "agent_registration", name: "skills_exist", status: "pass",
1449
- message: "#{found.size} plastic-* skill(s) installed in #{tilde(skills_root)}"
1450
- )
1451
- else
1452
- check(
1453
- category: "agent_registration", name: "skills_exist", status: "fail",
1454
- message: "No plastic-* skills found in #{tilde(skills_root)}",
1455
- fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest #{installer_flag}"
1456
- )
1457
- end
1458
- end
1459
1143
 
1460
1144
  # Backstop for a rename or removal (intent 158a, AC15): an installed plastic-*
1461
1145
  # skill directory under agent_dir/skills that has NO corresponding entry in the
@@ -1464,258 +1148,39 @@ end
1464
1148
  # manifest predates it). Complements flat_skills_check (which only confirms at
1465
1149
  # least one skill exists). Defers to the manifest check when the manifest itself
1466
1150
  # is missing or malformed, so the two checks never double-report the same gap.
1467
- def stray_skills_check(agent_dir, installer_flag, manifest_path)
1468
- skills_root = File.join(agent_dir, "skills")
1469
- manifest = read_json_safe(manifest_path)
1470
- files = manifest.is_a?(Hash) ? manifest["files"] : nil
1471
- return nil unless files.is_a?(Hash)
1472
-
1473
- installed = Dir.glob(File.join(skills_root, "plastic-*", "SKILL.md"))
1474
- .map { |f| File.basename(File.dirname(f)) }
1475
- tracked = files.keys
1476
- .select { |p| p.start_with?("#{skills_root}/") }
1477
- .map { |p| p.sub("#{skills_root}/", "").split("/").first }
1478
- .uniq
1479
-
1480
- strays = (installed - tracked).sort
1481
-
1482
- if strays.empty?
1483
- check(
1484
- category: "agent_registration", name: "stray_skills", status: "pass",
1485
- message: "No stray plastic-* skill directories in #{tilde(skills_root)}"
1486
- )
1487
- else
1488
- check(
1489
- category: "agent_registration", name: "stray_skills", status: "warn",
1490
- message: "#{strays.size} installed skill dir(s) not in the current manifest (stray, e.g. a leftover old-name copy)",
1491
- details: strays,
1492
- fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest #{installer_flag}"
1493
- )
1494
- end
1495
- end
1496
1151
 
1497
1152
  # Auto-mode role agents install as <dir>/agents/plastic-*.md. Pass if at least
1498
1153
  # one such role file is present (the installer syncs agents/ on every install).
1499
- def flat_agents_check(agent_dir, installer_flag)
1500
- agents_root = File.join(agent_dir, "agents")
1501
- found = Dir.glob(File.join(agents_root, "plastic-*.md"))
1502
-
1503
- if !found.empty?
1504
- check(
1505
- category: "agent_registration", name: "agents_exist", status: "pass",
1506
- message: "#{found.size} plastic-* agent(s) installed in #{tilde(agents_root)}"
1507
- )
1508
- else
1509
- check(
1510
- category: "agent_registration", name: "agents_exist", status: "fail",
1511
- message: "No plastic-* agents found in #{tilde(agents_root)}",
1512
- fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest #{installer_flag}"
1513
- )
1514
- end
1515
- end
1516
1154
 
1517
1155
  # Shared skills-presence plus stray-skills check, used by both the generic
1518
1156
  # (hermes) agent-registration path and codex's TOML-based one. Neither the flat
1519
1157
  # `.md` agents check nor anything agent-format-specific lives here.
1520
- def check_flat_skills_and_stray(agent_key, agent_dir)
1521
- checks = []
1522
-
1523
- # For codex/hermes: just check skills exist (no settings.json hooks)
1524
- checks << flat_skills_check(agent_dir, "--#{agent_key}")
1525
-
1526
- # stray_skills — installed plastic-* skill dir with no manifest entry (a leftover,
1527
- # e.g. an old-name copy after a rename; intent 158a AC15)
1528
- stray_check = stray_skills_check(agent_dir, "--#{agent_key}", File.join(agent_dir, "plastic", "manifest.json"))
1529
- checks << stray_check if stray_check
1530
-
1531
- checks
1532
- end
1533
-
1534
- def check_generic_agent_registration(agent_key, agent_dir)
1535
- checks = check_flat_skills_and_stray(agent_key, agent_dir)
1536
- checks << flat_agents_check(agent_dir, "--#{agent_key}") # hermes: unchanged (.md copy)
1537
- checks
1538
- end
1539
1158
 
1540
- # Codex marker literals. Keep in sync with InstallerCore::CODEX_SECTION_BEGIN_PREFIX /
1541
- # CODEX_SECTION_END (doctor does not require installer_core, so the literals are duplicated).
1542
- CODEX_SECTION_BEGIN_PREFIX = "<!-- BEGIN PLASTIC INTEGRATION"
1543
- CODEX_SECTION_END = "<!-- END PLASTIC INTEGRATION -->"
1544
1159
 
1545
- def check_codex_registration(agent_key, agent_dir)
1546
- config = agents[agent_key]
1547
- checks = check_flat_skills_and_stray(agent_key, agent_dir)
1548
- checks << codex_agents_toml_check(config)
1549
-
1550
- agents_md = File.join(config[:home_dir], "AGENTS.md")
1551
-
1552
- if !File.exist?(agents_md)
1553
- checks << check(
1554
- category: "agent_registration", name: "codex_agents_md", status: "fail",
1555
- message: "Codex AGENTS.md not found at #{tilde(agents_md)}",
1556
- fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
1557
- )
1558
- else
1559
- content = File.read(agents_md)
1560
- b = content.index(CODEX_SECTION_BEGIN_PREFIX)
1561
- e = content.index(CODEX_SECTION_END)
1562
- well_formed = b && e && e > b && content[b...e].include?("-->")
1563
- if well_formed
1564
- checks << check(
1565
- category: "agent_registration", name: "codex_agents_md", status: "pass",
1566
- message: "Codex AGENTS.md carries the Plastic section"
1567
- )
1568
- else
1569
- checks << check(
1570
- category: "agent_registration", name: "codex_agents_md", status: "fail",
1571
- message: "Codex AGENTS.md is missing or has a malformed Plastic section",
1572
- fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
1573
- )
1574
- end
1575
- end
1576
-
1577
- hooks_check = codex_hooks_registered_check(config)
1578
- checks << hooks_check
1579
- checks << codex_hooks_implemented_check(config)
1580
- checks << codex_hook_trust_advisory_check if hooks_check[:status] == "pass"
1581
- codex_config_toml_advisory_check(config).tap { |c| checks << c if c }
1582
- codex_version_floor_check(config).tap { |c| checks << c if c }
1583
-
1584
- checks
1585
- end
1586
-
1587
- # Hooks being REGISTERED (hooks.json content matches HookRegistry) is not
1588
- # the same as hooks being TRUSTED: Codex gates every non-managed hook
1589
- # command behind a human /hooks review, keyed by the command's current
1590
- # hash, so a release that changes a hook command re-arms the review
1591
- # (intent 198, Decision D2). Whether Codex persists a queryable trust
1592
- # record anywhere under ~/.codex is undocumented and unverified, so this
1593
- # can never be a real pass or fail on trust state; it is an advisory that
1594
- # always names the /hooks step, and it fires only once hooks are actually
1595
- # registered (an unregistered hook is already reported by
1596
- # codex_hooks_registered_check, so reminding about trust on top of that
1597
- # would be noise, not signal).
1598
- def codex_hook_trust_advisory_check
1599
- check(
1600
- category: "agent_registration", name: "codex_hooks_trust", status: "warn",
1601
- message: "Open Codex, run /hooks, and trust the Plastic hook definitions. " \
1602
- "Codex re-arms this review whenever a hook's command changes.",
1603
- fixable: false
1604
- )
1605
- end
1606
-
1607
- # Codex-specific agent presence + structural sanity: ~/.codex/agents/plastic-*.toml
1608
- # exist and each carries the mandatory fields. No TOML parser (doctor depends on none):
1609
- # "structural" means the mandatory keys appear as lines and the multi-line
1610
- # developer_instructions string is balanced (an opening triple-quote has a later one).
1611
- def codex_agents_toml_check(config)
1612
- agents_root = File.join(config[:home_dir], "agents")
1613
- found = Dir.glob(File.join(agents_root, "plastic-*.toml"))
1614
-
1615
- if found.empty?
1616
- return check(
1617
- category: "agent_registration", name: "codex_agents_toml", status: "fail",
1618
- message: "No plastic-* agent TOML files found in #{tilde(agents_root)}",
1619
- fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
1620
- )
1621
- end
1622
-
1623
- malformed = found.reject { |f| codex_agent_toml_well_formed?(File.read(f)) }
1624
- if malformed.empty?
1625
- check(
1626
- category: "agent_registration", name: "codex_agents_toml", status: "pass",
1627
- message: "#{found.size} plastic-* agent TOML(s) installed in #{tilde(agents_root)}"
1628
- )
1629
- else
1630
- check(
1631
- category: "agent_registration", name: "codex_agents_toml", status: "fail",
1632
- message: "#{malformed.size} Codex agent TOML(s) missing mandatory fields",
1633
- details: malformed.map { |f| tilde(f) },
1634
- fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
1635
- )
1636
- end
1637
- end
1638
-
1639
- def codex_agent_toml_well_formed?(content)
1640
- marker = 'developer_instructions = """'
1641
- di = content.index(marker)
1642
- has_name = content.match?(/^name\s*=\s*"/)
1643
- has_desc = content.match?(/^description\s*=\s*"/)
1644
- balanced = di && content.index('"""', di + marker.length)
1645
- has_name && has_desc && !di.nil? && !balanced.nil?
1646
- end
1647
-
1648
- # Plain string extraction of the single model-selection line a generated
1649
- # Codex agent TOML carries (model_reasoning_effort for a tier alias, model
1650
- # for a literal override id; codex_model_fields never emits both). No TOML
1651
- # parser dependency, mirroring codex_agent_toml_well_formed? above.
1652
- def codex_agent_toml_model_value(content)
1653
- m = content.match(/^model_reasoning_effort\s*=\s*"([^"]*)"/) || content.match(/^model\s*=\s*"([^"]*)"/)
1654
- 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] }
1655
1178
  end
1656
1179
 
1657
1180
  # codex_hooks_registered (intent 102, the owner-facing first-run validation
1658
1181
  # path, Decision 14): ~/.codex/hooks.json must carry EXACTLY the commands
1659
1182
  # HookRegistry.codex_hooks_json defines, mirroring the Claude
1660
1183
  # hooks_match_registry diff. Never writes.
1661
- def codex_hooks_registered_check(config)
1662
- hooks_json = File.join(config[:home_dir], "hooks.json")
1663
- dispatcher = File.join(plastic_home, "scripts", "codex-hook")
1664
- data = read_json_safe(hooks_json)
1665
-
1666
- if data.nil?
1667
- return check(
1668
- category: "agent_registration", name: "codex_hooks_registered", status: "fail",
1669
- message: "Codex hooks.json missing or unreadable at #{tilde(hooks_json)}",
1670
- fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
1671
- )
1672
- end
1673
-
1674
- expected = HookRegistry.codex_hooks_json(dispatcher_path: dispatcher)
1675
- live = data["hooks"] || {}
1676
- missing = []
1677
- expected.each do |event, groups|
1678
- want = Array(groups).flat_map { |g| g["hooks"].map { |h| h["command"] } }
1679
- got = Array(live[event]).flat_map { |g| Array(g["hooks"]).map { |h| h["command"] } }
1680
- missing.concat(want - got)
1681
- end
1682
-
1683
- if missing.empty?
1684
- check(
1685
- category: "agent_registration", name: "codex_hooks_registered", status: "pass",
1686
- message: "Codex hooks registered in hooks.json"
1687
- )
1688
- else
1689
- check(
1690
- category: "agent_registration", name: "codex_hooks_registered", status: "fail",
1691
- message: "Codex hooks.json missing #{missing.size} command(s)",
1692
- details: missing,
1693
- fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
1694
- )
1695
- end
1696
- end
1697
-
1698
- # codex_hooks_implemented (intent 200): codex_hooks_registered_check above proves
1699
- # hooks.json content matches what HookRegistry would emit; both sides of THAT
1700
- # comparison come from the registry, so a pass only proves the registry agrees
1701
- # with itself. It never looks at the actual dispatcher, so it cannot see a
1702
- # registered gate with no real branch there (links-gate shipped exactly this way,
1703
- # dead, in v1.4.0/intent 192, invisible to doctor and the suite until 198 found it
1704
- # by hand), or a dispatcher branch nobody registers (bash-gate's shape, intent
1705
- # 203, in the opposite direction). This check closes both directions at once.
1706
-
1707
- # The Codex gate names HookRegistry actually registers: the six apply_patch-gated
1708
- # names (CODEX_PRE_HOOKS + CODEX_POST_HOOKS), the two Bash-matcher shell gates
1709
- # (CODEX_BASH_HOOKS), and the live-state hook names Codex inherits whole from
1710
- # `events` (CODEX_LIVE_STATE_EVENTS). No parsing needed: these are HookRegistry's
1711
- # own Ruby constants.
1712
- def codex_registry_gate_names
1713
- live_state = HookRegistry::CODEX_LIVE_STATE_EVENTS.flat_map do |event|
1714
- HookRegistry.events[event].flat_map { |g| g["hooks"].map { |h| h["name"] } }
1715
- end
1716
- (HookRegistry::CODEX_PRE_HOOKS + HookRegistry::CODEX_POST_HOOKS +
1717
- HookRegistry::CODEX_BASH_HOOKS + live_state).uniq
1718
- end
1719
1184
 
1720
1185
  # Source-text extraction of scripts/codex-hook's supported gate names (D3): the
1721
1186
  # dispatcher is an executable script with real top-level side effects (it reads
@@ -1735,280 +1200,13 @@ end
1735
1200
  # finds nothing and calls that healthy IS the exact disease this whole check
1736
1201
  # exists to catch, one level up, so nil forces the caller (below) to fail loudly
1737
1202
  # instead of reporting health it never actually verified.
1738
- def codex_dispatcher_gate_names(source)
1739
- names = []
1740
- %w[STATE_HOOKS SHELL_HOOKS].each do |const|
1741
- m = source.match(/^#{const}\s*=\s*%w\[([^\]]*)\]/)
1742
- names.concat(m[1].split(/\s+/)) if m
1743
- end
1744
-
1745
- case_start = source.index(/^case gate\b/)
1746
- if case_start
1747
- case_body = source[case_start..-1]
1748
- else_idx = case_body.index(/^else\b/)
1749
- scanned = else_idx ? case_body[0...else_idx] : case_body
1750
- names.concat(scanned.scan(/^when\s+"([^"]+)"/).flatten)
1751
- end
1752
-
1753
- names.uniq!
1754
- names.empty? ? nil : names
1755
- end
1756
-
1757
- # The both-direction diff. Each mismatch becomes one detail line naming the
1758
- # hook, the direction, the harness ("Codex"), and the concrete runtime effect,
1759
- # never a generic "Codex hooks drift" message (D4).
1760
- def codex_hooks_implemented_check(config)
1761
- dispatcher_path = File.join(plastic_home, "scripts", "codex-hook")
1762
-
1763
- unless File.exist?(dispatcher_path)
1764
- return check(
1765
- category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
1766
- message: "scripts/codex-hook not found at #{tilde(dispatcher_path)}; cannot verify " \
1767
- "the Codex hook registry and dispatcher agree",
1768
- fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
1769
- )
1770
- end
1771
-
1772
- dispatcher_names = codex_dispatcher_gate_names(File.read(dispatcher_path))
1773
-
1774
- if dispatcher_names.nil?
1775
- return check(
1776
- category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
1777
- message: "Could not read any gate names out of #{tilde(dispatcher_path)}: the " \
1778
- "STATE_HOOKS/SHELL_HOOKS constants and the `case gate` statement no longer " \
1779
- "match the shape this check expects, so the registry could not be checked " \
1780
- "against the real dispatcher. This is exactly the silent-pass failure this " \
1781
- "check exists to prevent; update codex_dispatcher_gate_names in doctor.rb " \
1782
- "to the file's new shape.",
1783
- fixable: false
1784
- )
1785
- end
1786
-
1787
- registry_names = codex_registry_gate_names
1788
- missing_dispatcher_branch = registry_names - dispatcher_names
1789
- dead_branch = dispatcher_names - registry_names
1790
-
1791
- if missing_dispatcher_branch.empty? && dead_branch.empty?
1792
- return check(
1793
- category: "agent_registration", name: "codex_hooks_implemented", status: "pass",
1794
- message: "Every Codex-registered gate has a scripts/codex-hook branch, and every " \
1795
- "dispatcher branch is registered"
1796
- )
1797
- end
1798
-
1799
- details = missing_dispatcher_branch.map do |name|
1800
- "#{name} is registered in ~/.codex/hooks.json but scripts/codex-hook has no branch " \
1801
- "for it, so it always falls through to the fail-open else and always allows the write"
1802
- end
1803
- details += dead_branch.map do |name|
1804
- "#{name} has a branch in scripts/codex-hook but is not registered in HookRegistry for " \
1805
- "Codex, so it is dead code Codex never reaches"
1806
- end
1807
-
1808
- check(
1809
- category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
1810
- message: "Codex's hook registry and scripts/codex-hook disagree on #{details.size} gate(s)",
1811
- details: details,
1812
- fixable: false
1813
- )
1814
- end
1815
1203
 
1816
1204
  # config.toml advisory (intent 102, Decision 2, R2): READ ONLY. Warns on the two
1817
1205
  # documented footguns (hooks disabled, sandbox read-only); never writes, and
1818
1206
  # returns nil (no check emitted) when config.toml is absent or carries neither.
1819
- def codex_config_toml_advisory_check(config)
1820
- config_toml = File.join(config[:home_dir], "config.toml")
1821
- return nil unless File.exist?(config_toml)
1822
-
1823
- toml = File.read(config_toml) rescue ""
1824
- warns = []
1825
- # guide Part 3: `codex_hooks` is a deprecated alias for `[features] hooks`; catch both.
1826
- warns << "hooks are disabled ([features] hooks = false); Plastic gates will not fire" if toml.match?(/^\s*(?:codex_)?hooks\s*=\s*false/)
1827
- warns << "sandbox_mode = \"read-only\"; apply_patch writes (and gates) cannot run" if toml.match?(/^\s*sandbox_mode\s*=\s*["']read-only["']/)
1828
- return nil if warns.empty?
1829
-
1830
- check(
1831
- category: "agent_registration", name: "codex_config_advisory", status: "warn",
1832
- message: warns.join("; "),
1833
- fixable: false,
1834
- fix_hint: "Set [features] hooks = true and sandbox_mode = \"workspace-write\" in ~/.codex/config.toml"
1835
- )
1836
- end
1837
-
1838
- # Codex version-floor advisory (intent 184): READ ONLY. The apply_patch PreToolUse
1839
- # veto (scripts/lib/hook_registry.rb, scripts/codex-hook) only fires from Codex
1840
- # v0.123.0 (PR #18391); below it the veto silently no-ops. version.json is Codex's
1841
- # update-checker cache and holds no installed version, so the only install-method-
1842
- # agnostic source is `codex --version`, shelled out through the injected runner.
1843
- # Four honest branches (intent 208, no pass-by-construction): absent home -> nil;
1844
- # present but undetectable -> distinct warn; below floor -> warn; at/above -> pass.
1845
- def codex_version_floor_check(config)
1846
- return nil unless File.exist?(config[:home_dir])
1847
-
1848
- stdout, ok = @runner.call(["--version"])
1849
- version = ok ? codex_version_from_output(stdout) : nil
1850
- parsed = version && safe_version(version)
1851
-
1852
- if parsed.nil?
1853
- return check(
1854
- category: "agent_registration", name: "codex_version_floor", status: "warn",
1855
- message: "Could not determine the installed Codex version (`codex --version` did not " \
1856
- "return a parseable version); Plastic cannot confirm the apply_patch hooks " \
1857
- "floor v#{CODEX_HOOKS_FLOOR} is met, so its gate may silently no-op",
1858
- fixable: false,
1859
- fix_hint: "Ensure `codex` is on PATH and `codex --version` >= #{CODEX_HOOKS_FLOOR}"
1860
- )
1861
- end
1862
-
1863
- if parsed < safe_version(CODEX_HOOKS_FLOOR)
1864
- return check(
1865
- category: "agent_registration", name: "codex_version_floor", status: "warn",
1866
- message: "Installed Codex #{version} predates v#{CODEX_HOOKS_FLOOR}; the apply_patch " \
1867
- "PreToolUse veto only exists from v#{CODEX_HOOKS_FLOOR} (PR #18391), so Plastic's " \
1868
- "gate silently no-ops on this install",
1869
- fixable: false,
1870
- fix_hint: "Upgrade Codex to v#{CODEX_HOOKS_FLOOR} or newer with your install method " \
1871
- "(npm i -g @openai/codex, mise, homebrew, or cargo)"
1872
- )
1873
- end
1874
-
1875
- check(
1876
- category: "agent_registration", name: "codex_version_floor", status: "pass",
1877
- message: "Codex #{version} meets the apply_patch hooks floor v#{CODEX_HOOKS_FLOOR}"
1878
- )
1879
- end
1880
-
1881
- def codex_version_from_output(stdout)
1882
- m = stdout.to_s.match(/(\d+\.\d+\.\d+(?:[.\-+][0-9A-Za-z.\-+]*)?)/)
1883
- m && m[1]
1884
- end
1885
-
1886
- def safe_version(str)
1887
- Gem::Version.new(str.to_s)
1888
- rescue ArgumentError
1889
- nil
1890
- end
1891
1207
 
1892
1208
  # --- Check category 4: Core files ---
1893
1209
 
1894
- def check_core_files(agent_key, include_drift: true)
1895
- checks = []
1896
-
1897
- # plastic_md
1898
- plastic_md = File.join(plastic_home, "PLASTIC.md")
1899
- if File.exist?(plastic_md)
1900
- checks << check(
1901
- category: "core_files", name: "plastic_md", status: "pass",
1902
- message: "PLASTIC.md exists"
1903
- )
1904
- else
1905
- checks << check(
1906
- category: "core_files", name: "plastic_md", status: "fail",
1907
- message: "PLASTIC.md not found at #{tilde(plastic_md)}",
1908
- fixable: true, fix_hint: "Re-run the Plastic installer to restore core files"
1909
- )
1910
- end
1911
-
1912
- # version_file
1913
- version_path = File.join(plastic_home, "VERSION")
1914
- if File.exist?(version_path)
1915
- checks << check(
1916
- category: "core_files", name: "version_file", status: "pass",
1917
- message: "VERSION file exists"
1918
- )
1919
- else
1920
- checks << check(
1921
- category: "core_files", name: "version_file", status: "fail",
1922
- message: "VERSION file not found at #{tilde(version_path)}",
1923
- fixable: true, fix_hint: "Re-run the Plastic installer to restore core files"
1924
- )
1925
- end
1926
-
1927
- # scripts_present
1928
- scripts_dir = File.join(plastic_home, "scripts")
1929
- missing_scripts = REQUIRED_SCRIPTS.reject { |s| File.exist?(File.join(scripts_dir, s)) }
1930
-
1931
- if missing_scripts.empty?
1932
- checks << check(
1933
- category: "core_files", name: "scripts_present", status: "pass",
1934
- message: "All #{REQUIRED_SCRIPTS.size} required scripts present"
1935
- )
1936
- else
1937
- checks << check(
1938
- category: "core_files", name: "scripts_present", status: "fail",
1939
- message: "#{missing_scripts.size} required script(s) missing from #{tilde(scripts_dir)}",
1940
- details: missing_scripts,
1941
- fixable: true, fix_hint: "Re-run the Plastic installer to restore scripts"
1942
- )
1943
- end
1944
-
1945
- # scripts_executable
1946
- if File.directory?(scripts_dir)
1947
- script_files = Dir.children(scripts_dir)
1948
- .map { |f| File.join(scripts_dir, f) }
1949
- .select { |f| File.file?(f) }
1950
-
1951
- non_executable = script_files.reject { |f| File.executable?(f) }
1952
-
1953
- if non_executable.empty?
1954
- checks << check(
1955
- category: "core_files", name: "scripts_executable", status: "pass",
1956
- message: "All scripts in #{tilde(scripts_dir)} are executable"
1957
- )
1958
- else
1959
- checks << check(
1960
- category: "core_files", name: "scripts_executable", status: "fail",
1961
- message: "#{non_executable.size} script(s) not executable",
1962
- details: non_executable.map { |f| tilde(f) },
1963
- fixable: true, fix_hint: "chmod +x on the listed files"
1964
- )
1965
- end
1966
- end
1967
-
1968
- # version_match — compare global VERSION with agent-side VERSION
1969
- global_version = read_version
1970
- agent_config = agents[agent_key]
1971
-
1972
- if global_version && agent_config
1973
- agent_version_path = case agent_key
1974
- when "claude" then File.join(agent_config[:dir], "plastic", "VERSION")
1975
- else File.join(agent_config[:dir], "plastic", "VERSION")
1976
- end
1977
-
1978
- if File.exist?(agent_version_path)
1979
- agent_version = File.read(agent_version_path).strip
1980
-
1981
- if global_version == agent_version
1982
- checks << check(
1983
- category: "core_files", name: "version_match", status: "pass",
1984
- message: "Global VERSION (#{global_version}) matches agent-side VERSION"
1985
- )
1986
- else
1987
- checks << check(
1988
- category: "core_files", name: "version_match", status: "warn",
1989
- message: "Version mismatch: global=#{global_version}, agent=#{agent_version}",
1990
- details: [
1991
- "#{tilde(File.join(plastic_home, "VERSION"))}: #{global_version}",
1992
- "#{tilde(agent_version_path)}: #{agent_version}",
1993
- ],
1994
- fixable: true,
1995
- fix_hint: "Re-sync the stale harness: npx @zalom/plastic@latest install --reinstall <flag>, or plastic-rollback to a prior version"
1996
- )
1997
- end
1998
- else
1999
- checks << check(
2000
- category: "core_files", name: "version_match", status: "warn",
2001
- message: "Agent-side VERSION file not found at #{tilde(agent_version_path)}",
2002
- fixable: true,
2003
- fix_hint: "Re-sync the stale harness: npx @zalom/plastic@latest install --reinstall <flag>, or plastic-rollback to a prior version"
2004
- )
2005
- end
2006
- end
2007
-
2008
- checks += check_agent_model_drift(agent_key) if include_drift
2009
-
2010
- checks
2011
- end
2012
1210
 
2013
1211
  # agent_model_drift - advisory (never fail) config-vs-frontmatter comparison
2014
1212
  # for every installed <agent_dir>/agents/plastic-*.md file (intent 170, D1;
@@ -2123,18 +1321,39 @@ end
2123
1321
  end
2124
1322
  end
2125
1323
 
2126
- # Codex leg of agent_model_drift (intent 198, Decision D4): the shared .md
2127
- # path above is structurally blind on Codex (codex agents are TOML under
2128
- # ~/.codex/agents/, never ~/.agents/agents/*.md), so it always passed on an
2129
- # empty glob without opening a single Codex file. This mirrors the same
2130
- # four buckets (sanctioned override, matches default, drifted,
2131
- # unclassified) against ~/.codex/agents/plastic-*.toml instead, reading
2132
- # 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
2133
1333
  # codex_agent_toml_well_formed? already uses (no TOML parser dependency).
2134
- # The expected value resolves through AgentModels::TIER_DEFAULTS mapped
2135
- # 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
2136
1342
  # agents.models.codex.<name> override precedence install_codex's own
2137
- # 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
+ #
2138
1357
  # AgentModels::CONSULTATION_AGENTS need no special-case bucket here:
2139
1358
  # generate_codex_agents already skips writing them for Codex entirely, so
2140
1359
  # the glob below never finds them and there is nothing to classify.
@@ -2158,21 +1377,30 @@ end
2158
1377
 
2159
1378
  installed.each do |path|
2160
1379
  basename = File.basename(path, ".toml")
2161
- installed_value = codex_agent_toml_model_value(File.read(path))
1380
+ fields = codex_agent_toml_model_fields(File.read(path))
2162
1381
  override = overrides[basename]
2163
1382
 
2164
1383
  if override
2165
- 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}"
2166
1386
  elsif AgentModels::TIER_DEFAULTS.key?(basename)
2167
- expected_effort = AgentModels.effort_for(AgentModels::TIER_DEFAULTS[basename])
2168
- if installed_value != expected_effort
2169
- 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}"
2170
1393
  end
1394
+ if expected_effort && fields[:effort] != expected_effort
1395
+ mismatches << "effort=#{fields[:effort].inspect}, resolved default effort=#{expected_effort.inspect}"
1396
+ end
1397
+ drifted << "#{basename}: #{mismatches.join("; ")}" if mismatches.any?
2171
1398
  else
2172
- unclassified << "#{basename}: toml=#{installed_value.inspect}, no resolved default (basename is in " \
2173
- "neither AgentModels::TIER_DEFAULTS nor AgentModels::CONSULTATION_AGENTS in " \
2174
- "scripts/lib/agent_models.rb; add it there, or set agents.models.codex.#{basename} " \
2175
- "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)"
2176
1404
  end
2177
1405
  end
2178
1406
 
@@ -2189,7 +1417,7 @@ end
2189
1417
  )]
2190
1418
  else
2191
1419
  parts = []
2192
- 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?
2193
1421
  parts << "#{unclassified.size} installed Codex agent TOML(s) have no resolved default in scripts/lib/agent_models.rb" if unclassified.any?
2194
1422
  [check(
2195
1423
  category: "core_files", name: "agent_model_drift", status: "warn",
@@ -2209,69 +1437,6 @@ end
2209
1437
  # Manifest format: { "version", "created", "files": { abs_path => sha256 } }.
2210
1438
  # A missing manifest is a fail; any missing/mismatched listed file is a fail;
2211
1439
  # otherwise a single pass per manifest.
2212
- def check_manifest_sync(agent_key)
2213
- checks = []
2214
-
2215
- global_manifest = File.join(plastic_home, "manifest.json")
2216
- checks << verify_manifest(global_manifest, "global")
2217
-
2218
- agent_dir = agents[agent_key][:dir]
2219
- agent_manifest = File.join(agent_dir, "plastic", "manifest.json")
2220
- checks << verify_manifest(agent_manifest, "agent")
2221
-
2222
- checks
2223
- end
2224
-
2225
- # Check one manifest file. Returns a single check (pass or fail).
2226
- def verify_manifest(manifest_path, label)
2227
- unless File.exist?(manifest_path)
2228
- return check(
2229
- category: "manifest_sync", name: "#{label}_manifest", status: "fail",
2230
- message: "#{label} core manifest missing — re-run the Plastic installer",
2231
- details: [tilde(manifest_path)],
2232
- fixable: true, fix_hint: "Re-run the Plastic installer"
2233
- )
2234
- end
2235
-
2236
- data = read_json_safe(manifest_path)
2237
- files = data.is_a?(Hash) ? data["files"] : nil
2238
- unless files.is_a?(Hash)
2239
- return check(
2240
- category: "manifest_sync", name: "#{label}_manifest", status: "fail",
2241
- message: "#{label} core manifest unreadable or malformed — re-run the Plastic installer",
2242
- details: [tilde(manifest_path)],
2243
- fixable: true, fix_hint: "Re-run the Plastic installer"
2244
- )
2245
- end
2246
-
2247
- missing = []
2248
- mismatched = []
2249
- files.each do |path, recorded|
2250
- unless File.exist?(path)
2251
- missing << tilde(path)
2252
- next
2253
- end
2254
- actual = Digest::SHA256.file(path).hexdigest
2255
- mismatched << tilde(path) if actual != recorded
2256
- end
2257
-
2258
- if missing.empty? && mismatched.empty?
2259
- check(
2260
- category: "manifest_sync", name: "#{label}_manifest", status: "pass",
2261
- message: "#{label} manifest: all #{files.size} tracked file(s) present and matching"
2262
- )
2263
- else
2264
- details = []
2265
- details += missing.map { |p| "missing: #{p}" }
2266
- details += mismatched.map { |p| "modified: #{p}" }
2267
- check(
2268
- category: "manifest_sync", name: "#{label}_manifest", status: "fail",
2269
- message: "#{label} manifest out of sync: #{missing.size} missing, #{mismatched.size} modified",
2270
- details: details,
2271
- fixable: true, fix_hint: "Re-run the Plastic installer to restore tracked files"
2272
- )
2273
- end
2274
- end
2275
1440
 
2276
1441
  # install_integrity (intent 210, D4): FULL tier only, warn-only, never writes. For each
2277
1442
  # installed agent, re-hash its manifest-listed files against disk and WARN on drift
@@ -2340,50 +1505,6 @@ end
2340
1505
  # mirror (~/.plastic/projects/<slug>/{store,INDEX.md,project.yml}); this method validates the
2341
1506
  # registered repo path itself (projects.yml's "path" key), decoupled from 175's deeper
2342
1507
  # repo-root-versus-registered-path question (219 D9).
2343
- def check_registered_project_paths
2344
- checks = []
2345
-
2346
- projects_yml_path = File.join(plastic_home, "projects.yml")
2347
- projects_data = load_yaml_safe(projects_yml_path)
2348
-
2349
- if projects_data.nil?
2350
- checks << check(
2351
- category: "project_stores", name: "registered_project_paths", status: "fail",
2352
- message: "projects.yml not found or invalid at #{tilde(projects_yml_path)}",
2353
- fixable: true, fix_hint: "Re-run the Plastic installer to restore projects.yml"
2354
- )
2355
- return checks
2356
- end
2357
-
2358
- projects = projects_data["projects"]
2359
- unless projects.is_a?(Hash) && !projects.empty?
2360
- checks << check(
2361
- category: "project_stores", name: "registered_project_paths", status: "pass",
2362
- message: "No projects registered"
2363
- )
2364
- return checks
2365
- end
2366
-
2367
- projects.each do |slug, project_info|
2368
- path = project_info.is_a?(Hash) ? project_info["path"] : nil
2369
-
2370
- if path && File.directory?(path)
2371
- checks << check(
2372
- category: "project_stores", name: "project_path_resolves", status: "pass",
2373
- message: "Registered path for '#{slug}' resolves to a real directory"
2374
- )
2375
- else
2376
- checks << check(
2377
- category: "project_stores", name: "project_path_resolves", status: "fail",
2378
- message: "Registered path for '#{slug}' does not resolve to a real directory",
2379
- details: [path ? tilde(path) : "(no path set in projects.yml)"],
2380
- fixable: false
2381
- )
2382
- end
2383
- end
2384
-
2385
- checks
2386
- end
2387
1508
 
2388
1509
  # Per-project validation extracted from check_project_stores so a single
2389
1510
  # project can be checked in isolation (used by `--store <slug>`).
@@ -2776,6 +1897,61 @@ end
2776
1897
  end
2777
1898
  end
2778
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
+
2779
1955
  # --- Check category: skill-lint (advisory only; intent 85b) ---
2780
1956
  #
2781
1957
  # Reports SkillLint's five structural checks over the skills/ tree as a
@@ -2829,6 +2005,7 @@ end
2829
2005
  all_checks += check_deprecations
2830
2006
  all_checks += check_config_asks(agent_key)
2831
2007
  all_checks += check_qmd
2008
+ all_checks += check_ruby_runtime
2832
2009
  all_checks += check_done_signals(scopes: ["global"])
2833
2010
  all_checks += check_skill_lint
2834
2011
  all_checks += check_install_integrity
@@ -2836,22 +2013,6 @@ end
2836
2013
  summarize(all_checks, agent_key)
2837
2014
  end
2838
2015
 
2839
- # Binary core sync check: agent registration + core files (drift excluded) + manifest
2840
- # sync + registered project paths + global store availability, rolled up with binary:
2841
- # true so ANY warn or fail makes the overall status "fail" (and "warn" is never
2842
- # emitted). Used by `doctor.rb --core` (219 D1/D2: operational readiness only, no
2843
- # agent_model_drift, no store-content scanning).
2844
- def run_core_checks(agent_key)
2845
- all_checks = []
2846
- all_checks += check_agent_registration(agent_key)
2847
- all_checks += check_core_files(agent_key, include_drift: false)
2848
- all_checks += check_manifest_sync(agent_key)
2849
- all_checks += check_registered_project_paths
2850
- all_checks += check_global_store_available
2851
-
2852
- summarize(all_checks, agent_key, binary: true)
2853
- end
2854
-
2855
2016
  # Per-intent structure gate for `doctor.rb --intent <id> [--store <key>] [--disposition ...]`
2856
2017
  # (intent 222). `store`, here, is already the resolved scope key (see
2857
2018
  # normalize_intent_store_scope, the CLI-boundary translator that turns the loosely-typed
@@ -2945,33 +2106,6 @@ end
2945
2106
  check_enola(cwd: probe_cwd, path_probe: enola_path_probe, marker_finder: enola_marker_finder)
2946
2107
  end
2947
2108
 
2948
- # Roll a list of checks up into the standard result envelope.
2949
- # When binary: true, the overall status is "pass" only if there are zero warn
2950
- # AND zero fail; any warn or fail yields "fail" (never "warn"). When false
2951
- # (the default) the classic 3-state pass/warn/fail roll-up is used.
2952
- def summarize(all_checks, agent_key, binary: false)
2953
- summary = { pass: 0, warn: 0, fail: 0, total: all_checks.size }
2954
- all_checks.each { |c| summary[c[:status].to_sym] += 1 }
2955
-
2956
- overall = if binary
2957
- (summary[:fail] > 0 || summary[:warn] > 0) ? "fail" : "pass"
2958
- elsif summary[:fail] > 0
2959
- "fail"
2960
- elsif summary[:warn] > 0
2961
- "warn"
2962
- else
2963
- "pass"
2964
- end
2965
-
2966
- {
2967
- version: read_version || "unknown",
2968
- timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
2969
- status: overall,
2970
- agent: agent_key,
2971
- checks: all_checks,
2972
- summary: summary,
2973
- }
2974
- end
2975
2109
 
2976
2110
  # --- Main ---
2977
2111