@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/PLASTIC.md +28 -11
- package/package.json +1 -1
- package/scripts/codex-hook +122 -8
- package/scripts/dashboard.rb +323 -71
- package/scripts/doctor.rb +230 -1
- package/scripts/hook-lock-gate +8 -3
- package/scripts/install.rb +51 -6
- package/scripts/lib/bridge.rb +76 -26
- package/scripts/lib/hook_registry.rb +32 -2
- package/scripts/lib/installer_core.rb +40 -6
- package/scripts/lib/lock.rb +186 -11
- package/scripts/plastic-lock +76 -9
- package/skills/auto/SKILL.md +29 -6
- package/skills/auto/references/agent-architecture.md +7 -0
- package/skills/dashboard/SKILL.md +48 -25
- package/skills/dashboard/evals/evals.json +4 -4
- package/skills/dashboard/templates/dashboard-global.md +3 -5
- package/skills/dashboard/templates/dashboard-project.md +6 -18
- package/skills/intent-locking/SKILL.md +20 -2
- package/skills/intent-starting/SKILL.md +6 -4
- package/skills/project-continuing/SKILL.md +10 -0
- package/skills/project-continuing/evals/evals.json +3 -3
- package/skills/project-continuing/references/board-fill.md +13 -11
- package/skills/tutorial/SKILL.md +4 -4
- package/skills/tutorial/references/track-1-guided.md +2 -1
- package/skills/tutorial/references/track-2-auto.md +2 -1
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -1
|
@@ -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).
|
|
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
|
-
|
|
428
|
-
|
|
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
|
package/scripts/lib/lock.rb
CHANGED
|
@@ -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
|
|
372
|
-
"delegate, the owner must register you:
|
|
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
|
package/scripts/plastic-lock
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
package/skills/auto/SKILL.md
CHANGED
|
@@ -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
|
-
'
|
|
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
|
|
102
|
-
|
|
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
|
-
|
|
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.
|