@zalom/plastic 1.4.0 → 1.5.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 (44) hide show
  1. package/PLASTIC-reference.md +2 -0
  2. package/PLASTIC.md +160 -42
  3. package/agents/plastic-intent-curator.md +10 -2
  4. package/package.json +1 -1
  5. package/scripts/codex-hook +122 -8
  6. package/scripts/dashboard.rb +323 -71
  7. package/scripts/doctor.rb +271 -15
  8. package/scripts/end-intent +32 -7
  9. package/scripts/hook-lock-gate +8 -3
  10. package/scripts/install.rb +51 -6
  11. package/scripts/lib/bridge.rb +79 -30
  12. package/scripts/lib/hook_registry.rb +44 -2
  13. package/scripts/lib/installer_core.rb +45 -6
  14. package/scripts/lib/lock.rb +186 -11
  15. package/scripts/lib/maintenance_git.rb +94 -0
  16. package/scripts/lib/revisions_writer.rb +69 -0
  17. package/scripts/lib/worktree.rb +14 -32
  18. package/scripts/lib/worktree_sweep.rb +129 -0
  19. package/scripts/maintenance-run +236 -0
  20. package/scripts/plastic-lock +76 -9
  21. package/scripts/project-links +127 -24
  22. package/scripts/rebuild-graph +37 -3
  23. package/scripts/restore-intent-v1 +37 -3
  24. package/scripts/sweep-store-worktrees +53 -0
  25. package/skills/auto/SKILL.md +29 -6
  26. package/skills/auto/references/agent-architecture.md +7 -0
  27. package/skills/auto/references/end-tail.md +8 -6
  28. package/skills/dashboard/SKILL.md +48 -25
  29. package/skills/dashboard/evals/evals.json +4 -4
  30. package/skills/dashboard/templates/dashboard-global.md +3 -5
  31. package/skills/dashboard/templates/dashboard-project.md +6 -18
  32. package/skills/doctor/SKILL.md +6 -0
  33. package/skills/intent-locking/SKILL.md +20 -2
  34. package/skills/intent-starting/SKILL.md +6 -4
  35. package/skills/project-continuing/SKILL.md +10 -0
  36. package/skills/project-continuing/evals/evals.json +3 -3
  37. package/skills/project-continuing/references/board-fill.md +13 -11
  38. package/skills/releasing/SKILL.md +3 -3
  39. package/skills/store-curating/SKILL.md +9 -0
  40. package/skills/store-curating/evals/evals.json +16 -0
  41. package/skills/tutorial/SKILL.md +4 -4
  42. package/skills/tutorial/references/track-1-guided.md +2 -1
  43. package/skills/tutorial/references/track-2-auto.md +2 -1
  44. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -1
@@ -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
 
@@ -782,13 +798,12 @@ module Bridge
782
798
  "warning_at" => 80,
783
799
  "critical_at" => 90
784
800
  },
785
- # Worktree isolation block (intent 73c). Born unprovisioned; arm_auto calls
786
- # Worktree.provision to fill it. code/store are abs paths or null.
801
+ # Worktree isolation block (intent 73c; store-worktree half retired by
802
+ # intent 178). Born unprovisioned; arm_auto calls Worktree.provision to
803
+ # fill it. "code" is an abs path or null.
787
804
  "worktree" => {
788
805
  "code" => nil,
789
806
  "code_branch" => nil,
790
- "store" => nil,
791
- "store_branch" => nil,
792
807
  "provisioned" => false
793
808
  },
794
809
  # Delivery-lock CACHE block (intent 108, D2). The durable truth is the
@@ -872,7 +887,8 @@ module Bridge
872
887
  # and arm_guided (auto: false) are thin delegators so the lock-stamp + provision
873
888
  # behaviour stays identical across both modes. Works even when no bridge exists
874
889
  # yet (mid-session intent creation).
875
- def self.arm(session, intent_id:, intent_dir:, store:, name:, auto:)
890
+ def self.arm(session, intent_id:, intent_dir:, store:, name:, auto:, harness: nil,
891
+ agent: nil, model: nil, thread: nil)
876
892
  key = resolve_session(session, intent_id: intent_id, store: store)
877
893
  if blank?(session) && blank?(ENV["CLAUDE_CODE_SESSION_ID"])
878
894
  $stderr.puts "plastic: no session id available; arming with derived bridge key #{key}"
@@ -883,24 +899,29 @@ module Bridge
883
899
  # Acquire the durable delivery lock (D1/D2): session-keyed, O_EXCL, in the
884
900
  # intent dir. The bridge lock block is a cache of the file.
885
901
  intent_dir_abs = File.expand_path(intent_dir)
886
- status, lock_data = Lock.acquire(intent_dir_abs, session: key)
902
+ status, lock_data = Lock.acquire(intent_dir_abs, session: key,
903
+ harness: harness, agent: agent,
904
+ model: model, thread: thread,
905
+ run_mode: auto ? "auto" : "guided")
887
906
  case status
888
907
  when :acquired, :owned
889
908
  data["lock"] = lock_cache(lock_data)
890
909
  when :held
891
910
  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"
911
+ "session #{lock_data && lock_data['owner_session']}; run " \
912
+ "#{skill_ref('plastic-doctor', harness: harness)} check the lock status"
894
913
  when :stale
895
914
  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"
915
+ "(owner #{lock_data && lock_data['owner_session']}); run " \
916
+ "#{skill_ref('plastic-doctor', harness: harness)} reclaim the lock to take it " \
917
+ "over with an audit"
898
918
  when :excluded
899
919
  raise LockHeldError, "a #{lock_data && lock_data['type']} lock is active on " \
900
- "intent #{intent_id}; run /plastic-doctor check the lock status"
920
+ "intent #{intent_id}; run #{skill_ref('plastic-doctor', harness: harness)} check " \
921
+ "the lock status"
901
922
  when :corrupt
902
923
  raise LockHeldError, "delivery.lock for intent #{intent_id} is unreadable; " \
903
- "run /plastic-doctor fix the lock"
924
+ "run #{skill_ref('plastic-doctor', harness: harness)} fix the lock"
904
925
  end
905
926
 
906
927
  # Provision the per-intent worktrees (mandatory code worktree for project
@@ -920,15 +941,19 @@ module Bridge
920
941
 
921
942
  # Arm auto mode for a session+intent. Works even when no bridge exists yet
922
943
  # (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)
944
+ def self.arm_auto(session, intent_id:, intent_dir:, store:, name:, harness: nil,
945
+ agent: nil, model: nil, thread: nil)
946
+ arm(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name,
947
+ auto: true, harness: harness, agent: agent, model: model, thread: thread)
925
948
  end
926
949
 
927
950
  # Acquire the delivery lock WITHOUT auto mode (intent 96 / Start guided branch).
928
951
  # Mirrors arm_auto's lock-stamp + worktree provision but leaves build.auto = false.
929
952
  # 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)
953
+ def self.arm_guided(session, intent_id:, intent_dir:, store:, name:, harness: nil,
954
+ agent: nil, model: nil, thread: nil)
955
+ arm(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name,
956
+ auto: false, harness: harness, agent: agent, model: model, thread: thread)
932
957
  end
933
958
 
934
959
  # Degrade path for disarm_auto when no intent_id is given (intent 131): the
@@ -992,10 +1017,20 @@ module Bridge
992
1017
  # reclaim verb (Lock.takeover). Two entry points call this: the
993
1018
  # plastic-lock CLI and /plastic-intent-starting (self-healing boarding).
994
1019
  def self.repair_lock(session, intent_id:, intent_dir:, store:, name:,
995
- now: Time.now, tmp: tmp_dir)
1020
+ now: Time.now, tmp: tmp_dir, harness: nil,
1021
+ agent: nil, model: nil, thread: nil, run_mode: nil,
1022
+ hint_harness: nil)
996
1023
  key = resolve_session(session, intent_id: intent_id, store: store)
997
1024
  dir = File.expand_path(intent_dir)
998
1025
  actions = []
1026
+ previous = read(key, intent_id: intent_id, tmp: tmp)
1027
+ auto = !!(previous && previous.dig("build", "auto"))
1028
+ derived_mode = if previous && previous.dig("build").is_a?(Hash) &&
1029
+ previous["build"].key?("auto")
1030
+ auto ? "auto" : "guided"
1031
+ end
1032
+ identity = { harness: harness, agent: agent, model: model, thread: thread,
1033
+ run_mode: blank?(run_mode) ? derived_mode : run_mode.to_s }
999
1034
 
1000
1035
  if Lock.corrupt?(dir)
1001
1036
  File.delete(Lock.path(dir))
@@ -1010,21 +1045,31 @@ module Bridge
1010
1045
  end
1011
1046
  return { "status" => "stale", "owner" => lock["owner_session"],
1012
1047
  "actions" => actions, "session" => key,
1013
- "hint" => "run /plastic-doctor reclaim the lock to take over with an audit" }
1048
+ "hint" => "run #{skill_ref('plastic-doctor', harness: hint_harness || harness)} reclaim the " \
1049
+ "lock to take over with an audit" }
1014
1050
  end
1015
1051
 
1016
1052
  if lock
1017
- Lock.heartbeat(dir, session: key, now: now)
1018
- lock_data = Lock.read(dir)
1053
+ if lock["owner_session"].to_s == key.to_s
1054
+ lock_data = lock.dup
1055
+ { "owner_harness" => harness, "owner_agent" => agent,
1056
+ "owner_model" => model, "owner_thread" => thread,
1057
+ "run_mode" => identity[:run_mode] }.each do |field, value|
1058
+ lock_data[field] = value.to_s unless blank?(value)
1059
+ end
1060
+ Lock.write(dir, lock_data)
1061
+ Lock.heartbeat(dir, session: key, now: now)
1062
+ else
1063
+ Lock.heartbeat(dir, session: key, now: now)
1064
+ lock_data = Lock.read(dir)
1065
+ end
1019
1066
  role = lock_data["owner_session"].to_s == key ? "owner" : "delegate"
1020
1067
  actions << "lock kept (#{role})"
1021
1068
  else
1022
- status, lock_data = Lock.acquire(dir, session: key, now: now)
1069
+ status, lock_data = Lock.acquire(dir, session: key, now: now, **identity)
1023
1070
  actions << "lock #{status}"
1024
1071
  end
1025
1072
 
1026
- previous = read(key, intent_id: intent_id, tmp: tmp)
1027
- auto = !!(previous && previous.dig("build", "auto"))
1028
1073
  data = derive(key, intent_id: intent_id, intent_dir: dir, store: store,
1029
1074
  name: name, tmp: tmp)
1030
1075
  data["build"]["auto"] = auto
@@ -1138,7 +1183,8 @@ module Bridge
1138
1183
  # target's lock names as owner or delegate (even when stale: a stale lock is
1139
1184
  # still its owner's until an explicit takeover).
1140
1185
  def self.lock_gate_decision(bridge_data, file_path, session: nil,
1141
- ttl: Lock::TTL_SECONDS, now: Time.now, home: Dir.home)
1186
+ ttl: Lock::TTL_SECONDS, now: Time.now, home: Dir.home,
1187
+ harness: :claude)
1142
1188
  return nil if blank?(file_path)
1143
1189
 
1144
1190
  target_dir = intent_dir_for(file_path)
@@ -1166,20 +1212,23 @@ module Bridge
1166
1212
  "#{lock['owner_session']}. Back off; if you are the owner's " \
1167
1213
  "subagent, the owner must run: plastic-lock delegate " \
1168
1214
  "--intent-dir #{target_dir} --session <your-session-id>. " \
1169
- "Inspect with /plastic-doctor check the lock status"
1215
+ "Inspect with #{skill_ref('plastic-doctor', harness: harness)} check the " \
1216
+ "lock status"
1170
1217
  end
1171
1218
  return solo_allow(id, "stale delivery lock") if solo
1172
1219
  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"
1220
+ "#{lock['owner_session']}); run #{skill_ref('plastic-doctor', harness: harness)} " \
1221
+ "reclaim the lock to take it over, or " \
1222
+ "#{skill_ref('plastic-doctor', harness: harness)} fix the lock"
1175
1223
  end
1176
1224
  if Lock.corrupt?(target_dir)
1177
1225
  return solo_allow(id, "unreadable delivery.lock") if solo
1178
- return "delivery.lock for intent #{id} is unreadable; run /plastic-doctor fix the lock"
1226
+ return "delivery.lock for intent #{id} is unreadable; run " \
1227
+ "#{skill_ref('plastic-doctor', harness: harness)} fix the lock"
1179
1228
  end
1180
1229
  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"
1230
+ "no delivery lock held for intent #{id}; run " \
1231
+ "#{skill_ref('plastic-intent-starting', harness: harness)} to lock and begin"
1183
1232
  end
1184
1233
 
1185
1234
  # 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,33 @@ 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
139
+ end
140
+
141
+ # Flattened, deduplicated Claude launcher names for every hook `events`
142
+ # registers (intent 204): each hook name maps to a hooks/<name> launcher
143
+ # installed as ~/.claude/hooks/plastic-<name>. The single derivation doctor's
144
+ # hooks_exist/hooks_executable/hooks_no_orphans checks read from, so a
145
+ # hand-kept list of launchers can never drift out of step with `events`
146
+ # again (the gap that let 8 of 15 launchers, all the enforcement gates, go
147
+ # unchecked).
148
+ def claude_launcher_names
149
+ events.values.flatten.flat_map { |g| g["hooks"].map { |h| h["name"] } }
150
+ .uniq.sort.map { |name| "plastic-#{name}" }
109
151
  end
110
152
 
111
153
  # The settings.json shape merge_claude_hooks expects: single-group events map
@@ -17,9 +17,10 @@ class InstallerCore
17
17
  DEFAULT_PLASTIC_HOME = File.join(Dir.home, ".plastic")
18
18
 
19
19
  DEFAULT_AGENTS = [
20
- { key: "claude", name: "Claude Code", dir: File.join(Dir.home, ".claude"), flag: "--claude" },
20
+ { key: "claude", name: "Claude Code", dir: File.join(Dir.home, ".claude"), flag: "--claude",
21
+ skill_prefix: "/" },
21
22
  { key: "codex", name: "Codex CLI", dir: File.join(Dir.home, ".agents"),
22
- home_dir: File.join(Dir.home, ".codex"), flag: "--codex" },
23
+ home_dir: File.join(Dir.home, ".codex"), flag: "--codex", skill_prefix: "$" },
23
24
  { key: "hermes", name: "Hermes", dir: File.join(Dir.home, ".hermes"), flag: "--hermes" },
24
25
  ].freeze
25
26
 
@@ -43,7 +44,8 @@ class InstallerCore
43
44
  - The full conventions live in ~/.plastic/PLASTIC.md. Read it and follow it exactly.
44
45
  It is generated and overwritten on Plastic updates, so never edit it.
45
46
  - Operational procedures are installed as skills under ~/.agents/skills/ (each
46
- plastic-<name>/SKILL.md). Use them for the lifecycle work they describe.
47
+ plastic-<name>/SKILL.md). Invoke one explicitly as $plastic-<name> (for example
48
+ $plastic-doctor), or let Codex pick one implicitly by matching its description.
47
49
  - Intents, specs, plans, checklists, and outcomes live under ~/.plastic/, never in
48
50
  the project tree.
49
51
 
@@ -175,7 +177,7 @@ class InstallerCore
175
177
  return ["claude"] unless input.tty?
176
178
 
177
179
  puts "Which agents should Plastic register for?\n\n"
178
- agents.each_with_index { |a, i| puts " #{i + 1}. #{a[:name]} (#{a[:dir]})" }
180
+ agents.each_with_index { |a, i| puts " #{i + 1}. #{a[:name]} (#{a[:home_dir] || a[:dir]})" }
179
181
  puts " #{agents.size + 1}. All"
180
182
  puts
181
183
 
@@ -328,6 +330,11 @@ class InstallerCore
328
330
  "scripts/rebuild-graph" => "scripts/rebuild-graph",
329
331
  "scripts/lib/restore_intent_v1.rb" => "scripts/lib/restore_intent_v1.rb",
330
332
  "scripts/restore-intent-v1" => "scripts/restore-intent-v1",
333
+ "scripts/lib/revisions_writer.rb" => "scripts/lib/revisions_writer.rb",
334
+ "scripts/maintenance-run" => "scripts/maintenance-run",
335
+ "scripts/lib/maintenance_git.rb" => "scripts/lib/maintenance_git.rb",
336
+ "scripts/lib/worktree_sweep.rb" => "scripts/lib/worktree_sweep.rb",
337
+ "scripts/sweep-store-worktrees" => "scripts/sweep-store-worktrees",
331
338
  "scripts/validate-intent" => "scripts/validate-intent",
332
339
  "scripts/new-intent" => "scripts/new-intent",
333
340
  "scripts/end-intent" => "scripts/end-intent",
@@ -420,12 +427,44 @@ class InstallerCore
420
427
  (data["files"] || {}).keys
421
428
  end
422
429
 
430
+ # Per-agent registration probe (intent 198, D7 follow-up). `installed?` in
431
+ # install.rb only answers "is Plastic core installed at all", which cannot
432
+ # tell two harnesses apart: once core is present, install.rb's old gate
433
+ # refused to add ANY new harness, even one that had never been touched. This
434
+ # asks the narrower, correct question, "has Plastic already registered
435
+ # files for THIS agent", using the signal already tracked for prune-on-update:
436
+ # the per-agent manifest (manifest_path_for). A missing manifest file, or a
437
+ # manifest whose "files" list is empty (write_manifest still writes one when
438
+ # nothing was installed), both mean nothing is registered for this agent yet.
439
+ # An unknown key is never "installed" (fail toward proceeding, since a caller
440
+ # that already validated the key gets its own "Unknown agent" result from
441
+ # install_for_agent).
442
+ def agent_installed?(key)
443
+ config = agent_config(key)
444
+ return false unless config
445
+ !manifest_files(manifest_path_for(key, config)).empty?
446
+ end
447
+
423
448
  def install_for_agent(key, force, argv: [], input: $stdin, reinstall: false)
424
449
  config = agent_config(key)
425
450
  return { agent: config[:name], success: false, reason: "Unknown agent" } unless config
426
451
 
427
- unless File.directory?(config[:dir])
428
- return { agent: config[:name], success: false, reason: "#{config[:dir]} not found \u{2014} #{config[:name]} not installed?" }
452
+ # Presence probe (intent 198, Decision D1): an agent that declares its own
453
+ # home directory (Codex, home_dir: ~/.codex) is checked THERE, because
454
+ # config[:dir] (~/.agents) is the shared cross-tool skills root, not
455
+ # anything Codex itself creates. A fresh Codex install has no ~/.agents
456
+ # yet, so testing config[:dir] aborted a genuinely-present Codex. Claude
457
+ # and Hermes declare no home_dir, so presence_dir resolves to config[:dir]
458
+ # exactly as before and their behavior is unchanged. The failure message
459
+ # reuses the same resolved directory, so it always names the directory
460
+ # actually tested. install_codex still needs config[:dir] to exist by the
461
+ # time it writes skills; install_skills_flat and generate_codex_agents
462
+ # already FileUtils.mkdir_p their own nested paths under config[:dir] and
463
+ # config[:home_dir], so a fresh install creates it as a side effect (no
464
+ # separate top-level mkdir_p is required here).
465
+ presence_dir = config[:home_dir] || config[:dir]
466
+ unless File.directory?(presence_dir)
467
+ return { agent: config[:name], success: false, reason: "#{presence_dir} not found, #{config[:name]} not installed?" }
429
468
  end
430
469
 
431
470
  # Capture the prior manifest so we can prune files that no longer ship
@@ -24,7 +24,30 @@ require "time"
24
24
  module Lock
25
25
  module_function
26
26
 
27
+ # Skill-invocation prefix per harness (intent 201, D2/D3). Claude Code invokes a
28
+ # skill with a slash (/plastic-doctor); Codex CLI invokes explicitly with a
29
+ # dollar ($plastic-doctor) and may also select one implicitly by matching the
30
+ # skill's description. This table is the actual source of truth for
31
+ # Bridge.skill_ref (bridge.rb requires lock.rb, never the reverse, so the
32
+ # table lives here rather than pulling Bridge into this dependency-free file
33
+ # just to render two characters). InstallerCore::DEFAULT_AGENTS carries the
34
+ # same values per adapter as documented config (see ACTION_2); this constant
35
+ # is not read from it at runtime, by the same reasoning bridge.rb/hook-*
36
+ # already stay clear of installer_core.rb (spec Alternatives Considered).
37
+ SKILL_PREFIXES = { "claude" => "/", "codex" => "$" }.freeze
38
+
39
+ # Renders a skill reference for the given harness. Unset or unrecognized
40
+ # harness falls back to Claude's slash form, so an existing call site that
41
+ # never passes harness: keeps behaving exactly as it does today (D2). name
42
+ # is the bare skill name ("plastic-doctor"), never pre-prefixed.
43
+ def self.skill_ref(name, harness: :claude)
44
+ prefix = SKILL_PREFIXES.fetch(harness.to_s, SKILL_PREFIXES["claude"])
45
+ "#{prefix}#{name}"
46
+ end
47
+
27
48
  TYPES = %w[delivery maintenance].freeze
49
+ DELEGATE_ACTIVITY_LIMIT = 20
50
+ DELEGATE_STATUSES = %w[active finished failed].freeze
28
51
 
29
52
  # Lease TTL. Heartbeats fire from the write-path hooks (PostToolUse
30
53
  # gate-check and the lock-gate allow path), so a delivering session
@@ -84,7 +107,8 @@ module Lock
84
107
  # [:excluded, other] the OTHER lock type is fresh (D3)
85
108
  # [:corrupt, nil] unparseable lock file: run repair
86
109
  def acquire(intent_dir, session:, type: "delivery", host: Socket.gethostname,
87
- ttl: TTL_SECONDS, now: Time.now)
110
+ ttl: TTL_SECONDS, now: Time.now, harness: nil, agent: nil,
111
+ model: nil, thread: nil, run_mode: nil)
88
112
  raise ArgumentError, "unknown lock type #{type.inspect}" unless TYPES.include?(type)
89
113
  raise ArgumentError, "lock session must be present" if blank?(session)
90
114
 
@@ -99,7 +123,13 @@ module Lock
99
123
  if existing
100
124
  if existing["owner_session"].to_s == session.to_s
101
125
  data = payload(session: session, type: type, host: host, now: now,
102
- delegates: Array(existing["delegates"]))
126
+ delegates: Array(existing["delegates"]),
127
+ delegate_activity: Array(existing["delegate_activity"]),
128
+ harness: merged_value(harness, existing["owner_harness"]),
129
+ agent: merged_value(agent, existing["owner_agent"]),
130
+ model: merged_value(model, existing["owner_model"]),
131
+ thread: merged_value(thread, existing["owner_thread"]),
132
+ run_mode: merged_value(run_mode, existing["run_mode"]))
103
133
  write(intent_dir, data, type: type)
104
134
  return [:owned, data]
105
135
  end
@@ -107,7 +137,9 @@ module Lock
107
137
  return [:stale, existing]
108
138
  end
109
139
 
110
- data = payload(session: session, type: type, host: host, now: now)
140
+ data = payload(session: session, type: type, host: host, now: now,
141
+ harness: harness, agent: agent, model: model, thread: thread,
142
+ run_mode: run_mode)
111
143
  File.open(path(intent_dir, type: type),
112
144
  File::WRONLY | File::CREAT | File::EXCL) do |io|
113
145
  io.write(JSON.pretty_generate(data))
@@ -117,16 +149,49 @@ module Lock
117
149
  [:held, read(intent_dir, type: type)] # lost the O_EXCL race
118
150
  end
119
151
 
120
- def payload(session:, type:, host:, now:, delegates: [])
152
+ def payload(session:, type:, host:, now:, delegates: [], delegate_activity: [],
153
+ harness: nil, agent: nil, model: nil, thread: nil, run_mode: nil)
121
154
  {
122
155
  "type" => type,
123
156
  "owner_session" => session.to_s,
124
157
  "host" => host,
125
158
  "acquired_at" => now.utc.iso8601,
126
159
  "delegates" => delegates,
160
+ "owner_harness" => normalized_value(harness),
161
+ "owner_agent" => normalized_value(agent),
162
+ "owner_model" => normalized_value(model),
163
+ "owner_thread" => normalized_value(thread),
164
+ "run_mode" => normalized_value(run_mode),
165
+ "delegate_activity" => bounded_delegate_activity(delegate_activity, delegates: delegates),
127
166
  }
128
167
  end
129
168
 
169
+ def normalized_value(value)
170
+ blank?(value) ? nil : value.to_s
171
+ end
172
+
173
+ def merged_value(explicit, existing)
174
+ blank?(explicit) ? normalized_value(existing) : explicit.to_s
175
+ end
176
+
177
+ # Keep every active record that still names an authorized delegate, while
178
+ # bounding completed history. Active work is current truth and must never be
179
+ # evicted merely because newer delegates finished.
180
+ def bounded_delegate_activity(records, delegates:)
181
+ records = Array(records)
182
+ authorized = Array(delegates).map(&:to_s)
183
+ terminal_indexes = records.each_index.reject do |index|
184
+ record = records[index]
185
+ record.is_a?(Hash) && record["status"].to_s == "active" &&
186
+ authorized.include?(record["session"].to_s)
187
+ end.last(DELEGATE_ACTIVITY_LIMIT)
188
+ records.each_with_index.filter_map do |record, index|
189
+ active = record.is_a?(Hash) && record["status"].to_s == "active" &&
190
+ authorized.include?(record["session"].to_s)
191
+ record if active || terminal_indexes.include?(index)
192
+ end
193
+ end
194
+
130
195
  # Owner/delegate heartbeat: touch the mtime, never rewrite content.
131
196
  def heartbeat(intent_dir, session:, type: "delivery", now: Time.now)
132
197
  return false unless holds?(intent_dir, session: session, type: type)
@@ -136,11 +201,49 @@ module Lock
136
201
 
137
202
  # Owner registers a delegate (D4): a session allowed to write under this
138
203
  # lock. Only the OWNER may delegate; delegates cannot re-delegate.
139
- def add_delegate(intent_dir, delegate:, session:, type: "delivery")
204
+ def add_delegate(intent_dir, delegate:, session:, type: "delivery", now: Time.now,
205
+ harness: nil, agent: nil, model: nil, thread: nil)
140
206
  data = read(intent_dir, type: type)
141
207
  return false if blank?(delegate)
142
208
  return false unless data && data["owner_session"].to_s == session.to_s
143
209
  data["delegates"] = (Array(data["delegates"]) + [delegate.to_s]).uniq
210
+ activity = Array(data["delegate_activity"])
211
+ previous = activity.find { |record| record.is_a?(Hash) && record["session"].to_s == delegate.to_s }
212
+ activity.reject! { |record| record.is_a?(Hash) && record["session"].to_s == delegate.to_s }
213
+ record = {
214
+ "session" => delegate.to_s,
215
+ "status" => "active",
216
+ "registered_at" => now.utc.iso8601,
217
+ "last_activity_at" => now.utc.iso8601,
218
+ "harness" => merged_value(harness, previous && previous["harness"]),
219
+ "agent" => merged_value(agent, previous && previous["agent"]),
220
+ "model" => merged_value(model, previous && previous["model"]),
221
+ "thread" => merged_value(thread, previous && previous["thread"]),
222
+ }
223
+ data["delegate_activity"] = bounded_delegate_activity(activity + [record],
224
+ delegates: data["delegates"])
225
+ write(intent_dir, data, type: type)
226
+ true
227
+ end
228
+
229
+ # Activity metadata is observational only. Finishing or failing a delegate
230
+ # never removes its string session id from the authorization list.
231
+ def update_delegate_status(intent_dir, delegate:, status:, session:, type: "delivery",
232
+ now: Time.now)
233
+ return false unless (DELEGATE_STATUSES - ["active"]).include?(status.to_s)
234
+ data = read(intent_dir, type: type)
235
+ return false unless data && data["owner_session"].to_s == session.to_s
236
+ activity = Array(data["delegate_activity"])
237
+ index = activity.index do |record|
238
+ record.is_a?(Hash) && record["session"].to_s == delegate.to_s
239
+ end
240
+ return false unless index
241
+ activity[index] = activity[index].merge(
242
+ "status" => status.to_s,
243
+ "last_activity_at" => now.utc.iso8601
244
+ )
245
+ data["delegate_activity"] = bounded_delegate_activity(activity,
246
+ delegates: data["delegates"])
144
247
  write(intent_dir, data, type: type)
145
248
  true
146
249
  end
@@ -164,7 +267,8 @@ module Lock
164
267
  # lock; there is no silent reclaim path anywhere else.
165
268
  # Returns [:taken, data], [:fresh, existing], or acquire's error statuses.
166
269
  def takeover(intent_dir, session:, type: "delivery", host: Socket.gethostname,
167
- ttl: TTL_SECONDS, now: Time.now)
270
+ ttl: TTL_SECONDS, now: Time.now, harness: nil, agent: nil,
271
+ model: nil, thread: nil, run_mode: nil)
168
272
  existing = read(intent_dir, type: type)
169
273
  if existing && !authorized?(existing, session) &&
170
274
  fresh?(intent_dir, type: type, ttl: ttl, now: now)
@@ -175,7 +279,8 @@ module Lock
175
279
  p = path(intent_dir, type: type)
176
280
  File.delete(p) if File.exist?(p)
177
281
  status, data = acquire(intent_dir, session: session, type: type, host: host,
178
- ttl: ttl, now: now)
282
+ ttl: ttl, now: now, harness: harness, agent: agent,
283
+ model: model, thread: thread, run_mode: run_mode)
179
284
  return [status, data] unless status == :acquired
180
285
 
181
286
  audit = "#{now.utc.iso8601} Lock takeover: #{session} reclaimed #{type} " \
@@ -190,6 +295,75 @@ module Lock
190
295
  def write(intent_dir, data, type: "delivery")
191
296
  File.write(path(intent_dir, type: type), JSON.pretty_generate(data))
192
297
  end
298
+
299
+ # Read-only normalized inspection. The lock file and its mtime remain the
300
+ # sole sources of owner and heartbeat truth; no environment or transcript
301
+ # inference belongs here.
302
+ def who(intent_dir, ttl: TTL_SECONDS, now: Time.now)
303
+ p = path(intent_dir)
304
+ unless File.exist?(p)
305
+ return { "state" => "none",
306
+ "claims" => Claim.claims_status(intent_dir, ttl: ttl, now: now) }
307
+ end
308
+ data = read(intent_dir)
309
+ unless data
310
+ return { "state" => "corrupt",
311
+ "claims" => Claim.claims_status(intent_dir, ttl: ttl, now: now) }
312
+ end
313
+
314
+ activity = Array(data["delegate_activity"])
315
+ activity_by_session = activity.each_with_object({}) do |record, memo|
316
+ memo[record["session"].to_s] = record if record.is_a?(Hash)
317
+ end
318
+ authorized_sessions = Array(data["delegates"]).map(&:to_s)
319
+ activity_sessions = activity.filter_map do |record|
320
+ record["session"].to_s if record.is_a?(Hash) &&
321
+ authorized_sessions.include?(record["session"].to_s)
322
+ end.uniq
323
+ activity_order = activity.each_with_index.each_with_object({}) do |(record, index), memo|
324
+ memo[record["session"].to_s] = index if record.is_a?(Hash)
325
+ end
326
+ activity_sessions.sort_by! do |session|
327
+ record = activity_by_session[session] || {}
328
+ # Active delegates are current ahead of terminal delegates. Within that
329
+ # group, latest activity wins; original record order is the stable
330
+ # fallback for legacy records without timestamps.
331
+ [record["status"].to_s == "active" ? 1 : 0,
332
+ record["last_activity_at"].to_s, activity_order.fetch(session, -1)]
333
+ end
334
+ # Legacy string-only delegates retain their authorization order. Rich
335
+ # records follow in deterministic current/latest order, so consumers may
336
+ # reliably take the last projection entry: the most recent active delegate
337
+ # when one exists, otherwise the most recent terminal activity.
338
+ ordered_sessions = (authorized_sessions - activity_sessions) + activity_sessions
339
+ delegates = ordered_sessions.map do |session|
340
+ record = activity_by_session[session] || {}
341
+ {
342
+ "session" => session,
343
+ "harness" => normalized_value(record["harness"]) || "unknown",
344
+ "agent" => normalized_value(record["agent"]) || "unknown",
345
+ "model" => normalized_value(record["model"]) || "unknown",
346
+ "thread" => normalized_value(record["thread"]) || "unknown",
347
+ "status" => normalized_value(record["status"]) || "unknown",
348
+ "registered_at" => record["registered_at"],
349
+ "last_activity_at" => record["last_activity_at"],
350
+ }
351
+ end
352
+ {
353
+ "state" => fresh?(intent_dir, ttl: ttl, now: now) ? "fresh" : "stale",
354
+ "owner" => {
355
+ "harness" => normalized_value(data["owner_harness"]) || "unknown",
356
+ "agent" => normalized_value(data["owner_agent"]) || "unknown",
357
+ "model" => normalized_value(data["owner_model"]) || "unknown",
358
+ "thread" => normalized_value(data["owner_thread"]) || "unknown",
359
+ "run_mode" => normalized_value(data["run_mode"]) || "unknown",
360
+ },
361
+ "owner_session" => data["owner_session"],
362
+ "heartbeat_at" => File.mtime(p).utc.iso8601,
363
+ "delegates" => delegates,
364
+ "claims" => Claim.claims_status(intent_dir, ttl: ttl, now: now),
365
+ }
366
+ end
193
367
  end
194
368
 
195
369
  # Claim: the per-artifact claim-token layer (intent 111, D1/D7). Sits BENEATH
@@ -359,7 +533,8 @@ module Claim
359
533
  # ENGAGES only when a claim file exists (dormant otherwise, so single-owner flows
360
534
  # and the existing suite stay green, AC7). Fails open on stale/corrupt via
361
535
  # fail_open?, the named contract.
362
- def claim_gate_reason(intent_dir, artifact, session:, ttl: Lock::TTL_SECONDS, now: Time.now)
536
+ def claim_gate_reason(intent_dir, artifact, session:, ttl: Lock::TTL_SECONDS, now: Time.now,
537
+ harness: :claude)
363
538
  return nil if Lock.blank?(artifact)
364
539
  return nil unless File.exist?(path(intent_dir, artifact)) # dormant: no claim
365
540
  return nil if holds_claim?(intent_dir, artifact, session: session) # you hold it
@@ -368,8 +543,8 @@ module Claim
368
543
  holder = data && data["owner_session"]
369
544
  since = data && data["acquired_at"]
370
545
  "artifact #{artifact} is claimed by #{holder} since #{since}; another writer holds " \
371
- "it. Back off or run /plastic-doctor check the lock status. If you are a distinct " \
372
- "delegate, the owner must register you: plastic-lock delegate --intent-dir " \
373
- "#{intent_dir} --session <your-session-id>"
546
+ "it. Back off or run #{Lock.skill_ref('plastic-doctor', harness: harness)} check the " \
547
+ "lock status. If you are a distinct delegate, the owner must register you: " \
548
+ "plastic-lock delegate --intent-dir #{intent_dir} --session <your-session-id>"
374
549
  end
375
550
  end