@zalom/plastic 1.0.0-beta.29 → 1.0.0-beta.30
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 +16 -0
- package/package.json +1 -1
- package/scripts/hook-lock-gate +35 -0
- package/scripts/lib/lock.rb +182 -0
- package/scripts/plastic-lock +56 -12
package/PLASTIC.md
CHANGED
|
@@ -379,6 +379,22 @@ idempotent function with two entry points: the `plastic-lock` command (status, f
|
|
|
379
379
|
release, reclaim, delegate) and `/plastic-intent-starting`, so boarding self-heals. This is
|
|
380
380
|
mandatory, not a convention.
|
|
381
381
|
|
|
382
|
+
The delivery lock arbitrates at the whole-intent grain: it decides who may work
|
|
383
|
+
an intent at all. Underneath it, a per-artifact claim token (intent 111)
|
|
384
|
+
arbitrates at the file grain: it decides who, among those already holding the
|
|
385
|
+
delivery lock, is the one writer for one lifecycle file right now. A write to
|
|
386
|
+
`spec.md`, `plan.md`, `checklist.md`, or the intent file must hold both the
|
|
387
|
+
delivery lock and that file's claim. Claims live in `.claims/<artifact>.claim`
|
|
388
|
+
inside the intent directory, one small JSON file per artifact, scoped strictly
|
|
389
|
+
per-intent-per-artifact, never session-global. The claim gate is dormant
|
|
390
|
+
(allows) when no claim file exists for an artifact, so ordinary single-owner
|
|
391
|
+
work is unaffected; it engages, and denies, only when a second writer tries to
|
|
392
|
+
take a fresh claim someone else already holds. A stale or corrupt claim fails
|
|
393
|
+
open (the write proceeds, the claim yields) and the condition is surfaced in
|
|
394
|
+
`plastic-lock status`, which lists any live claims alongside the delivery
|
|
395
|
+
lock. See `plastic-lock claim`/`release-claim` and `docs/internals.md` for the
|
|
396
|
+
full mechanism.
|
|
397
|
+
|
|
382
398
|
Two locks share this schema (the two-lock doctrine): `delivery.lock` (exclusive, one owner
|
|
383
399
|
plus delegates) and the future `maintenance.lock` (short TTL, structural move-and-record
|
|
384
400
|
only). They are mutually exclusive in either direction; maintenance is allowed at any
|
package/package.json
CHANGED
package/scripts/hook-lock-gate
CHANGED
|
@@ -28,6 +28,41 @@ begin
|
|
|
28
28
|
rescue StandardError
|
|
29
29
|
# ignore
|
|
30
30
|
end
|
|
31
|
+
|
|
32
|
+
# Second, independent gate (intent 111 D7): the artifact-grain claim.
|
|
33
|
+
# Composes UNDER the delivery lock above; only reached once the delivery
|
|
34
|
+
# lock already allows. Dormant (nil) unless a claim file exists for this
|
|
35
|
+
# artifact, so AC7 (existing single-owner flows) is unaffected.
|
|
36
|
+
begin
|
|
37
|
+
dir = Bridge.intent_dir_for(file_path)
|
|
38
|
+
artifact = File.basename(file_path) if dir
|
|
39
|
+
if dir && artifact
|
|
40
|
+
claim_reason = Claim.claim_gate_reason(dir, artifact, session: session)
|
|
41
|
+
if claim_reason
|
|
42
|
+
print JSON.generate(
|
|
43
|
+
"hookSpecificOutput" => {
|
|
44
|
+
"hookEventName" => "PreToolUse",
|
|
45
|
+
"permissionDecision" => "deny",
|
|
46
|
+
"permissionDecisionReason" => claim_reason,
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
exit 0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Fail-open surface (AC3): allow, but make the yielded/stale claim visible.
|
|
53
|
+
if Claim.fail_open?(dir, artifact)
|
|
54
|
+
$stderr.puts "plastic claim gate: unresolvable claim on #{artifact} in " \
|
|
55
|
+
"intent #{Bridge.intent_id_from_dir(dir)} yielded; write " \
|
|
56
|
+
"proceeds (see /plastic-lock status)"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Heartbeat the session's own claim on the allow path (best-effort).
|
|
60
|
+
Claim.heartbeat(dir, artifact, session: session) rescue nil
|
|
61
|
+
end
|
|
62
|
+
rescue StandardError
|
|
63
|
+
# ignore: a claim-gate bug must never block a write (fail open)
|
|
64
|
+
end
|
|
65
|
+
|
|
31
66
|
exit 0
|
|
32
67
|
end
|
|
33
68
|
|
package/scripts/lib/lock.rb
CHANGED
|
@@ -191,3 +191,185 @@ module Lock
|
|
|
191
191
|
File.write(path(intent_dir, type: type), JSON.pretty_generate(data))
|
|
192
192
|
end
|
|
193
193
|
end
|
|
194
|
+
|
|
195
|
+
# Claim: the per-artifact claim-token layer (intent 111, D1/D7). Sits BENEATH
|
|
196
|
+
# the session-keyed delivery lock: a lifecycle-file write must hold BOTH the
|
|
197
|
+
# intent's delivery lock (Lock, unchanged) AND that specific artifact's claim.
|
|
198
|
+
# Neither layer replaces the other.
|
|
199
|
+
#
|
|
200
|
+
# Storage: one small JSON file per artifact, sibling to delivery.lock, under
|
|
201
|
+
# `.claims/<artifact>.claim` INSIDE the intent dir. Scope is strictly
|
|
202
|
+
# per-intent-per-artifact (D4, hard constraint): a claim's on-disk path is
|
|
203
|
+
# always `<intent_dir>/.claims/<artifact>.claim`, so a claim can never affect
|
|
204
|
+
# any artifact but its own, nor any intent but its own. This is what stops a
|
|
205
|
+
# stuck/stale claim from recreating the collision-90 failure mode.
|
|
206
|
+
#
|
|
207
|
+
# Exclusivity is O_EXCL at acquire, not session-equality (see plan.md): a
|
|
208
|
+
# fresh claim is NEVER idempotently re-granted, even to the session that
|
|
209
|
+
# holds it. This is what makes "exactly one writer" mechanical rather than a
|
|
210
|
+
# convention: the second acquire against a live claim is rejected at the
|
|
211
|
+
# filesystem, even when both callers share one CLAUDE_CODE_SESSION_ID.
|
|
212
|
+
#
|
|
213
|
+
# Fail open, always (D3): a stale or corrupt claim never blocks; it yields to
|
|
214
|
+
# the current writer and the condition is surfaced (see Claim.fail_open?,
|
|
215
|
+
# added in a later action, the named contract 112 gates on).
|
|
216
|
+
#
|
|
217
|
+
# Pure and dependency-injected: every function takes explicit paths plus ttl:
|
|
218
|
+
# and now:; nothing here reads ENV or globals, and nothing shells out. Does
|
|
219
|
+
# not touch any Lock function.
|
|
220
|
+
module Claim
|
|
221
|
+
module_function
|
|
222
|
+
|
|
223
|
+
CLAIMS_DIR = ".claims"
|
|
224
|
+
|
|
225
|
+
def dir_path(intent_dir)
|
|
226
|
+
File.join(intent_dir, CLAIMS_DIR)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def path(intent_dir, artifact)
|
|
230
|
+
File.join(dir_path(intent_dir), "#{artifact}.claim")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Parsed claim Hash, or nil when absent or corrupt (corrupt? distinguishes).
|
|
234
|
+
def read(intent_dir, artifact)
|
|
235
|
+
p = path(intent_dir, artifact)
|
|
236
|
+
return nil unless File.exist?(p)
|
|
237
|
+
data = JSON.parse(File.read(p)) rescue nil
|
|
238
|
+
data.is_a?(Hash) ? data : nil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def corrupt?(intent_dir, artifact)
|
|
242
|
+
File.exist?(path(intent_dir, artifact)) && read(intent_dir, artifact).nil?
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Lease freshness: the file mtime IS the heartbeat (mirrors Lock.fresh?).
|
|
246
|
+
def fresh?(intent_dir, artifact, ttl: Lock::TTL_SECONDS, now: Time.now)
|
|
247
|
+
p = path(intent_dir, artifact)
|
|
248
|
+
return false unless File.exist?(p)
|
|
249
|
+
(now - File.mtime(p)) <= ttl
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def payload(session:, artifact:, now:, delegate: nil)
|
|
253
|
+
{
|
|
254
|
+
"artifact" => artifact,
|
|
255
|
+
"owner_session" => session.to_s,
|
|
256
|
+
"acquired_at" => now.utc.iso8601,
|
|
257
|
+
"delegate" => delegate,
|
|
258
|
+
}
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Atomic acquisition (O_EXCL). Returns a [status, data] pair:
|
|
262
|
+
# [:acquired, claim] created fresh
|
|
263
|
+
# [:held, claim] fresh claim (own or foreign): never idempotently
|
|
264
|
+
# re-granted; the caller backs off or waits
|
|
265
|
+
# [:stale, claim] expired claim: caller may take over (see plastic-lock)
|
|
266
|
+
# [:corrupt, nil] unparseable claim file: caller may repair
|
|
267
|
+
def acquire_claim(intent_dir, artifact, session:, delegate: nil,
|
|
268
|
+
ttl: Lock::TTL_SECONDS, now: Time.now)
|
|
269
|
+
raise ArgumentError, "claim session must be present" if Lock.blank?(session)
|
|
270
|
+
raise ArgumentError, "claim artifact must be present" if Lock.blank?(artifact)
|
|
271
|
+
|
|
272
|
+
FileUtils.mkdir_p(dir_path(intent_dir))
|
|
273
|
+
return [:corrupt, nil] if corrupt?(intent_dir, artifact)
|
|
274
|
+
|
|
275
|
+
existing = read(intent_dir, artifact)
|
|
276
|
+
if existing
|
|
277
|
+
return [:held, existing] if fresh?(intent_dir, artifact, ttl: ttl, now: now)
|
|
278
|
+
return [:stale, existing]
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
data = payload(session: session, artifact: artifact, now: now, delegate: delegate)
|
|
282
|
+
File.open(path(intent_dir, artifact),
|
|
283
|
+
File::WRONLY | File::CREAT | File::EXCL) do |io|
|
|
284
|
+
io.write(JSON.pretty_generate(data))
|
|
285
|
+
end
|
|
286
|
+
[:acquired, data]
|
|
287
|
+
rescue Errno::EEXIST
|
|
288
|
+
[:held, read(intent_dir, artifact)] # lost the O_EXCL race
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# session is the owner or the registered delegate on this claim. Stale-own
|
|
292
|
+
# still counts as holding (mirrors Lock.holds?): the claim is theirs until
|
|
293
|
+
# an explicit takeover replaces it; freshness only guards AGAINST others.
|
|
294
|
+
def holds_claim?(intent_dir, artifact, session:)
|
|
295
|
+
data = read(intent_dir, artifact)
|
|
296
|
+
return false unless data.is_a?(Hash)
|
|
297
|
+
return false if Lock.blank?(session)
|
|
298
|
+
data["owner_session"].to_s == session.to_s || data["delegate"].to_s == session.to_s
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Owner (or force:) releases the claim. Returns :none, :not_owner, or
|
|
302
|
+
# :released.
|
|
303
|
+
def release_claim(intent_dir, artifact, session:, force: false)
|
|
304
|
+
p = path(intent_dir, artifact)
|
|
305
|
+
return :none unless File.exist?(p)
|
|
306
|
+
unless force || holds_claim?(intent_dir, artifact, session: session)
|
|
307
|
+
return :not_owner
|
|
308
|
+
end
|
|
309
|
+
File.delete(p)
|
|
310
|
+
:released
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Owner/delegate heartbeat: touch the mtime, never rewrite content. False
|
|
314
|
+
# (no-op) when the session does not hold the claim.
|
|
315
|
+
def heartbeat(intent_dir, artifact, session:, now: Time.now)
|
|
316
|
+
return false unless holds_claim?(intent_dir, artifact, session: session)
|
|
317
|
+
FileUtils.touch(path(intent_dir, artifact), mtime: now)
|
|
318
|
+
true
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# The named fail-open contract (intent 111 D6; intent 112 gates its Exec on
|
|
322
|
+
# this test and re-runs it as a regression check on every lock.rb edit it
|
|
323
|
+
# makes). True iff a claim FILE exists but is unresolvable (stale or
|
|
324
|
+
# corrupt): the write must PROCEED (yield the claim to the current writer)
|
|
325
|
+
# and surface the condition; it MUST NEVER block. Absence of a claim is not
|
|
326
|
+
# fail-open, that is plain dormancy (the gate is not engaged at all).
|
|
327
|
+
def fail_open?(intent_dir, artifact, ttl: Lock::TTL_SECONDS, now: Time.now)
|
|
328
|
+
return true if corrupt?(intent_dir, artifact)
|
|
329
|
+
!!(read(intent_dir, artifact) && !fresh?(intent_dir, artifact, ttl: ttl, now: now))
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# The data behind `plastic-lock status` (AC5): every live claim under this
|
|
333
|
+
# intent, with enough to show who holds what since when, and whether it is
|
|
334
|
+
# still fresh. Returns [] when no artifact has ever been claimed.
|
|
335
|
+
def claims_status(intent_dir, ttl: Lock::TTL_SECONDS, now: Time.now)
|
|
336
|
+
return [] unless Dir.exist?(dir_path(intent_dir))
|
|
337
|
+
Dir.glob(File.join(dir_path(intent_dir), "*.claim")).sort.map do |file|
|
|
338
|
+
artifact = File.basename(file, ".claim")
|
|
339
|
+
data = begin
|
|
340
|
+
parsed = JSON.parse(File.read(file))
|
|
341
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
342
|
+
rescue JSON::ParserError
|
|
343
|
+
nil
|
|
344
|
+
end
|
|
345
|
+
{
|
|
346
|
+
"artifact" => (data && data["artifact"]) || artifact,
|
|
347
|
+
"owner_session" => data && data["owner_session"],
|
|
348
|
+
"delegate" => data && data["delegate"],
|
|
349
|
+
"acquired_at" => data && data["acquired_at"],
|
|
350
|
+
"fresh" => fresh?(intent_dir, artifact, ttl: ttl, now: now),
|
|
351
|
+
"corrupt" => data.nil?,
|
|
352
|
+
}
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Second, independent write gate at the artifact grain (intent 111 D7). Returns a
|
|
357
|
+
# deny reason String to BLOCK, or nil to ALLOW. Composes UNDER the delivery-lock
|
|
358
|
+
# gate: only reached after the session already holds the intent's delivery lock.
|
|
359
|
+
# ENGAGES only when a claim file exists (dormant otherwise, so single-owner flows
|
|
360
|
+
# and the existing suite stay green, AC7). Fails open on stale/corrupt via
|
|
361
|
+
# fail_open?, the named contract.
|
|
362
|
+
def claim_gate_reason(intent_dir, artifact, session:, ttl: Lock::TTL_SECONDS, now: Time.now)
|
|
363
|
+
return nil if Lock.blank?(artifact)
|
|
364
|
+
return nil unless File.exist?(path(intent_dir, artifact)) # dormant: no claim
|
|
365
|
+
return nil if holds_claim?(intent_dir, artifact, session: session) # you hold it
|
|
366
|
+
return nil if fail_open?(intent_dir, artifact, ttl: ttl, now: now) # stale/corrupt: yield
|
|
367
|
+
data = read(intent_dir, artifact)
|
|
368
|
+
holder = data && data["owner_session"]
|
|
369
|
+
since = data && data["acquired_at"]
|
|
370
|
+
"artifact #{artifact} is claimed by #{holder} since #{since}; another writer holds " \
|
|
371
|
+
"it. Back off or run /plastic-lock status. If you are a distinct delegate, the " \
|
|
372
|
+
"owner must register you: plastic-lock delegate --intent-dir #{intent_dir} " \
|
|
373
|
+
"--session <your-session-id>"
|
|
374
|
+
end
|
|
375
|
+
end
|
package/scripts/plastic-lock
CHANGED
|
@@ -2,18 +2,24 @@
|
|
|
2
2
|
# encoding: UTF-8
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
#
|
|
5
|
-
# plastic-lock: inspect and repair the durable delivery lock (intent 108, D5)
|
|
5
|
+
# plastic-lock: inspect and repair the durable delivery lock (intent 108, D5),
|
|
6
|
+
# and take/free per-artifact claim tokens (intent 111 D1/D5).
|
|
6
7
|
#
|
|
7
|
-
# Usage: plastic-lock <status|fix|release|reclaim|delegate>
|
|
8
|
-
# [--intent-dir DIR] [--session SID] [--delegate SID]
|
|
8
|
+
# Usage: plastic-lock <status|fix|release|reclaim|delegate|claim|release-claim>
|
|
9
|
+
# [--intent-dir DIR] [--session SID] [--delegate SID] [--artifact NAME]
|
|
9
10
|
#
|
|
10
11
|
# Verbs:
|
|
11
|
-
# status
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
12
|
+
# status report the lock file, the bridge cache, their agreement,
|
|
13
|
+
# and any live per-artifact claims
|
|
14
|
+
# fix idempotent repair: rebuild lock + bridge from disk truth for
|
|
15
|
+
# the current session; never touches a fresh foreign lock
|
|
16
|
+
# release owner clears the lock (End tail / abandoning a boarding)
|
|
17
|
+
# reclaim explicit takeover of a stale lock, audited in savepoint.md
|
|
18
|
+
# delegate owner registers a subagent session under the lock (D4)
|
|
19
|
+
# claim acquire the named --artifact's claim token (intent 111);
|
|
20
|
+
# rejected (exit 1) while a fresh claim is held by anyone,
|
|
21
|
+
# including this same session; takes over a stale claim
|
|
22
|
+
# release-claim free the named --artifact's claim token
|
|
17
23
|
#
|
|
18
24
|
# Without --intent-dir the intent is resolved from this session's bridge.
|
|
19
25
|
# Exit 0 on success/report; exit 1 when the verb is blocked (held elsewhere).
|
|
@@ -23,13 +29,13 @@ require_relative "lib/bridge"
|
|
|
23
29
|
require_relative "lib/lock"
|
|
24
30
|
|
|
25
31
|
def usage!
|
|
26
|
-
warn "usage: plastic-lock <status|fix|release|reclaim|delegate> " \
|
|
27
|
-
"[--intent-dir DIR] [--session SID] [--delegate SID]"
|
|
32
|
+
warn "usage: plastic-lock <status|fix|release|reclaim|delegate|claim|release-claim> " \
|
|
33
|
+
"[--intent-dir DIR] [--session SID] [--delegate SID] [--artifact NAME]"
|
|
28
34
|
exit 1
|
|
29
35
|
end
|
|
30
36
|
|
|
31
37
|
verb = ARGV.shift
|
|
32
|
-
usage! unless %w[status fix release reclaim delegate].include?(verb)
|
|
38
|
+
usage! unless %w[status fix release reclaim delegate claim release-claim].include?(verb)
|
|
33
39
|
|
|
34
40
|
opts = {}
|
|
35
41
|
until ARGV.empty?
|
|
@@ -37,6 +43,7 @@ until ARGV.empty?
|
|
|
37
43
|
when "--intent-dir" then opts[:dir] = ARGV.shift
|
|
38
44
|
when "--session" then opts[:session] = ARGV.shift
|
|
39
45
|
when "--delegate" then opts[:delegate] = ARGV.shift
|
|
46
|
+
when "--artifact" then opts[:artifact] = ARGV.shift
|
|
40
47
|
else
|
|
41
48
|
warn "unknown flag #{flag}"
|
|
42
49
|
usage!
|
|
@@ -75,6 +82,7 @@ when "status"
|
|
|
75
82
|
"bridge_present" => !bridge.nil?,
|
|
76
83
|
"agreement" => (lock && bridge) ?
|
|
77
84
|
(lock["owner_session"] == bridge.dig("lock", "owner_session")) : nil,
|
|
85
|
+
"claims" => Claim.claims_status(dir),
|
|
78
86
|
}
|
|
79
87
|
puts JSON.pretty_generate(report)
|
|
80
88
|
when "fix"
|
|
@@ -117,4 +125,40 @@ when "delegate"
|
|
|
117
125
|
exit 1
|
|
118
126
|
end
|
|
119
127
|
puts "delegated #{opts[:delegate]} under #{key}"
|
|
128
|
+
when "claim"
|
|
129
|
+
usage! if opts[:artifact].nil?
|
|
130
|
+
status, data = Claim.acquire_claim(dir, opts[:artifact], session: key,
|
|
131
|
+
delegate: opts[:delegate])
|
|
132
|
+
case status
|
|
133
|
+
when :acquired
|
|
134
|
+
puts "claimed #{opts[:artifact]} under #{key}"
|
|
135
|
+
when :held
|
|
136
|
+
warn "plastic-lock: #{opts[:artifact]} is claimed by " \
|
|
137
|
+
"#{data['owner_session']} since #{data['acquired_at']}; back off"
|
|
138
|
+
exit 1
|
|
139
|
+
when :stale
|
|
140
|
+
old = data && data["owner_session"]
|
|
141
|
+
Claim.release_claim(dir, opts[:artifact], session: key, force: true)
|
|
142
|
+
status2, = Claim.acquire_claim(dir, opts[:artifact], session: key,
|
|
143
|
+
delegate: opts[:delegate])
|
|
144
|
+
if status2 == :acquired
|
|
145
|
+
warn "plastic-lock: took over stale claim on #{opts[:artifact]} from #{old}"
|
|
146
|
+
puts "claimed #{opts[:artifact]} under #{key}"
|
|
147
|
+
else
|
|
148
|
+
warn "plastic-lock: could not take over #{opts[:artifact]} (#{status2})"
|
|
149
|
+
exit 1
|
|
150
|
+
end
|
|
151
|
+
when :corrupt
|
|
152
|
+
Claim.release_claim(dir, opts[:artifact], session: key, force: true)
|
|
153
|
+
Claim.acquire_claim(dir, opts[:artifact], session: key, delegate: opts[:delegate])
|
|
154
|
+
puts "claimed #{opts[:artifact]} under #{key} (repaired corrupt claim)"
|
|
155
|
+
end
|
|
156
|
+
when "release-claim"
|
|
157
|
+
usage! if opts[:artifact].nil?
|
|
158
|
+
result = Claim.release_claim(dir, opts[:artifact], session: key)
|
|
159
|
+
if result == :not_owner
|
|
160
|
+
warn "plastic-lock: not the claim owner; run plastic-lock status"
|
|
161
|
+
exit 1
|
|
162
|
+
end
|
|
163
|
+
puts "released-claim #{opts[:artifact]} (#{result})"
|
|
120
164
|
end
|