@zalom/plastic 1.0.0-beta.23 → 1.0.0-beta.25

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.
@@ -0,0 +1,193 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "fileutils"
6
+ require "socket"
7
+ require "time"
8
+
9
+ # Lock: the durable single-owner delivery lock (intent 108).
10
+ #
11
+ # One JSON lock file per intent, delivery.lock, living IN the intent dir beside
12
+ # savepoint.md (git-ignored, transient state). Ownership is session-keyed (D1):
13
+ # the file records the owner session, never a pid. Liveness is a lease: the
14
+ # owner's hooks touch the file mtime on tool calls (heartbeat); the lock is
15
+ # stale only when that heartbeat is older than the TTL. The /tmp bridge is a
16
+ # per-session CACHE of this state; on any disagreement the lock file wins (D2).
17
+ #
18
+ # Mutual-exclusion seam (D3): the schema carries a type ("delivery" now,
19
+ # "maintenance" in a chained intent after 93) and acquire refuses while the
20
+ # OTHER type is fresh. Only the seam ships in 108.
21
+ #
22
+ # Pure and dependency-injected: every function takes explicit paths plus ttl:
23
+ # and now:; nothing here reads ENV or globals, and nothing shells out.
24
+ module Lock
25
+ module_function
26
+
27
+ TYPES = %w[delivery maintenance].freeze
28
+
29
+ # Lease TTL. Heartbeats fire from the write-path hooks (PostToolUse
30
+ # gate-check and the lock-gate allow path), so a delivering session
31
+ # refreshes constantly; 30 minutes tolerates long read-only stretches
32
+ # without opening a takeover window mid-delivery. Reclaim is explicit
33
+ # either way (takeover), so the TTL only bounds WHEN takeover is allowed.
34
+ TTL_SECONDS = 1800
35
+
36
+ def blank?(value)
37
+ value.nil? || value.to_s.strip.empty?
38
+ end
39
+
40
+ def path(intent_dir, type: "delivery")
41
+ File.join(intent_dir, "#{type}.lock")
42
+ end
43
+
44
+ # Parsed lock Hash, or nil when absent or corrupt (corrupt? distinguishes).
45
+ def read(intent_dir, type: "delivery")
46
+ p = path(intent_dir, type: type)
47
+ return nil unless File.exist?(p)
48
+ data = JSON.parse(File.read(p)) rescue nil
49
+ data.is_a?(Hash) ? data : nil
50
+ end
51
+
52
+ def corrupt?(intent_dir, type: "delivery")
53
+ File.exist?(path(intent_dir, type: type)) && read(intent_dir, type: type).nil?
54
+ end
55
+
56
+ # Lease freshness: the file mtime IS the heartbeat.
57
+ def fresh?(intent_dir, type: "delivery", ttl: TTL_SECONDS, now: Time.now)
58
+ p = path(intent_dir, type: type)
59
+ return false unless File.exist?(p)
60
+ (now - File.mtime(p)) <= ttl
61
+ end
62
+
63
+ # session is the owner or a registered delegate (D4).
64
+ def authorized?(data, session)
65
+ return false unless data.is_a?(Hash)
66
+ return false if blank?(session)
67
+ return true if data["owner_session"].to_s == session.to_s
68
+ Array(data["delegates"]).map(&:to_s).include?(session.to_s)
69
+ end
70
+
71
+ # The one question gates ask: does session hold this intent's lock?
72
+ # Owner/delegate on an EXISTING lock counts even when stale (a stale lock is
73
+ # still theirs until an explicit takeover replaces it); freshness only
74
+ # guards AGAINST other sessions.
75
+ def holds?(intent_dir, session:, type: "delivery")
76
+ authorized?(read(intent_dir, type: type), session)
77
+ end
78
+
79
+ # Atomic acquisition (O_EXCL). Returns a [status, data] pair:
80
+ # [:acquired, lock] created fresh
81
+ # [:owned, lock] re-acquire by the current owner (idempotent re-arm)
82
+ # [:held, lock] fresh foreign lock: back off
83
+ # [:stale, lock] expired foreign lock: explicit takeover required
84
+ # [:excluded, other] the OTHER lock type is fresh (D3)
85
+ # [:corrupt, nil] unparseable lock file: run repair
86
+ def acquire(intent_dir, session:, type: "delivery", host: Socket.gethostname,
87
+ ttl: TTL_SECONDS, now: Time.now)
88
+ raise ArgumentError, "unknown lock type #{type.inspect}" unless TYPES.include?(type)
89
+ raise ArgumentError, "lock session must be present" if blank?(session)
90
+
91
+ other = (TYPES - [type]).first
92
+ if fresh?(intent_dir, type: other, ttl: ttl, now: now)
93
+ return [:excluded, read(intent_dir, type: other)]
94
+ end
95
+
96
+ return [:corrupt, nil] if corrupt?(intent_dir, type: type)
97
+
98
+ existing = read(intent_dir, type: type)
99
+ if existing
100
+ if existing["owner_session"].to_s == session.to_s
101
+ data = payload(session: session, type: type, host: host, now: now,
102
+ delegates: Array(existing["delegates"]))
103
+ write(intent_dir, data, type: type)
104
+ return [:owned, data]
105
+ end
106
+ return [:held, existing] if fresh?(intent_dir, type: type, ttl: ttl, now: now)
107
+ return [:stale, existing]
108
+ end
109
+
110
+ data = payload(session: session, type: type, host: host, now: now)
111
+ File.open(path(intent_dir, type: type),
112
+ File::WRONLY | File::CREAT | File::EXCL) do |io|
113
+ io.write(JSON.pretty_generate(data))
114
+ end
115
+ [:acquired, data]
116
+ rescue Errno::EEXIST
117
+ [:held, read(intent_dir, type: type)] # lost the O_EXCL race
118
+ end
119
+
120
+ def payload(session:, type:, host:, now:, delegates: [])
121
+ {
122
+ "type" => type,
123
+ "owner_session" => session.to_s,
124
+ "host" => host,
125
+ "acquired_at" => now.utc.iso8601,
126
+ "delegates" => delegates,
127
+ }
128
+ end
129
+
130
+ # Owner/delegate heartbeat: touch the mtime, never rewrite content.
131
+ def heartbeat(intent_dir, session:, type: "delivery", now: Time.now)
132
+ return false unless holds?(intent_dir, session: session, type: type)
133
+ FileUtils.touch(path(intent_dir, type: type), mtime: now)
134
+ true
135
+ end
136
+
137
+ # Owner registers a delegate (D4): a session allowed to write under this
138
+ # lock. Only the OWNER may delegate; delegates cannot re-delegate.
139
+ def add_delegate(intent_dir, delegate:, session:, type: "delivery")
140
+ data = read(intent_dir, type: type)
141
+ return false if blank?(delegate)
142
+ return false unless data && data["owner_session"].to_s == session.to_s
143
+ data["delegates"] = (Array(data["delegates"]) + [delegate.to_s]).uniq
144
+ write(intent_dir, data, type: type)
145
+ true
146
+ end
147
+
148
+ # Owner releases the lock (disarm / End tail, D6). force: true is the repair
149
+ # path's escape hatch for corrupt or own-session rebuilds.
150
+ # Returns :released, :not_owner, or :none.
151
+ def release(intent_dir, session:, type: "delivery", force: false)
152
+ p = path(intent_dir, type: type)
153
+ return :none unless File.exist?(p)
154
+ data = read(intent_dir, type: type)
155
+ unless force || (data && data["owner_session"].to_s == session.to_s)
156
+ return :not_owner
157
+ end
158
+ File.delete(p)
159
+ :released
160
+ end
161
+
162
+ # Explicit takeover of a stale (or corrupt) lock (D2): replace the lock and
163
+ # append an audit line to savepoint.md. NEVER takes over a fresh foreign
164
+ # lock; there is no silent reclaim path anywhere else.
165
+ # Returns [:taken, data], [:fresh, existing], or acquire's error statuses.
166
+ def takeover(intent_dir, session:, type: "delivery", host: Socket.gethostname,
167
+ ttl: TTL_SECONDS, now: Time.now)
168
+ existing = read(intent_dir, type: type)
169
+ if existing && !authorized?(existing, session) &&
170
+ fresh?(intent_dir, type: type, ttl: ttl, now: now)
171
+ return [:fresh, existing]
172
+ end
173
+
174
+ old_owner = existing ? existing["owner_session"] : "corrupt-or-missing"
175
+ p = path(intent_dir, type: type)
176
+ File.delete(p) if File.exist?(p)
177
+ status, data = acquire(intent_dir, session: session, type: type, host: host,
178
+ ttl: ttl, now: now)
179
+ return [status, data] unless status == :acquired
180
+
181
+ audit = "#{now.utc.iso8601} Lock takeover: #{session} reclaimed #{type} " \
182
+ "lock from #{old_owner}\n"
183
+ File.open(File.join(intent_dir, "savepoint.md"), "a") { |io| io.write(audit) }
184
+ [:taken, data]
185
+ end
186
+
187
+ # Rewrite the lock file in place (owner-side mutations). A content write also
188
+ # refreshes the mtime, which is correct: every sanctioned mutation is owner
189
+ # activity.
190
+ def write(intent_dir, data, type: "delivery")
191
+ File.write(path(intent_dir, type: type), JSON.pretty_generate(data))
192
+ end
193
+ end
@@ -4,10 +4,10 @@
4
4
  require_relative "qmd_sync"
5
5
 
6
6
  # PowerTools — detect-then-degrade harness for Plastic's optional power-tools
7
- # (intent 66b). It owns deterministic detection of each tool and builds an
8
- # obligation ("mandate") string for whichever tools are present, so the agent is
9
- # obliged (not merely reminded) to use them: QMD for finding intents, Serena for
10
- # code navigation.
7
+ # (intent 66b; demoted to recommendations in intent 108, D8). It owns
8
+ # deterministic detection of each tool and builds a RECOMMENDATION string for
9
+ # whichever tools are present, so the agent is reminded (not obliged) to prefer
10
+ # them: QMD for finding intents, Serena for code navigation.
11
11
  #
12
12
  # Strictly detect-then-degrade: a tool that is absent contributes nothing, and
13
13
  # `mandate` returns nil when no tool is present. Nothing here installs anything.
@@ -52,22 +52,21 @@ module PowerTools
52
52
  false
53
53
  end
54
54
 
55
- # Obligation text for whichever tools are present, joined by newlines, or nil
56
- # when none are. One MANDATORY line per present tool.
55
+ # Recommendation text for whichever tools are present, joined by newlines, or
56
+ # nil when none are. One recommendation line per present tool.
57
57
  def mandate(cwd:, qmd_detector: QmdSync.method(:detect), serena_detector: nil)
58
58
  lines = []
59
59
 
60
60
  if qmd?(detector: qmd_detector)
61
- lines << "MANDATORY: you MUST use QMD (`qmd search` / `qmd query` over the " \
62
- "`plastic-*` collections) to check for an existing or related intent " \
63
- "before treating this as new work; do not grep/Read the store first."
61
+ lines << "QMD is available: prefer `qmd search` / `qmd query` over the " \
62
+ "`plastic-*` collections to check for existing or related intents " \
63
+ "before treating work as new."
64
64
  end
65
65
 
66
66
  serena_present = serena_detector ? !!serena_detector.call : serena?(cwd: cwd)
67
67
  if serena_present
68
- lines << "MANDATORY: you MUST use Serena's symbolic tools (find_symbol / " \
69
- "get_symbols_overview / find_referencing_symbols) for code navigation " \
70
- "before grep/Read."
68
+ lines << "Serena is available: prefer its symbolic tools (find_symbol / " \
69
+ "get_symbols_overview / find_referencing_symbols) for code navigation."
71
70
  end
72
71
 
73
72
  return nil if lines.empty?
@@ -5,6 +5,7 @@ require "json"
5
5
  require "yaml"
6
6
  require "socket"
7
7
  require "time"
8
+ require_relative "lock"
8
9
 
9
10
  # Worktree -- Plastic-supplied git worktree isolation and the delivery lock
10
11
  # (intent 73c / 73c1).
@@ -21,8 +22,8 @@ require "time"
21
22
  # code worktree <repo>/.claude/worktrees/{id}--{slug} branch plastic/{id}--{slug}
22
23
  # store worktree <plastic_home>/.worktrees/{id}--{slug} branch plastic-store/{id}--{slug}
23
24
  #
24
- # The bridge file doubles as the delivery lock (decision D3): single-owner,
25
- # stale-lock reclaim via pid liveness.
25
+ # The durable delivery.lock file in the intent dir is the single-owner
26
+ # delivery lock (intent 108): session-keyed, lease-based, explicit takeover.
26
27
  #
27
28
  # Pure and dependency-injected: every git call goes through an injected
28
29
  # `ShellRunner`, so unit tests are hermetic (no real git; inject a fake runner).
@@ -123,6 +124,10 @@ module Worktree
123
124
  # into the store commit. Ensure both ignore entries before any worktree add.
124
125
  ensure_gitignored(plastic_home, ".worktrees/", runner: runner)
125
126
 
127
+ # The durable lock files live inside intent dirs under the store git repo
128
+ # (intent 108, D2): transient state, never committed.
129
+ ensure_gitignored(plastic_home, "*.lock", runner: runner)
130
+
126
131
  # Store worktree: created against the plastic home git repo. Fail-open if the
127
132
  # store repo is not a git repo (a fresh global store may be ungit'd).
128
133
  store_ok = add_worktree(runner, repo: plastic_home,
@@ -275,46 +280,20 @@ module Worktree
275
280
 
276
281
  # --- lock ------------------------------------------------------------------
277
282
 
278
- # pid liveness: signal 0 probes without sending. Any error (no such process,
279
- # not ours) means not live.
280
- def session_live?(pid)
281
- n = Integer(pid) rescue nil
282
- return false if n.nil? || n <= 0
283
- Process.kill(0, n)
284
- true
285
- rescue StandardError
286
- false
287
- end
288
-
289
- # True iff ANOTHER bridge for this intent has a LIVE owner pid that is not
290
- # current_session. Scans /tmp/plastic-*.json (or `tmp`). The current session's
291
- # own bridge never counts as "other". A dead owner does not hold the lock
292
- # (stale-lock reclaim).
293
- def lock_held_by_other?(intent_id:, store:, current_session:, home: Dir.home, tmp: nil)
294
- tmp ||= default_tmp
295
- id = intent_id.to_s
296
- st = File.expand_path(store.to_s) unless blank?(store)
297
-
298
- Dir.glob(File.join(tmp, "plastic-*.json")).each do |f|
299
- next if f.end_with?(".tmp")
300
- data = (JSON.parse(File.read(f)) rescue nil)
301
- next unless data.is_a?(Hash)
302
-
303
- intent = data["intent"] || {}
304
- next unless intent["id"].to_s == id
305
- unless st.nil?
306
- bstore = intent["store"].to_s
307
- next unless bstore.empty? || File.expand_path(bstore) == st
308
- end
309
-
310
- session = data["session"].to_s
311
- next if !blank?(current_session) && session == current_session.to_s
312
-
313
- lock = data["lock"] || {}
314
- owner_pid = lock["pid"]
315
- return true if session_live?(owner_pid)
316
- end
317
- false
283
+ # True iff ANOTHER session's delivery.lock is FRESH on this intent's dir
284
+ # (intent 108, D2): the durable lock file decides; /tmp bridges are not
285
+ # consulted and no pid is probed. current_session being the owner or a
286
+ # delegate does not count as "other". A stale lock does not hold (explicit
287
+ # takeover reclaims it).
288
+ def lock_held_by_other?(intent_id:, store:, current_session:, home: Dir.home,
289
+ ttl: Lock::TTL_SECONDS, now: Time.now)
290
+ return false if blank?(store)
291
+ dir = Dir.glob(File.join(File.expand_path(store), "#{intent_id}--*")).first
292
+ return false unless dir
293
+ data = Lock.read(dir)
294
+ return false unless data
295
+ return false if Lock.authorized?(data, current_session)
296
+ Lock.fresh?(dir, ttl: ttl, now: now)
318
297
  rescue StandardError
319
298
  false
320
299
  end
@@ -402,8 +381,4 @@ module Worktree
402
381
  base[(idx + 2)..]
403
382
  end
404
383
 
405
- def default_tmp
406
- t = ENV["PLASTIC_TMP"]
407
- (t.nil? || t.strip.empty?) ? "/tmp" : t
408
- end
409
384
  end
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+ #
5
+ # plastic-lock: inspect and repair the durable delivery lock (intent 108, D5).
6
+ #
7
+ # Usage: plastic-lock <status|fix|release|reclaim|delegate>
8
+ # [--intent-dir DIR] [--session SID] [--delegate SID]
9
+ #
10
+ # 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)
17
+ #
18
+ # Without --intent-dir the intent is resolved from this session's bridge.
19
+ # Exit 0 on success/report; exit 1 when the verb is blocked (held elsewhere).
20
+
21
+ require "json"
22
+ require_relative "lib/bridge"
23
+ require_relative "lib/lock"
24
+
25
+ def usage!
26
+ warn "usage: plastic-lock <status|fix|release|reclaim|delegate> " \
27
+ "[--intent-dir DIR] [--session SID] [--delegate SID]"
28
+ exit 1
29
+ end
30
+
31
+ verb = ARGV.shift
32
+ usage! unless %w[status fix release reclaim delegate].include?(verb)
33
+
34
+ opts = {}
35
+ until ARGV.empty?
36
+ case (flag = ARGV.shift)
37
+ when "--intent-dir" then opts[:dir] = ARGV.shift
38
+ when "--session" then opts[:session] = ARGV.shift
39
+ when "--delegate" then opts[:delegate] = ARGV.shift
40
+ else
41
+ warn "unknown flag #{flag}"
42
+ usage!
43
+ end
44
+ end
45
+
46
+ session = opts[:session]
47
+ session = ENV["CLAUDE_CODE_SESSION_ID"] if session.nil? || session.strip.empty?
48
+
49
+ dir = opts[:dir]
50
+ if dir.nil? || dir.strip.empty?
51
+ bridge = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
52
+ dir = Bridge.bridge_intent_dir(bridge)
53
+ end
54
+ if dir.nil?
55
+ warn "plastic-lock: no intent resolved; pass --intent-dir <intent dir>"
56
+ exit 1
57
+ end
58
+ dir = File.expand_path(dir)
59
+
60
+ intent_id = Bridge.intent_id_from_dir(dir)
61
+ store = File.dirname(dir)
62
+ name = File.basename(dir)
63
+ key = Bridge.resolve_session(session, intent_id: intent_id, store: store)
64
+
65
+ case verb
66
+ when "status"
67
+ lock = Lock.read(dir)
68
+ bridge = Bridge.read(key)
69
+ report = {
70
+ "intent_dir" => dir,
71
+ "session" => key,
72
+ "lock" => lock,
73
+ "lock_fresh" => Lock.fresh?(dir),
74
+ "lock_corrupt" => Lock.corrupt?(dir),
75
+ "bridge_present" => !bridge.nil?,
76
+ "agreement" => (lock && bridge) ?
77
+ (lock["owner_session"] == bridge.dig("lock", "owner_session")) : nil,
78
+ }
79
+ puts JSON.pretty_generate(report)
80
+ when "fix"
81
+ report = Bridge.repair_lock(key, intent_id: intent_id, intent_dir: dir,
82
+ store: store, name: name)
83
+ puts JSON.pretty_generate(report)
84
+ unless report["status"] == "repaired"
85
+ warn "plastic-lock: #{report['status']} by #{report['owner']}" \
86
+ "#{report['hint'] ? " (#{report['hint']})" : ''}"
87
+ exit 1
88
+ end
89
+ when "release"
90
+ result = Lock.release(dir, session: key)
91
+ if result == :not_owner
92
+ warn "plastic-lock: not the owner; run plastic-lock status"
93
+ exit 1
94
+ end
95
+ data = Bridge.read(key)
96
+ if data
97
+ data["lock"] = { "owner_session" => nil, "acquired_at" => nil,
98
+ "host" => nil, "type" => nil, "delegates" => [] }
99
+ Bridge.write(key, data)
100
+ end
101
+ puts "released (#{result})"
102
+ when "reclaim"
103
+ status, lock_data = Lock.takeover(dir, session: key)
104
+ if status == :fresh
105
+ warn "plastic-lock: lock is FRESH and held by #{lock_data['owner_session']}; " \
106
+ "back off (no silent reclaim)"
107
+ exit 1
108
+ end
109
+ report = Bridge.repair_lock(key, intent_id: intent_id, intent_dir: dir,
110
+ store: store, name: name)
111
+ puts JSON.pretty_generate(report)
112
+ when "delegate"
113
+ usage! if opts[:delegate].nil?
114
+ ok = Lock.add_delegate(dir, delegate: opts[:delegate], session: key)
115
+ unless ok
116
+ warn "plastic-lock: only the lock owner may delegate; run plastic-lock status"
117
+ exit 1
118
+ end
119
+ puts "delegated #{opts[:delegate]} under #{key}"
120
+ end
@@ -56,6 +56,11 @@ It never returns nil, so the gate engages even when every session env var is emp
56
56
  never needs a non-empty session env var to function. Arming prints a one-line notice to
57
57
  stderr when it falls through to the derived key.
58
58
 
59
+ Arming acquires the durable `delivery.lock` in the intent dir, keyed by that resolved
60
+ session. Ownership is session-keyed, not process-keyed, so the arm one-liner exiting
61
+ immediately is fine by construction: the lock stays yours for every later tool call in this
62
+ session. A failed arm raises with a message naming the resolving `plastic-lock` verb.
63
+
59
64
  **Hard rule for the rest of this run:** do NOT edit project code (anything outside the
60
65
  intent directory / `~/.plastic/`) until `plan.md` AND `checklist.md` exist for the intent.
61
66
  Honor the cycle: What → Why (spec.md) → How (plan.md + actions/ + checklist.md) → Exec.
@@ -84,6 +89,21 @@ Completion report (require-then-synthesize): every dispatched specialist MUST en
84
89
 
85
90
  Final-gate review: dispatch an independent reviewer subagent at the final gate only, not as a standing role.
86
91
 
92
+ ### Delegation (subagents writing under the owner's lock)
93
+
94
+ The enforcer's session owns the delivery lock. Per-stage specialists run in
95
+ their own sessions and would be denied by the lock gate, so register each one
96
+ as a delegate before (or when) it needs to write into the intent dir:
97
+
98
+ 1. Instruct each spawned specialist to report its session id
99
+ (`CLAUDE_CODE_SESSION_ID`) in its first message.
100
+ 2. As the lock owner, run:
101
+ `ruby ~/.plastic/scripts/plastic-lock delegate --delegate <specialist-session-id>`
102
+ 3. If a specialist hits a lock-gate deny, the deny message names this exact
103
+ command; run it and have the specialist retry.
104
+
105
+ Only the owner can delegate. Delegates cannot re-delegate or release.
106
+
87
107
  Headless manual gate: when running headless or in the background, still enforce gates manually rather than relying on hooks alone. The PostToolUse gate hook reads `session_id` from hook stdin, and the savepoint ledger write is decoupled from the bridge (derived from the file path, so it fires even with no session id) - these do NOT no-op. What can degrade is the bridge-keyed stage enforcement: if no session id reaches the bridge and no matching bridge is discovered, the stage-gate enforcement step exits without acting, so verify state yourself. The bridge still resolves arming via `CLAUDE_CODE_SESSION_ID` or the derived-key fallback (see the arm-gate note above).
88
108
 
89
109
  Solo fallback: if the harness has no subagent dispatch, fall back to a single agent walking the full What, Why, How, Exec cycle yourself. This preserves current behavior.
@@ -227,8 +247,11 @@ During initial project creation, all decisions are non-destructive by definition
227
247
  ```bash
228
248
  ruby -r ~/.plastic/scripts/lib/bridge -e 'Bridge.disarm_auto(ENV["CLAUDE_CODE_SESSION_ID"])'
229
249
  ```
230
- Disarming also purges stale bridge files from the temp directory automatically (it keeps the
231
- current bridge and any live run), so no manual `/tmp` cleanup is needed.
250
+ Disarm runs the ordered End tail: it releases the worktrees first, then clears the
251
+ intent's `delivery.lock` (and the bridge's lock cache), and only then is the bridge
252
+ purge-eligible. Disarming also purges stale bridge files from the temp directory
253
+ automatically (it keeps the current bridge, any live run, and any bridge whose intent
254
+ still holds a delivery lock), so no manual `/tmp` cleanup is needed.
232
255
 
233
256
  **Worktree cleanup (mandatory, intent 73c3).** Disarming performs the worktree release:
234
257
  `disarm_auto` calls `Worktree.release`, which removes both per-intent worktrees (the code
@@ -119,9 +119,12 @@ Use the `fix_hint` value to determine the correct action:
119
119
  | "Inject the missing required frontmatter field(s)" | Edit the intent's `{ID}--{slug}.md` frontmatter to add the missing key (e.g. `chain: []`) without touching other keys |
120
120
  | "Run: provision-project-store {slug}" | Run `provision-project-store <slug>` (or invoke the `plastic-add-project-store` skill) to create the missing store |
121
121
  | "Re-run installer" | Run `npx @zalom/plastic@latest --agent` |
122
+ | "Dispatch plastic-intent-curator ... revisions.md ..." | Invoke the `plastic-intent-curator` (or the agent) to relocate the flagged section or ref into the intent's `revisions.md` via move-and-record (one dated, `[rule: <tag>]`-tagged entry per item), per PLASTIC.md > Structural maintenance and revisions.md. For a missing required section, restore or reproject it instead. |
122
123
 
123
124
  For fixes the agent cannot handle automatically, explain what the user needs
124
- to do manually.
125
+ to do manually. The `revisions.md` remedy is curator-applied (a move-and-record
126
+ relocation, not a mechanical edit) and stays human-gated by the Step 4
127
+ Fix / Select / Skip prompt.
125
128
 
126
129
  ### Step 6: Verify
127
130
 
@@ -34,7 +34,14 @@ enforces it: without a held lock, mutating writes to this active intent's dir ar
34
34
  1. **Ensure the intent is in INDEX `## Active`.** If it sits in `## Future`, activate it
35
35
  (move it to `## Active`, auto-commit) before arming. Creation precedes activation, so a
36
36
  brand-new What intent is activated here, then locked.
37
- 2. **Arm the bridge.** Which arm is chosen by the mode answer (below), but the lock itself is
37
+ 2. **Self-heal the lock state first.** Run:
38
+ `ruby ~/.plastic/scripts/plastic-lock fix --intent-dir <STORE>/<dir>`
39
+ This is the one repair function (same one /plastic-lock exposes): it removes
40
+ corrupt or legacy lock state and rebuilds the lock and bridge from disk for
41
+ this session. If it reports `held`, another session owns the intent: STOP
42
+ and tell the user who holds it. If it reports `stale`, ask the user before
43
+ running `plastic-lock reclaim` (takeover is audited).
44
+ 3. **Arm the bridge.** Which arm is chosen by the mode answer (below), but the lock itself is
38
45
  taken first. Reuse the arm one-liner shape from `plastic-auto`:
39
46
  ```bash
40
47
  # guided (lock only):
@@ -55,8 +62,14 @@ deterministic derived key (a hash of the store and intent id). It never returns
55
62
  lock is taken even when every session env var is empty; arming prints a one-line stderr
56
63
  notice when it falls through to the derived key.
57
64
 
58
- Idempotent re-arm: arming again with the same owner just refreshes the lock (re-derives and
59
- rewrites the bridge); it is not an error to re-board an intent this session already owns.
65
+ **What the lock IS.** Ownership is session-keyed and lease-based: arming writes a durable
66
+ `delivery.lock` file in the intent dir naming this session as owner, and the owner's hooks
67
+ refresh the file mtime on tool activity (the lease heartbeat). The /tmp bridge is only a
68
+ cache of that file; on any disagreement the lock file wins, so a wiped /tmp never strands
69
+ the owner. Idempotent re-arm: arming again with the same owner just refreshes the lock; it
70
+ is not an error to re-board an intent this session already owns. A failed arm raises with
71
+ a message naming the resolving `plastic-lock` verb (`status`, `reclaim`, or `fix`): follow
72
+ that message, never delete a lock file by hand.
60
73
 
61
74
  ## Confirm delivery state
62
75
 
@@ -0,0 +1,41 @@
1
+ ---
2
+ name: lock
3
+ description: Inspect, repair, release, or reclaim an intent's delivery lock. Use when a lock-gate deny names /plastic-lock, when resuming interrupted work after a crash, reboot, or /tmp wipe, when a lock reads held or stale, or when the user says "fix the lock", "who holds the lock", or "reclaim the lock".
4
+ ---
5
+
6
+ # Plastic Lock
7
+
8
+ Command-only wrapper around `~/.plastic/scripts/plastic-lock`. The durable
9
+ delivery lock is a `delivery.lock` file in the intent directory: ownership is
10
+ session-keyed, liveness is a lease (the owner's hooks refresh the file mtime;
11
+ stale means the heartbeat is older than the TTL). The /tmp bridge is only a
12
+ cache; the lock file wins every disagreement.
13
+
14
+ ## Verbs
15
+
16
+ Run from the project (the intent resolves from this session's bridge), or pass
17
+ `--intent-dir` explicitly:
18
+
19
+ | Verb | What it does | When |
20
+ |---|---|---|
21
+ | `status` | Report the lock file, bridge cache, freshness, agreement | Always safe; run first |
22
+ | `fix` | Idempotent repair: rebuild lock + bridge from disk truth for THIS session. Never touches a fresh foreign lock | Interrupted work, corrupted state, /tmp wiped, legacy pid locks |
23
+ | `release` | Owner clears the lock | Ending or abandoning a boarding |
24
+ | `reclaim` | Explicit takeover of a STALE lock; appends an audit line to savepoint.md | The owner is gone and the lease expired |
25
+ | `delegate` | Owner registers a subagent session under the lock (`--delegate <session-id>`) | Auto-mode orchestration |
26
+
27
+ ```
28
+ ruby ~/.plastic/scripts/plastic-lock status
29
+ ruby ~/.plastic/scripts/plastic-lock fix --intent-dir <store>/<id>--<slug>
30
+ ruby ~/.plastic/scripts/plastic-lock reclaim --intent-dir <store>/<id>--<slug>
31
+ ruby ~/.plastic/scripts/plastic-lock delegate --delegate <subagent-session-id>
32
+ ```
33
+
34
+ ## Rules
35
+
36
+ - `fix` exits non-zero when another session holds a FRESH lock: back off, do
37
+ not retry in a loop. `status` shows the owner.
38
+ - `reclaim` refuses a fresh lock. There is no silent reclaim anywhere; every
39
+ takeover is audited in the intent's savepoint.md.
40
+ - Acquiring a lock for new work is NOT this skill's job: board through
41
+ `/plastic-intent-starting`, which calls the same repair internally.