@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.
- package/PLASTIC.md +46 -31
- package/hooks/code-gate +10 -1
- package/hooks/hooks.json +3 -3
- package/hooks/lock-gate +10 -1
- package/package.json +1 -1
- package/scripts/doctor.rb +47 -2
- package/scripts/hook-bash-gate +21 -3
- package/scripts/hook-create-gate +43 -7
- package/scripts/hook-gate-check +10 -0
- package/scripts/hook-lock-gate +14 -3
- package/scripts/hook-retrieval-gate +29 -17
- package/scripts/lib/bridge.rb +231 -73
- package/scripts/lib/hook_registry.rb +95 -0
- package/scripts/lib/installer_core.rb +8 -62
- package/scripts/lib/lock.rb +193 -0
- package/scripts/lib/power_tools.rb +11 -12
- package/scripts/lib/worktree.rb +21 -46
- package/scripts/plastic-lock +120 -0
- package/skills/auto/SKILL.md +25 -2
- package/skills/doctor/SKILL.md +4 -1
- package/skills/intent-starting/SKILL.md +16 -3
- package/skills/lock/SKILL.md +41 -0
|
@@ -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
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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
|
-
#
|
|
56
|
-
# when none are. One
|
|
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 << "
|
|
62
|
-
"`plastic-*` collections
|
|
63
|
-
"before treating
|
|
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 << "
|
|
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?
|
package/scripts/lib/worktree.rb
CHANGED
|
@@ -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
|
|
25
|
-
#
|
|
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
|
-
#
|
|
279
|
-
#
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
package/skills/auto/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
231
|
-
|
|
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
|
package/skills/doctor/SKILL.md
CHANGED
|
@@ -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. **
|
|
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
|
-
|
|
59
|
-
|
|
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.
|