@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.
@@ -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
 
@@ -420,12 +422,44 @@ class InstallerCore
420
422
  (data["files"] || {}).keys
421
423
  end
422
424
 
425
+ # Per-agent registration probe (intent 198, D7 follow-up). `installed?` in
426
+ # install.rb only answers "is Plastic core installed at all", which cannot
427
+ # tell two harnesses apart: once core is present, install.rb's old gate
428
+ # refused to add ANY new harness, even one that had never been touched. This
429
+ # asks the narrower, correct question, "has Plastic already registered
430
+ # files for THIS agent", using the signal already tracked for prune-on-update:
431
+ # the per-agent manifest (manifest_path_for). A missing manifest file, or a
432
+ # manifest whose "files" list is empty (write_manifest still writes one when
433
+ # nothing was installed), both mean nothing is registered for this agent yet.
434
+ # An unknown key is never "installed" (fail toward proceeding, since a caller
435
+ # that already validated the key gets its own "Unknown agent" result from
436
+ # install_for_agent).
437
+ def agent_installed?(key)
438
+ config = agent_config(key)
439
+ return false unless config
440
+ !manifest_files(manifest_path_for(key, config)).empty?
441
+ end
442
+
423
443
  def install_for_agent(key, force, argv: [], input: $stdin, reinstall: false)
424
444
  config = agent_config(key)
425
445
  return { agent: config[:name], success: false, reason: "Unknown agent" } unless config
426
446
 
427
- unless File.directory?(config[:dir])
428
- return { agent: config[:name], success: false, reason: "#{config[:dir]} not found \u{2014} #{config[:name]} not installed?" }
447
+ # Presence probe (intent 198, Decision D1): an agent that declares its own
448
+ # home directory (Codex, home_dir: ~/.codex) is checked THERE, because
449
+ # config[:dir] (~/.agents) is the shared cross-tool skills root, not
450
+ # anything Codex itself creates. A fresh Codex install has no ~/.agents
451
+ # yet, so testing config[:dir] aborted a genuinely-present Codex. Claude
452
+ # and Hermes declare no home_dir, so presence_dir resolves to config[:dir]
453
+ # exactly as before and their behavior is unchanged. The failure message
454
+ # reuses the same resolved directory, so it always names the directory
455
+ # actually tested. install_codex still needs config[:dir] to exist by the
456
+ # time it writes skills; install_skills_flat and generate_codex_agents
457
+ # already FileUtils.mkdir_p their own nested paths under config[:dir] and
458
+ # config[:home_dir], so a fresh install creates it as a side effect (no
459
+ # separate top-level mkdir_p is required here).
460
+ presence_dir = config[:home_dir] || config[:dir]
461
+ unless File.directory?(presence_dir)
462
+ return { agent: config[:name], success: false, reason: "#{presence_dir} not found, #{config[:name]} not installed?" }
429
463
  end
430
464
 
431
465
  # 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
@@ -5,10 +5,11 @@
5
5
  # plastic-lock: inspect and repair the durable delivery lock (intent 108, D5),
6
6
  # and take/free per-artifact claim tokens (intent 111 D1/D5).
7
7
  #
8
- # Usage: plastic-lock <status|fix|release|reclaim|delegate|claim|release-claim>
8
+ # Usage: plastic-lock <status|who|fix|release|reclaim|delegate|claim|release-claim>
9
9
  # [--intent-dir DIR] [--session SID] [--delegate SID] [--artifact NAME]
10
10
  #
11
11
  # Verbs:
12
+ # who compact, read-only durable owner/activity view
12
13
  # status report the lock file, the bridge cache, their agreement,
13
14
  # and any live per-artifact claims
14
15
  # fix idempotent repair: rebuild lock + bridge from disk truth for
@@ -29,13 +30,15 @@ require_relative "lib/bridge"
29
30
  require_relative "lib/lock"
30
31
 
31
32
  def usage!
32
- warn "usage: plastic-lock <status|fix|release|reclaim|delegate|claim|release-claim> " \
33
- "[--intent-dir DIR] [--session SID] [--delegate SID] [--artifact NAME]"
33
+ warn "usage: plastic-lock <status|who|fix|release|reclaim|delegate|claim|release-claim> " \
34
+ "[--intent-dir DIR] [--session SID] [--delegate SID] [--artifact NAME] " \
35
+ "[--harness claude|codex] [--agent NAME] [--model MODEL] [--thread ID] " \
36
+ "[--mode auto|guided] [--status finished|failed]"
34
37
  exit 1
35
38
  end
36
39
 
37
40
  verb = ARGV.shift
38
- usage! unless %w[status fix release reclaim delegate claim release-claim].include?(verb)
41
+ usage! unless %w[status who fix release reclaim delegate claim release-claim].include?(verb)
39
42
 
40
43
  opts = {}
41
44
  until ARGV.empty?
@@ -44,17 +47,30 @@ until ARGV.empty?
44
47
  when "--session" then opts[:session] = ARGV.shift
45
48
  when "--delegate" then opts[:delegate] = ARGV.shift
46
49
  when "--artifact" then opts[:artifact] = ARGV.shift
50
+ when "--harness" then opts[:harness] = ARGV.shift
51
+ when "--agent" then opts[:agent] = ARGV.shift
52
+ when "--model" then opts[:model] = ARGV.shift
53
+ when "--thread" then opts[:thread] = ARGV.shift
54
+ when "--mode" then opts[:mode] = ARGV.shift
55
+ when "--status" then opts[:status] = ARGV.shift
47
56
  else
48
57
  warn "unknown flag #{flag}"
49
58
  usage!
50
59
  end
51
60
  end
61
+ usage! if opts[:mode] && !%w[auto guided].include?(opts[:mode])
52
62
 
53
63
  session = opts[:session]
54
64
  session = ENV["CLAUDE_CODE_SESSION_ID"] if session.nil? || session.strip.empty?
65
+ harness = opts[:harness]
66
+ hint_harness = harness || "claude"
55
67
 
56
68
  dir = opts[:dir]
57
69
  if dir.nil? || dir.strip.empty?
70
+ if verb == "who"
71
+ warn "plastic-lock: who is strictly durable-state only; pass --intent-dir <intent dir>"
72
+ exit 1
73
+ end
58
74
  bridge = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
59
75
  dir = Bridge.bridge_intent_dir(bridge)
60
76
  end
@@ -64,6 +80,37 @@ if dir.nil?
64
80
  end
65
81
  dir = File.expand_path(dir)
66
82
 
83
+ if verb == "who"
84
+ view = Lock.who(dir)
85
+ basename = File.basename(dir)
86
+ intent_id, slug = basename.split("--", 2)
87
+ display = ->(value) { value.nil? || value.to_s.strip.empty? || value == "unknown" ? "Unknown" : value.to_s }
88
+ harness = ->(value) {
89
+ shown = display.call(value)
90
+ shown == "Unknown" ? shown : shown.sub(/\A./) { |char| char.upcase }
91
+ }
92
+ owner = view["owner"] || {}
93
+ claims = Array(view["claims"]).select { |claim| claim["fresh"] && !claim["corrupt"] }
94
+ claim_text = claims.map do |claim|
95
+ writer = claim["delegate"] || claim["owner_session"]
96
+ "#{display.call(claim['artifact'])} by #{display.call(writer)}"
97
+ end
98
+ delegate = Array(view["delegates"]).last
99
+
100
+ puts "#{intent_id} · #{slug || basename}"
101
+ puts "State: #{view['state']}"
102
+ puts "Controller: #{display.call(owner['agent'])} via #{harness.call(owner['harness'])}"
103
+ puts "Session: #{display.call(view['owner_session'])}"
104
+ puts "Heartbeat: #{view['heartbeat_at'] || 'none'}"
105
+ puts "Claims: #{claim_text.empty? ? 'none' : claim_text.join(', ')}"
106
+ if delegate
107
+ puts "Delegate: #{display.call(delegate['agent'])} via #{harness.call(delegate['harness'])}, #{display.call(delegate['status'])}"
108
+ else
109
+ puts "Delegate: none"
110
+ end
111
+ exit 0
112
+ end
113
+
67
114
  intent_id = Bridge.intent_id_from_dir(dir)
68
115
  store = File.dirname(dir)
69
116
  name = File.basename(dir)
@@ -87,7 +134,10 @@ when "status"
87
134
  puts JSON.pretty_generate(report)
88
135
  when "fix"
89
136
  report = Bridge.repair_lock(key, intent_id: intent_id, intent_dir: dir,
90
- store: store, name: name)
137
+ store: store, name: name, harness: harness,
138
+ agent: opts[:agent], model: opts[:model], thread: opts[:thread],
139
+ run_mode: opts[:mode],
140
+ hint_harness: hint_harness)
91
141
  puts JSON.pretty_generate(report)
92
142
  unless report["status"] == "repaired"
93
143
  warn "plastic-lock: #{report['status']} by #{report['owner']}" \
@@ -108,23 +158,40 @@ when "release"
108
158
  end
109
159
  puts "released (#{result})"
110
160
  when "reclaim"
111
- status, lock_data = Lock.takeover(dir, session: key)
161
+ status, lock_data = Lock.takeover(dir, session: key, harness: harness,
162
+ agent: opts[:agent], model: opts[:model],
163
+ thread: opts[:thread], run_mode: opts[:mode])
112
164
  if status == :fresh
113
165
  warn "plastic-lock: lock is FRESH and held by #{lock_data['owner_session']}; " \
114
166
  "back off (no silent reclaim)"
115
167
  exit 1
116
168
  end
117
169
  report = Bridge.repair_lock(key, intent_id: intent_id, intent_dir: dir,
118
- store: store, name: name)
170
+ store: store, name: name, harness: harness,
171
+ agent: opts[:agent], model: opts[:model], thread: opts[:thread],
172
+ run_mode: opts[:mode],
173
+ hint_harness: hint_harness)
119
174
  puts JSON.pretty_generate(report)
120
175
  when "delegate"
121
176
  usage! if opts[:delegate].nil?
122
- ok = Lock.add_delegate(dir, delegate: opts[:delegate], session: key)
177
+ usage! if opts[:status] && !%w[finished failed].include?(opts[:status])
178
+ ok = if opts[:status]
179
+ Lock.update_delegate_status(dir, delegate: opts[:delegate],
180
+ status: opts[:status], session: key)
181
+ else
182
+ Lock.add_delegate(dir, delegate: opts[:delegate], session: key,
183
+ harness: harness, agent: opts[:agent], model: opts[:model],
184
+ thread: opts[:thread])
185
+ end
123
186
  unless ok
124
187
  warn "plastic-lock: only the lock owner may delegate; run plastic-lock status"
125
188
  exit 1
126
189
  end
127
- puts "delegated #{opts[:delegate]} under #{key}"
190
+ if opts[:status]
191
+ puts "delegate #{opts[:delegate]} marked #{opts[:status]} under #{key}"
192
+ else
193
+ puts "delegated #{opts[:delegate]} under #{key}"
194
+ end
128
195
  when "claim"
129
196
  usage! if opts[:artifact].nil?
130
197
  status, data = Claim.acquire_claim(dir, opts[:artifact], session: key,
@@ -93,13 +93,15 @@ edited before the plan exists (the gate applies to YOU, the orchestrator):
93
93
 
94
94
  ```bash
95
95
  ruby -r ~/.plastic/scripts/lib/bridge -e \
96
- 'Bridge.arm_auto(ENV["CLAUDE_CODE_SESSION_ID"], intent_id: "<ID>", intent_dir: "<STORE>/<dir>", store: "<STORE>", name: "<name>")'
96
+ 'codex=ENV["CODEX_THREAD_ID"].to_s.strip; claude=ENV["CLAUDE_CODE_SESSION_ID"].to_s.strip; harness=!codex.empty? ? "codex" : (!claude.empty? ? "claude" : nil); session=!codex.empty? ? codex : (!claude.empty? ? claude : nil); Bridge.arm_auto(session, intent_id: "<ID>", intent_dir: "<STORE>/<dir>", store: "<STORE>", name: "<name>", harness: harness, agent: "plastic-enforcer", thread: (!codex.empty? ? codex : nil))'
97
97
  ```
98
98
 
99
99
  Replace `<ID>`, `<STORE>` (e.g. `~/.plastic/projects/<slug>/store` or `~/.plastic/store`),
100
100
  `<dir>` (the `ID--slug` directory), and `<name>`. The first argument is the session id you
101
- want the bridge keyed by: pass the hook stdin `session_id` when you have it, otherwise
102
- `ENV["CLAUDE_CODE_SESSION_ID"]`, otherwise `nil`. Arming always succeeds and acquires the
101
+ want the bridge keyed by: pass the hook stdin `session_id` when you have it. The executable
102
+ snippet trusts a nonblank `CODEX_THREAD_ID` as Codex, otherwise a nonblank
103
+ `CLAUDE_CODE_SESSION_ID` as Claude, otherwise leaves harness and thread unknown. Never guess
104
+ identity from an absent runtime variable. Arming always succeeds and acquires the
103
105
  durable `delivery.lock` in the intent dir. For the `resolve_session` fallback chain
104
106
  (why arming never needs a non-empty session env var, and what the lock ownership model
105
107
  implies for later tool calls) read `references/end-tail.md`.
@@ -148,12 +150,33 @@ The enforcer's session owns the delivery lock. Per-stage specialists run in
148
150
  their own sessions and would be denied by the lock gate, so register each one
149
151
  as a delegate before (or when) it needs to write into the intent dir:
150
152
 
151
- 1. Instruct each spawned specialist to report its session id
152
- (`CLAUDE_CODE_SESSION_ID`) in its first message.
153
+ 1. Instruct each spawned specialist to report its session id and runtime identity in its first
154
+ message: `CODEX_THREAD_ID` for Codex, or `CLAUDE_CODE_SESSION_ID` for Claude. Use the
155
+ specialist/hook identity when known; never infer a harness or model from missing context.
153
156
  2. As the lock owner, run:
154
- `ruby ~/.plastic/scripts/plastic-lock delegate --delegate <specialist-session-id>`
157
+ `ruby ~/.plastic/scripts/plastic-lock delegate --intent-dir <intent-dir> --delegate <specialist-session-id> --harness <specialist-harness-when-known> --agent <role> --model <resolved-model-when-known> --thread <reported-CODEX_THREAD_ID-when-Codex>`
158
+ Omit `--harness`, `--model`, or `--thread` when that value is unknown; `--agent <role>` is
159
+ always known from the dispatch roster.
155
160
  3. If a specialist hits a lock-gate deny, the deny message names this exact
156
161
  command; run it and have the specialist retry.
162
+ 4. Immediately after the specialist returns, and before validating or dispatching
163
+ the next handoff, classify the return and record its activity status as the owner:
164
+ - `finished` means the specialist returned a usable completion report, whether
165
+ agent-authored or synthesized through `scripts/agent-report`.
166
+ - `failed` means the specialist returned blocked, errored, or without a usable
167
+ completion report that can be synthesized.
168
+ 5. Record the classification with exactly one of:
169
+ ```bash
170
+ ruby ~/.plastic/scripts/plastic-lock delegate --intent-dir <intent-dir> \
171
+ --delegate <specialist-session-id> --status finished --harness <same-specialist-harness-when-known> \
172
+ --agent <same-role> --model <same-resolved-model-when-known> --thread <same-CODEX_THREAD_ID-when-Codex>
173
+ ruby ~/.plastic/scripts/plastic-lock delegate --intent-dir <intent-dir> \
174
+ --delegate <specialist-session-id> --status failed --harness <same-specialist-harness-when-known> \
175
+ --agent <same-role> --model <same-resolved-model-when-known> --thread <same-CODEX_THREAD_ID-when-Codex>
176
+ ```
177
+ Apply the same omission rule to unknown values on terminal status commands.
178
+ A failed specialist stops that handoff under the normal blocker/error procedure;
179
+ never dispatch the next specialist first.
157
180
 
158
181
  Only the owner can delegate. Delegates cannot re-delegate or release.
159
182
 
@@ -81,6 +81,13 @@ account therefore always exists: agent-authored when present, deterministically
81
81
  otherwise. This structures the finish notification only; in-flight observations stay in
82
82
  `## Insights`, no progress chatter is added.
83
83
 
84
+ Immediately after a specialist returns and before the next handoff, the enforcer records the
85
+ delegate's activity through `plastic-lock delegate --intent-dir <intent-dir> --delegate <id>
86
+ --status finished|failed`. `finished` requires a usable agent-authored or synthesized completion
87
+ report. A blocked or errored return, or one with no report that can be synthesized, is `failed`
88
+ and stops the handoff under the normal error procedure. Activity status is descriptive and does
89
+ not revoke the registered delegate's authorization.
90
+
84
91
  ### Gate Ownership
85
92
 
86
93
  The enforcer arms and verifies the lifecycle gate, then gates every stage transition.