@zalom/plastic 1.4.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/scripts/doctor.rb CHANGED
@@ -1268,12 +1268,35 @@ class Doctor
1268
1268
  end
1269
1269
  end
1270
1270
 
1271
- checks << codex_hooks_registered_check(config)
1271
+ hooks_check = codex_hooks_registered_check(config)
1272
+ checks << hooks_check
1273
+ checks << codex_hooks_implemented_check(config)
1274
+ checks << codex_hook_trust_advisory_check if hooks_check[:status] == "pass"
1272
1275
  codex_config_toml_advisory_check(config).tap { |c| checks << c if c }
1273
1276
 
1274
1277
  checks
1275
1278
  end
1276
1279
 
1280
+ # Hooks being REGISTERED (hooks.json content matches HookRegistry) is not
1281
+ # the same as hooks being TRUSTED: Codex gates every non-managed hook
1282
+ # command behind a human /hooks review, keyed by the command's current
1283
+ # hash, so a release that changes a hook command re-arms the review
1284
+ # (intent 198, Decision D2). Whether Codex persists a queryable trust
1285
+ # record anywhere under ~/.codex is undocumented and unverified, so this
1286
+ # can never be a real pass or fail on trust state; it is an advisory that
1287
+ # always names the /hooks step, and it fires only once hooks are actually
1288
+ # registered (an unregistered hook is already reported by
1289
+ # codex_hooks_registered_check, so reminding about trust on top of that
1290
+ # would be noise, not signal).
1291
+ def codex_hook_trust_advisory_check
1292
+ check(
1293
+ category: "agent_registration", name: "codex_hooks_trust", status: "warn",
1294
+ message: "Open Codex, run /hooks, and trust the Plastic hook definitions. " \
1295
+ "Codex re-arms this review whenever a hook's command changes.",
1296
+ fixable: false
1297
+ )
1298
+ end
1299
+
1277
1300
  # Codex-specific agent presence + structural sanity: ~/.codex/agents/plastic-*.toml
1278
1301
  # exist and each carries the mandatory fields. No TOML parser (doctor depends on none):
1279
1302
  # "structural" means the mandatory keys appear as lines and the multi-line
@@ -1315,6 +1338,15 @@ class Doctor
1315
1338
  has_name && has_desc && !di.nil? && !balanced.nil?
1316
1339
  end
1317
1340
 
1341
+ # Plain string extraction of the single model-selection line a generated
1342
+ # Codex agent TOML carries (model_reasoning_effort for a tier alias, model
1343
+ # for a literal override id; codex_model_fields never emits both). No TOML
1344
+ # parser dependency, mirroring codex_agent_toml_well_formed? above.
1345
+ def codex_agent_toml_model_value(content)
1346
+ m = content.match(/^model_reasoning_effort\s*=\s*"([^"]*)"/) || content.match(/^model\s*=\s*"([^"]*)"/)
1347
+ m && m[1]
1348
+ end
1349
+
1318
1350
  # codex_hooks_registered (intent 102, the owner-facing first-run validation
1319
1351
  # path, Decision 14): ~/.codex/hooks.json must carry EXACTLY the commands
1320
1352
  # HookRegistry.codex_hooks_json defines, mirroring the Claude
@@ -1356,6 +1388,124 @@ class Doctor
1356
1388
  end
1357
1389
  end
1358
1390
 
1391
+ # codex_hooks_implemented (intent 200): codex_hooks_registered_check above proves
1392
+ # hooks.json content matches what HookRegistry would emit; both sides of THAT
1393
+ # comparison come from the registry, so a pass only proves the registry agrees
1394
+ # with itself. It never looks at the actual dispatcher, so it cannot see a
1395
+ # registered gate with no real branch there (links-gate shipped exactly this way,
1396
+ # dead, in v1.4.0/intent 192, invisible to doctor and the suite until 198 found it
1397
+ # by hand), or a dispatcher branch nobody registers (bash-gate's shape, intent
1398
+ # 203, in the opposite direction). This check closes both directions at once.
1399
+
1400
+ # The Codex gate names HookRegistry actually registers: the six apply_patch-gated
1401
+ # names (CODEX_PRE_HOOKS + CODEX_POST_HOOKS), the two Bash-matcher shell gates
1402
+ # (CODEX_BASH_HOOKS), and the live-state hook names Codex inherits whole from
1403
+ # `events` (CODEX_LIVE_STATE_EVENTS). No parsing needed: these are HookRegistry's
1404
+ # own Ruby constants.
1405
+ def codex_registry_gate_names
1406
+ live_state = HookRegistry::CODEX_LIVE_STATE_EVENTS.flat_map do |event|
1407
+ HookRegistry.events[event].flat_map { |g| g["hooks"].map { |h| h["name"] } }
1408
+ end
1409
+ (HookRegistry::CODEX_PRE_HOOKS + HookRegistry::CODEX_POST_HOOKS +
1410
+ HookRegistry::CODEX_BASH_HOOKS + live_state).uniq
1411
+ end
1412
+
1413
+ # Source-text extraction of scripts/codex-hook's supported gate names (D3): the
1414
+ # dispatcher is an executable script with real top-level side effects (it reads
1415
+ # $stdin and may exit), so it can never be required or executed to introspect it,
1416
+ # only read as plain text, mirroring codex_agent_toml_well_formed? above. Pulls
1417
+ # gate names from the STATE_HOOKS and SHELL_HOOKS %w[] literals, plus the `when
1418
+ # "<name>"` labels of the top-level `case gate` statement (stopping at its
1419
+ # trailing `else`). Line-shape dependent, not AST-safe, disclosed as such in
1420
+ # docs; the healthy-install pass test against the REAL dispatcher is what proves
1421
+ # this shape still holds.
1422
+ #
1423
+ # SELF-CHECKING: returns nil, never [], when nothing recognizable is found. A
1424
+ # reshaped dispatcher (combined `when "a", "b"` arms, a multi-line array, a
1425
+ # Hash-dispatch rewrite) would otherwise silently read as zero gate names, and a
1426
+ # zero-gate dispatcher would make the diff below either flag every registered
1427
+ # hook as "missing" or, worse, quietly under-report a real gap. A check that
1428
+ # finds nothing and calls that healthy IS the exact disease this whole check
1429
+ # exists to catch, one level up, so nil forces the caller (below) to fail loudly
1430
+ # instead of reporting health it never actually verified.
1431
+ def codex_dispatcher_gate_names(source)
1432
+ names = []
1433
+ %w[STATE_HOOKS SHELL_HOOKS].each do |const|
1434
+ m = source.match(/^#{const}\s*=\s*%w\[([^\]]*)\]/)
1435
+ names.concat(m[1].split(/\s+/)) if m
1436
+ end
1437
+
1438
+ case_start = source.index(/^case gate\b/)
1439
+ if case_start
1440
+ case_body = source[case_start..-1]
1441
+ else_idx = case_body.index(/^else\b/)
1442
+ scanned = else_idx ? case_body[0...else_idx] : case_body
1443
+ names.concat(scanned.scan(/^when\s+"([^"]+)"/).flatten)
1444
+ end
1445
+
1446
+ names.uniq!
1447
+ names.empty? ? nil : names
1448
+ end
1449
+
1450
+ # The both-direction diff. Each mismatch becomes one detail line naming the
1451
+ # hook, the direction, the harness ("Codex"), and the concrete runtime effect,
1452
+ # never a generic "Codex hooks drift" message (D4).
1453
+ def codex_hooks_implemented_check(config)
1454
+ dispatcher_path = File.join(plastic_home, "scripts", "codex-hook")
1455
+
1456
+ unless File.exist?(dispatcher_path)
1457
+ return check(
1458
+ category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
1459
+ message: "scripts/codex-hook not found at #{tilde(dispatcher_path)}; cannot verify " \
1460
+ "the Codex hook registry and dispatcher agree",
1461
+ fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
1462
+ )
1463
+ end
1464
+
1465
+ dispatcher_names = codex_dispatcher_gate_names(File.read(dispatcher_path))
1466
+
1467
+ if dispatcher_names.nil?
1468
+ return check(
1469
+ category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
1470
+ message: "Could not read any gate names out of #{tilde(dispatcher_path)}: the " \
1471
+ "STATE_HOOKS/SHELL_HOOKS constants and the `case gate` statement no longer " \
1472
+ "match the shape this check expects, so the registry could not be checked " \
1473
+ "against the real dispatcher. This is exactly the silent-pass failure this " \
1474
+ "check exists to prevent; update codex_dispatcher_gate_names in doctor.rb " \
1475
+ "to the file's new shape.",
1476
+ fixable: false
1477
+ )
1478
+ end
1479
+
1480
+ registry_names = codex_registry_gate_names
1481
+ missing_dispatcher_branch = registry_names - dispatcher_names
1482
+ dead_branch = dispatcher_names - registry_names
1483
+
1484
+ if missing_dispatcher_branch.empty? && dead_branch.empty?
1485
+ return check(
1486
+ category: "agent_registration", name: "codex_hooks_implemented", status: "pass",
1487
+ message: "Every Codex-registered gate has a scripts/codex-hook branch, and every " \
1488
+ "dispatcher branch is registered"
1489
+ )
1490
+ end
1491
+
1492
+ details = missing_dispatcher_branch.map do |name|
1493
+ "#{name} is registered in ~/.codex/hooks.json but scripts/codex-hook has no branch " \
1494
+ "for it, so it always falls through to the fail-open else and always allows the write"
1495
+ end
1496
+ details += dead_branch.map do |name|
1497
+ "#{name} has a branch in scripts/codex-hook but is not registered in HookRegistry for " \
1498
+ "Codex, so it is dead code Codex never reaches"
1499
+ end
1500
+
1501
+ check(
1502
+ category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
1503
+ message: "Codex's hook registry and scripts/codex-hook disagree on #{details.size} gate(s)",
1504
+ details: details,
1505
+ fixable: false
1506
+ )
1507
+ end
1508
+
1359
1509
  # config.toml advisory (intent 102, Decision 2, R2): READ ONLY. Warns on the two
1360
1510
  # documented footguns (hooks disabled, sandbox read-only); never writes, and
1361
1511
  # returns nil (no check emitted) when config.toml is absent or carries neither.
@@ -1547,6 +1697,8 @@ class Doctor
1547
1697
  agent_config = agents[agent_key]
1548
1698
  return [] unless agent_config
1549
1699
 
1700
+ return check_agent_model_drift_codex(agent_config) if agent_key == "codex"
1701
+
1550
1702
  agents_dir = File.join(agent_config[:dir], "agents")
1551
1703
  installed = Dir.glob(File.join(agents_dir, "plastic-*.md")).sort
1552
1704
 
@@ -1608,6 +1760,83 @@ class Doctor
1608
1760
  end
1609
1761
  end
1610
1762
 
1763
+ # Codex leg of agent_model_drift (intent 198, Decision D4): the shared .md
1764
+ # path above is structurally blind on Codex (codex agents are TOML under
1765
+ # ~/.codex/agents/, never ~/.agents/agents/*.md), so it always passed on an
1766
+ # empty glob without opening a single Codex file. This mirrors the same
1767
+ # four buckets (sanctioned override, matches default, drifted,
1768
+ # unclassified) against ~/.codex/agents/plastic-*.toml instead, reading
1769
+ # model / model_reasoning_effort with the same plain string matching
1770
+ # codex_agent_toml_well_formed? already uses (no TOML parser dependency).
1771
+ # The expected value resolves through AgentModels::TIER_DEFAULTS mapped
1772
+ # through AgentModels.effort_for, honoring the Codex-scoped
1773
+ # agents.models.codex.<name> override precedence install_codex's own
1774
+ # agent_model_overrides(harness: "codex") already applies.
1775
+ # AgentModels::CONSULTATION_AGENTS need no special-case bucket here:
1776
+ # generate_codex_agents already skips writing them for Codex entirely, so
1777
+ # the glob below never finds them and there is nothing to classify.
1778
+ def check_agent_model_drift_codex(agent_config)
1779
+ agents_dir = File.join(agent_config[:home_dir], "agents")
1780
+ installed = Dir.glob(File.join(agents_dir, "plastic-*.toml")).sort
1781
+
1782
+ if installed.empty?
1783
+ return [check(
1784
+ category: "core_files", name: "agent_model_drift", status: "pass",
1785
+ message: "No installed plastic-* agent TOML files to check for model drift"
1786
+ )]
1787
+ end
1788
+
1789
+ global_config = load_yaml_safe(File.join(plastic_home, "config.yml")) || {}
1790
+ overrides = AgentModels.override_map(project_config: {}, global_config: global_config, harness: "codex")
1791
+
1792
+ drifted = []
1793
+ sanctioned = []
1794
+ unclassified = []
1795
+
1796
+ installed.each do |path|
1797
+ basename = File.basename(path, ".toml")
1798
+ installed_value = codex_agent_toml_model_value(File.read(path))
1799
+ override = overrides[basename]
1800
+
1801
+ if override
1802
+ sanctioned << "#{basename}: toml=#{installed_value.inspect}, sanctioned override=#{override.inspect}"
1803
+ elsif AgentModels::TIER_DEFAULTS.key?(basename)
1804
+ expected_effort = AgentModels.effort_for(AgentModels::TIER_DEFAULTS[basename])
1805
+ if installed_value != expected_effort
1806
+ drifted << "#{basename}: toml=#{installed_value.inspect}, resolved default effort=#{expected_effort.inspect}"
1807
+ end
1808
+ else
1809
+ unclassified << "#{basename}: toml=#{installed_value.inspect}, no resolved default (basename is in " \
1810
+ "neither AgentModels::TIER_DEFAULTS nor AgentModels::CONSULTATION_AGENTS in " \
1811
+ "scripts/lib/agent_models.rb; add it there, or set agents.models.codex.#{basename} " \
1812
+ "to sanction a model explicitly)"
1813
+ end
1814
+ end
1815
+
1816
+ if drifted.empty? && unclassified.empty?
1817
+ message = if sanctioned.empty?
1818
+ "No agent-model drift (#{installed.size} installed Codex agent TOML(s) match the resolved default)"
1819
+ else
1820
+ "No unsanctioned Codex agent-model drift; #{sanctioned.size} sanctioned override(s) in effect"
1821
+ end
1822
+ [check(
1823
+ category: "core_files", name: "agent_model_drift", status: "pass",
1824
+ message: message,
1825
+ details: sanctioned
1826
+ )]
1827
+ else
1828
+ parts = []
1829
+ parts << "#{drifted.size} installed Codex agent TOML(s) have unsanctioned model drift vs the config-resolved default" if drifted.any?
1830
+ parts << "#{unclassified.size} installed Codex agent TOML(s) have no resolved default in scripts/lib/agent_models.rb" if unclassified.any?
1831
+ [check(
1832
+ category: "core_files", name: "agent_model_drift", status: "warn",
1833
+ message: parts.join("; "),
1834
+ details: drifted + unclassified + sanctioned,
1835
+ fixable: false
1836
+ )]
1837
+ end
1838
+ end
1839
+
1611
1840
  # --- Check category: manifest sync (binary core integrity) ---
1612
1841
 
1613
1842
  # Verify, for BOTH the global manifest and the agent-side manifest, that every
@@ -2,7 +2,7 @@
2
2
  # encoding: UTF-8
3
3
  # frozen_string_literal: true
4
4
  #
5
- # Usage: hook-lock-gate <file_path> [session_id]
5
+ # Usage: hook-lock-gate <file_path> [session_id] [harness]
6
6
  # Fail-CLOSED PreToolUse gate (intent 96). Blocks a mutating write to an active
7
7
  # intent's lifecycle dir when THIS session holds no live lock. Project code is NOT
8
8
  # gated here (D2). Emits the PreToolUse JSON deny contract at exit 0; reserves
@@ -16,9 +16,14 @@ begin
16
16
  file_path = ARGV[0]
17
17
  exit 0 unless file_path && !file_path.empty?
18
18
  session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
19
+ # Usage: hook-lock-gate <file_path> [session_id] [harness]. Claude's own bash
20
+ # shim never passes a third arg, so harness falls through to Lock's default
21
+ # (claude, slash form); scripts/codex-hook is the only caller that passes
22
+ # "codex" here (intent 201 D2).
23
+ harness = (ARGV[2] unless ARGV[2].to_s.empty?) || "claude"
19
24
 
20
25
  bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd) # nil = no bridge cache
21
- reason = Bridge.lock_gate_decision(bridge_data, file_path, session: session)
26
+ reason = Bridge.lock_gate_decision(bridge_data, file_path, session: session, harness: harness)
22
27
  unless reason
23
28
  # Allow path: refresh the lease for the session that holds this target's
24
29
  # lock (owner or delegate). Best-effort, never blocks.
@@ -37,7 +42,7 @@ begin
37
42
  dir = Bridge.intent_dir_for(file_path)
38
43
  artifact = File.basename(file_path) if dir
39
44
  if dir && artifact
40
- claim_reason = Claim.claim_gate_reason(dir, artifact, session: session)
45
+ claim_reason = Claim.claim_gate_reason(dir, artifact, session: session, harness: harness)
41
46
  if claim_reason
42
47
  print JSON.generate(
43
48
  "hookSpecificOutput" => {
@@ -40,10 +40,26 @@ class Install < InstallerCore
40
40
  puts "\n\u{1f9e0} Plastic v#{version}\n\n"
41
41
 
42
42
  if installed? && !reinstall
43
- warn "Plastic v#{installed_version} is already installed."
44
- warn " - To upgrade: npx @zalom/plastic update"
45
- warn " - To re-sync files: npx @zalom/plastic install --reinstall"
46
- return 1
43
+ # `installed?` is a GLOBAL check (is Plastic core installed for ANY
44
+ # harness), so it cannot by itself decide whether to refuse: once any
45
+ # agent is present, a machine that has NEVER installed a second harness
46
+ # (e.g. Codex, with no ~/.agents, no ~/.codex/hooks.json) must still be
47
+ # able to add it. Refuse only when EVERY selected agent already has its
48
+ # own registration (intent 198, D7); otherwise proceed with just the
49
+ # unregistered ones, and report the already-registered ones without
50
+ # silently re-syncing them (that is what --reinstall is for).
51
+ new_agents = selected.reject { |key| agent_installed?(key) }
52
+ if new_agents.empty?
53
+ warn "Plastic v#{installed_version} is already installed."
54
+ warn " - To upgrade: npx @zalom/plastic update"
55
+ warn " - To re-sync files: npx @zalom/plastic install --reinstall"
56
+ return 1
57
+ end
58
+
59
+ already_registered = selected - new_agents
60
+ run(selected: new_agents, force: force, reinstall: reinstall, ledger_action: ledger_action, argv: argv,
61
+ already_registered: already_registered)
62
+ return 0
47
63
  end
48
64
 
49
65
  run(selected: selected, force: force, reinstall: reinstall, ledger_action: ledger_action, argv: argv)
@@ -51,7 +67,11 @@ class Install < InstallerCore
51
67
  end
52
68
 
53
69
  # Hermetic entrypoint (no prompting / no exit). Returns the per-agent results array.
54
- def run(selected:, force: false, reinstall: false, ledger_action: nil, argv: ARGV, input: $stdin)
70
+ # `already_registered` names agents the gate above found already registered:
71
+ # they are reported, never re-installed, so the summary line and the Codex
72
+ # trust reminder only ever count agents that actually changed this run.
73
+ def run(selected:, force: false, reinstall: false, ledger_action: nil, argv: ARGV, input: $stdin,
74
+ already_registered: [])
55
75
  fresh = !installed?
56
76
  mode = fresh ? :install : :update # :update here means "re-sync, skip bootstrap"
57
77
 
@@ -60,6 +80,7 @@ class Install < InstallerCore
60
80
  apply_config_flags(argv)
61
81
 
62
82
  results = selected.map { |key| install_for_agent(key, force, argv: argv, input: input, reinstall: reinstall) }
83
+ results += already_registered.map { |key| already_registered_result(key) }
63
84
 
64
85
  action = ledger_action || (fresh ? "install" : "reinstall")
65
86
  ledger_append(version, action)
@@ -114,10 +135,17 @@ class Install < InstallerCore
114
135
  argv[i + 1]
115
136
  end
116
137
 
138
+ def already_registered_result(key)
139
+ config = agent_config(key)
140
+ { agent: config[:name], success: false, already_registered: true }
141
+ end
142
+
117
143
  def print_results(results, mode)
118
144
  puts "\n\u{2014} Results \u{2014}\n\n"
119
145
  results.each do |r|
120
- if r[:success]
146
+ if r[:already_registered]
147
+ puts " \u{2139} #{r[:agent]}: already registered, not re-synced (use --reinstall to re-sync)"
148
+ elsif r[:success]
121
149
  puts " \u{2705} #{r[:agent]}: #{r[:files]} files installed"
122
150
  else
123
151
  puts " \u{26a0}\u{fe0f} #{r[:agent]}: #{r[:reason]}"
@@ -132,6 +160,23 @@ class Install < InstallerCore
132
160
  puts " Registered for: #{installed.map { |r| r[:agent] }.join(", ")}"
133
161
  puts " Run /clear (or restart your agent) to pick up new conventions."
134
162
  puts " Next: read docs/guides/your-first-intent-in-10-minutes.md\n\n"
163
+
164
+ print_codex_hook_trust_reminder(installed)
165
+ end
166
+
167
+ # Codex hooks are installed but INERT until a human reviews and trusts each
168
+ # hook definition via /hooks (intent 198, Decision D2); Codex keys trust to
169
+ # the hook's current command hash, so a future release that changes a hook
170
+ # command re-arms the review. Printed only when a harness that declares its
171
+ # own home_dir (Codex today) actually installed successfully in this run.
172
+ # Data-driven from `agents`, never a hardcoded harness name, mirroring the
173
+ # same reasoning as the D1 presence-probe fix.
174
+ def print_codex_hook_trust_reminder(installed)
175
+ codex_like = agents.select { |a| a.key?(:home_dir) }
176
+ return if codex_like.none? { |a| installed.any? { |r| r[:agent] == a[:name] } }
177
+
178
+ puts " Codex: open Codex, run /hooks, and trust the Plastic hook definitions."
179
+ puts " Plastic's gates will not fire until you do.\n\n"
135
180
  end
136
181
 
137
182
  def show_help
@@ -21,6 +21,16 @@ module Bridge
21
21
  # (<id>--<slug>.md) is never sentineled; it is born complete.
22
22
  PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
23
23
 
24
+ # Single place a skill-reference string gets built (intent 201, D3): every
25
+ # message that used to write "/plastic-something" by hand calls this
26
+ # instead, so a fourth harness only teaches ITS prefix once instead of
27
+ # hunting the codebase for hardcoded slashes. The actual prefix table lives
28
+ # on Lock (see lock.rb), which bridge.rb already requires; this is a thin
29
+ # delegator so every call site in this file reads Bridge.skill_ref.
30
+ def self.skill_ref(name, harness: :claude)
31
+ Lock.skill_ref(name, harness: harness)
32
+ end
33
+
24
34
  # Bridge cleanup is terminal-state, not age-based (intent 80). A bridge is dead
25
35
  # weight ONLY once its intent is terminal (no longer in its store's INDEX.md
26
36
  # `## Active` block); such bridges are purged. An Active intent's bridge is kept
@@ -87,6 +97,12 @@ module Bridge
87
97
  "host" => lock_data["host"],
88
98
  "type" => lock_data["type"],
89
99
  "delegates" => Array(lock_data["delegates"]),
100
+ "owner_harness" => lock_data["owner_harness"],
101
+ "owner_agent" => lock_data["owner_agent"],
102
+ "owner_model" => lock_data["owner_model"],
103
+ "owner_thread" => lock_data["owner_thread"],
104
+ "run_mode" => lock_data["run_mode"],
105
+ "delegate_activity" => Array(lock_data["delegate_activity"]),
90
106
  }
91
107
  end
92
108
 
@@ -872,7 +888,8 @@ module Bridge
872
888
  # and arm_guided (auto: false) are thin delegators so the lock-stamp + provision
873
889
  # behaviour stays identical across both modes. Works even when no bridge exists
874
890
  # yet (mid-session intent creation).
875
- def self.arm(session, intent_id:, intent_dir:, store:, name:, auto:)
891
+ def self.arm(session, intent_id:, intent_dir:, store:, name:, auto:, harness: nil,
892
+ agent: nil, model: nil, thread: nil)
876
893
  key = resolve_session(session, intent_id: intent_id, store: store)
877
894
  if blank?(session) && blank?(ENV["CLAUDE_CODE_SESSION_ID"])
878
895
  $stderr.puts "plastic: no session id available; arming with derived bridge key #{key}"
@@ -883,24 +900,29 @@ module Bridge
883
900
  # Acquire the durable delivery lock (D1/D2): session-keyed, O_EXCL, in the
884
901
  # intent dir. The bridge lock block is a cache of the file.
885
902
  intent_dir_abs = File.expand_path(intent_dir)
886
- status, lock_data = Lock.acquire(intent_dir_abs, session: key)
903
+ status, lock_data = Lock.acquire(intent_dir_abs, session: key,
904
+ harness: harness, agent: agent,
905
+ model: model, thread: thread,
906
+ run_mode: auto ? "auto" : "guided")
887
907
  case status
888
908
  when :acquired, :owned
889
909
  data["lock"] = lock_cache(lock_data)
890
910
  when :held
891
911
  raise LockHeldError, "delivery lock for intent #{intent_id} is held by " \
892
- "session #{lock_data && lock_data['owner_session']}; run /plastic-doctor " \
893
- "check the lock status"
912
+ "session #{lock_data && lock_data['owner_session']}; run " \
913
+ "#{skill_ref('plastic-doctor', harness: harness)} check the lock status"
894
914
  when :stale
895
915
  raise LockHeldError, "delivery lock for intent #{intent_id} is stale " \
896
- "(owner #{lock_data && lock_data['owner_session']}); run /plastic-doctor " \
897
- "reclaim the lock to take it over with an audit"
916
+ "(owner #{lock_data && lock_data['owner_session']}); run " \
917
+ "#{skill_ref('plastic-doctor', harness: harness)} reclaim the lock to take it " \
918
+ "over with an audit"
898
919
  when :excluded
899
920
  raise LockHeldError, "a #{lock_data && lock_data['type']} lock is active on " \
900
- "intent #{intent_id}; run /plastic-doctor check the lock status"
921
+ "intent #{intent_id}; run #{skill_ref('plastic-doctor', harness: harness)} check " \
922
+ "the lock status"
901
923
  when :corrupt
902
924
  raise LockHeldError, "delivery.lock for intent #{intent_id} is unreadable; " \
903
- "run /plastic-doctor fix the lock"
925
+ "run #{skill_ref('plastic-doctor', harness: harness)} fix the lock"
904
926
  end
905
927
 
906
928
  # Provision the per-intent worktrees (mandatory code worktree for project
@@ -920,15 +942,19 @@ module Bridge
920
942
 
921
943
  # Arm auto mode for a session+intent. Works even when no bridge exists yet
922
944
  # (mid-session intent creation). Re-derives intent state, then sets build.auto.
923
- def self.arm_auto(session, intent_id:, intent_dir:, store:, name:)
924
- arm(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name, auto: true)
945
+ def self.arm_auto(session, intent_id:, intent_dir:, store:, name:, harness: nil,
946
+ agent: nil, model: nil, thread: nil)
947
+ arm(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name,
948
+ auto: true, harness: harness, agent: agent, model: model, thread: thread)
925
949
  end
926
950
 
927
951
  # Acquire the delivery lock WITHOUT auto mode (intent 96 / Start guided branch).
928
952
  # Mirrors arm_auto's lock-stamp + worktree provision but leaves build.auto = false.
929
953
  # Same signature as arm_auto; disarm_auto (mode-agnostic) releases a guided lock.
930
- def self.arm_guided(session, intent_id:, intent_dir:, store:, name:)
931
- arm(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name, auto: false)
954
+ def self.arm_guided(session, intent_id:, intent_dir:, store:, name:, harness: nil,
955
+ agent: nil, model: nil, thread: nil)
956
+ arm(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name,
957
+ auto: false, harness: harness, agent: agent, model: model, thread: thread)
932
958
  end
933
959
 
934
960
  # Degrade path for disarm_auto when no intent_id is given (intent 131): the
@@ -992,10 +1018,20 @@ module Bridge
992
1018
  # reclaim verb (Lock.takeover). Two entry points call this: the
993
1019
  # plastic-lock CLI and /plastic-intent-starting (self-healing boarding).
994
1020
  def self.repair_lock(session, intent_id:, intent_dir:, store:, name:,
995
- now: Time.now, tmp: tmp_dir)
1021
+ now: Time.now, tmp: tmp_dir, harness: nil,
1022
+ agent: nil, model: nil, thread: nil, run_mode: nil,
1023
+ hint_harness: nil)
996
1024
  key = resolve_session(session, intent_id: intent_id, store: store)
997
1025
  dir = File.expand_path(intent_dir)
998
1026
  actions = []
1027
+ previous = read(key, intent_id: intent_id, tmp: tmp)
1028
+ auto = !!(previous && previous.dig("build", "auto"))
1029
+ derived_mode = if previous && previous.dig("build").is_a?(Hash) &&
1030
+ previous["build"].key?("auto")
1031
+ auto ? "auto" : "guided"
1032
+ end
1033
+ identity = { harness: harness, agent: agent, model: model, thread: thread,
1034
+ run_mode: blank?(run_mode) ? derived_mode : run_mode.to_s }
999
1035
 
1000
1036
  if Lock.corrupt?(dir)
1001
1037
  File.delete(Lock.path(dir))
@@ -1010,21 +1046,31 @@ module Bridge
1010
1046
  end
1011
1047
  return { "status" => "stale", "owner" => lock["owner_session"],
1012
1048
  "actions" => actions, "session" => key,
1013
- "hint" => "run /plastic-doctor reclaim the lock to take over with an audit" }
1049
+ "hint" => "run #{skill_ref('plastic-doctor', harness: hint_harness || harness)} reclaim the " \
1050
+ "lock to take over with an audit" }
1014
1051
  end
1015
1052
 
1016
1053
  if lock
1017
- Lock.heartbeat(dir, session: key, now: now)
1018
- lock_data = Lock.read(dir)
1054
+ if lock["owner_session"].to_s == key.to_s
1055
+ lock_data = lock.dup
1056
+ { "owner_harness" => harness, "owner_agent" => agent,
1057
+ "owner_model" => model, "owner_thread" => thread,
1058
+ "run_mode" => identity[:run_mode] }.each do |field, value|
1059
+ lock_data[field] = value.to_s unless blank?(value)
1060
+ end
1061
+ Lock.write(dir, lock_data)
1062
+ Lock.heartbeat(dir, session: key, now: now)
1063
+ else
1064
+ Lock.heartbeat(dir, session: key, now: now)
1065
+ lock_data = Lock.read(dir)
1066
+ end
1019
1067
  role = lock_data["owner_session"].to_s == key ? "owner" : "delegate"
1020
1068
  actions << "lock kept (#{role})"
1021
1069
  else
1022
- status, lock_data = Lock.acquire(dir, session: key, now: now)
1070
+ status, lock_data = Lock.acquire(dir, session: key, now: now, **identity)
1023
1071
  actions << "lock #{status}"
1024
1072
  end
1025
1073
 
1026
- previous = read(key, intent_id: intent_id, tmp: tmp)
1027
- auto = !!(previous && previous.dig("build", "auto"))
1028
1074
  data = derive(key, intent_id: intent_id, intent_dir: dir, store: store,
1029
1075
  name: name, tmp: tmp)
1030
1076
  data["build"]["auto"] = auto
@@ -1138,7 +1184,8 @@ module Bridge
1138
1184
  # target's lock names as owner or delegate (even when stale: a stale lock is
1139
1185
  # still its owner's until an explicit takeover).
1140
1186
  def self.lock_gate_decision(bridge_data, file_path, session: nil,
1141
- ttl: Lock::TTL_SECONDS, now: Time.now, home: Dir.home)
1187
+ ttl: Lock::TTL_SECONDS, now: Time.now, home: Dir.home,
1188
+ harness: :claude)
1142
1189
  return nil if blank?(file_path)
1143
1190
 
1144
1191
  target_dir = intent_dir_for(file_path)
@@ -1166,20 +1213,23 @@ module Bridge
1166
1213
  "#{lock['owner_session']}. Back off; if you are the owner's " \
1167
1214
  "subagent, the owner must run: plastic-lock delegate " \
1168
1215
  "--intent-dir #{target_dir} --session <your-session-id>. " \
1169
- "Inspect with /plastic-doctor check the lock status"
1216
+ "Inspect with #{skill_ref('plastic-doctor', harness: harness)} check the " \
1217
+ "lock status"
1170
1218
  end
1171
1219
  return solo_allow(id, "stale delivery lock") if solo
1172
1220
  return "intent #{id} has a stale delivery lock (owner " \
1173
- "#{lock['owner_session']}); run /plastic-doctor reclaim the lock to " \
1174
- "take it over, or /plastic-doctor fix the lock"
1221
+ "#{lock['owner_session']}); run #{skill_ref('plastic-doctor', harness: harness)} " \
1222
+ "reclaim the lock to take it over, or " \
1223
+ "#{skill_ref('plastic-doctor', harness: harness)} fix the lock"
1175
1224
  end
1176
1225
  if Lock.corrupt?(target_dir)
1177
1226
  return solo_allow(id, "unreadable delivery.lock") if solo
1178
- return "delivery.lock for intent #{id} is unreadable; run /plastic-doctor fix the lock"
1227
+ return "delivery.lock for intent #{id} is unreadable; run " \
1228
+ "#{skill_ref('plastic-doctor', harness: harness)} fix the lock"
1179
1229
  end
1180
1230
  return solo_allow(id, "no delivery lock") if solo
1181
- "no delivery lock held for intent #{id}; run /plastic-intent-starting " \
1182
- "to lock and begin"
1231
+ "no delivery lock held for intent #{id}; run " \
1232
+ "#{skill_ref('plastic-intent-starting', harness: harness)} to lock and begin"
1183
1233
  end
1184
1234
 
1185
1235
  # A session holds an intent's lock iff the durable delivery.lock in the
@@ -88,6 +88,26 @@ module HookRegistry
88
88
  CODEX_PRE_HOOKS = %w[code-gate lock-gate savepoint-pre links-gate create-gate].freeze
89
89
  CODEX_POST_HOOKS = %w[gate-check].freeze
90
90
 
91
+ # Codex's shell-tool gate hole (intent 203): bash-gate (denies a shell write to
92
+ # project code before How) and retrieval-gate (advisory, never denies) both
93
+ # belong on the Bash matcher, and ONLY Bash: the official Codex hooks doc's
94
+ # PreToolUse event catalog enumerates exactly Bash, apply_patch, and MCP tool
95
+ # calls, and neither it nor the two prior Codex research passes (198's
96
+ # official-docs research, 181's deep research) documents a discrete Read,
97
+ # Grep, or Glob tool name (D3). So this does NOT copy Claude's four-name
98
+ # "Bash|Read|Grep|Glob" retrieval-gate matcher; registering a tool name Codex
99
+ # never reports would be dead weight that looks alive, the exact defect this
100
+ # intent exists to fix.
101
+ CODEX_BASH_HOOKS = %w[bash-gate retrieval-gate].freeze
102
+
103
+ # Live-state events registered WHOLE (intent 199), unlike CODEX_PRE_HOOKS/
104
+ # CODEX_POST_HOOKS above: Codex's SessionStart/UserPromptSubmit/PreCompact already
105
+ # match Claude's shape exactly, one matcher group each ("", no tool to collapse
106
+ # onto), so every hook `events` lists under these three events projects straight
107
+ # through with no allowlist to keep in sync. A hook added to any of them on the
108
+ # Claude side registers for Codex automatically.
109
+ CODEX_LIVE_STATE_EVENTS = %w[SessionStart UserPromptSubmit PreCompact].freeze
110
+
91
111
  def codex_hooks_json(dispatcher_path:)
92
112
  # name => statusMessage, straight from the single `events` source (A8): the
93
113
  # guide Part 3 hooks.json format carries a per-hook statusMessage, so emit it.
@@ -101,11 +121,21 @@ module HookRegistry
101
121
  # Preserve the order these hook names appear across the PreToolUse groups in `events`.
102
122
  pre_order = events["PreToolUse"].flat_map { |g| g["hooks"].map { |h| h["name"] } }
103
123
  pre = (pre_order & CODEX_PRE_HOOKS).map { |n| cmd.call(n) }
124
+ bash = (pre_order & CODEX_BASH_HOOKS).map { |n| cmd.call(n) }
104
125
  post = CODEX_POST_HOOKS.map { |n| cmd.call(n) }
105
- {
106
- "PreToolUse" => [{ "matcher" => "apply_patch", "hooks" => pre }],
126
+
127
+ result = {
128
+ "PreToolUse" => [
129
+ { "matcher" => "apply_patch", "hooks" => pre },
130
+ { "matcher" => "Bash", "hooks" => bash },
131
+ ],
107
132
  "PostToolUse" => [{ "matcher" => "apply_patch", "hooks" => post }],
108
133
  }
134
+ CODEX_LIVE_STATE_EVENTS.each do |event|
135
+ names = events[event].flat_map { |g| g["hooks"].map { |h| h["name"] } }
136
+ result[event] = [{ "matcher" => "", "hooks" => names.map { |n| cmd.call(n) } }]
137
+ end
138
+ result
109
139
  end
110
140
 
111
141
  # The settings.json shape merge_claude_hooks expects: single-group events map