@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.0.0-beta.29",
3
+ "version": "1.0.0-beta.30",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
 
@@ -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
@@ -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 report the lock file, the bridge cache, and their agreement
12
- # fix idempotent repair: rebuild lock + bridge from disk truth for the
13
- # current session; never touches a fresh foreign lock
14
- # release owner clears the lock (End tail / abandoning a boarding)
15
- # reclaim explicit takeover of a stale lock, audited in savepoint.md
16
- # delegate owner registers a subagent session under the lock (D4)
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