@zalom/plastic 1.0.0-beta.24 → 1.0.0-beta.26
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 +101 -31
- package/agents/plastic-intent-curator.md +4 -1
- 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 +225 -3
- 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 +40 -11
- package/skills/executing-plan/SKILL.md +4 -4
- package/skills/intent-curator/SKILL.md +4 -3
- package/skills/intent-starting/SKILL.md +16 -3
- package/skills/lock/SKILL.md +41 -0
- package/skills/managing-index/SKILL.md +5 -1
- package/templates/outcome.md +3 -0
|
@@ -6,6 +6,7 @@ require "yaml"
|
|
|
6
6
|
require "fileutils"
|
|
7
7
|
require "digest"
|
|
8
8
|
require "time"
|
|
9
|
+
require_relative "hook_registry"
|
|
9
10
|
|
|
10
11
|
# Shared installer machinery, instantiable with injected package root / store / agent
|
|
11
12
|
# map so the verb scripts (install/update/uninstall/versions) and their tests can run
|
|
@@ -214,6 +215,10 @@ class InstallerCore
|
|
|
214
215
|
"scripts/lib/retrieval_gate.rb" => "scripts/lib/retrieval_gate.rb",
|
|
215
216
|
"scripts/hook-auto-arm" => "scripts/hook-auto-arm",
|
|
216
217
|
"scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
|
|
218
|
+
"scripts/lib/lock.rb" => "scripts/lib/lock.rb",
|
|
219
|
+
"scripts/plastic-lock" => "scripts/plastic-lock",
|
|
220
|
+
"scripts/lib/hook_registry.rb" => "scripts/lib/hook_registry.rb",
|
|
221
|
+
"scripts/agent-report" => "scripts/agent-report",
|
|
217
222
|
"scripts/lib/insights.rb" => "scripts/lib/insights.rb",
|
|
218
223
|
"scripts/lib/worktree.rb" => "scripts/lib/worktree.rb",
|
|
219
224
|
"scripts/lib/boot_banner.rb" => "scripts/lib/boot_banner.rb",
|
|
@@ -540,68 +545,9 @@ class InstallerCore
|
|
|
540
545
|
|
|
541
546
|
purge_stale_plastic_hooks(hooks)
|
|
542
547
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
"hooks" => [
|
|
547
|
-
{ "type" => "command", "command" => "#{hook_dir}/plastic-session-start", "statusMessage" => "Loading Plastic context..." },
|
|
548
|
-
{ "type" => "command", "command" => "#{hook_dir}/plastic-check-update", "statusMessage" => "" },
|
|
549
|
-
],
|
|
550
|
-
},
|
|
551
|
-
"PreCompact" => {
|
|
552
|
-
"matcher" => "",
|
|
553
|
-
"hooks" => [
|
|
554
|
-
{ "type" => "command", "command" => "#{hook_dir}/plastic-savepoint", "statusMessage" => "Saving Plastic intent state..." },
|
|
555
|
-
],
|
|
556
|
-
},
|
|
557
|
-
# PreToolUse carries TWO plastic groups with distinct matchers: the
|
|
558
|
-
# code-gate (Write|Edit|NotebookEdit) and the create-gate (Write only, intent
|
|
559
|
-
# 60b). A single group cannot carry two matchers, so this event maps to a
|
|
560
|
-
# LIST of groups; the merge loop appends each (idempotent because the purge
|
|
561
|
-
# pass removes all prior plastic groups first).
|
|
562
|
-
"PreToolUse" => [
|
|
563
|
-
{
|
|
564
|
-
"matcher" => "Write|Edit|NotebookEdit",
|
|
565
|
-
"hooks" => [
|
|
566
|
-
{ "type" => "command", "command" => "#{hook_dir}/plastic-code-gate", "statusMessage" => "Checking lifecycle gate..." },
|
|
567
|
-
# Fail-closed lock gate (intent 96): a 2nd ordered command INSIDE the
|
|
568
|
-
# code-gate group (NOT a new same-matcher group, which the merge loop
|
|
569
|
-
# below would collapse). Blocks no-lock writes to an active intent's dir.
|
|
570
|
-
{ "type" => "command", "command" => "#{hook_dir}/plastic-lock-gate", "statusMessage" => "Checking lock gate..." },
|
|
571
|
-
],
|
|
572
|
-
},
|
|
573
|
-
{
|
|
574
|
-
"matcher" => "Write",
|
|
575
|
-
"hooks" => [
|
|
576
|
-
{ "type" => "command", "command" => "#{hook_dir}/plastic-create-gate", "statusMessage" => "Checking create gate..." },
|
|
577
|
-
],
|
|
578
|
-
},
|
|
579
|
-
# Retrieval gate (intent 84, Lever 2): redirects store-markdown reads to
|
|
580
|
-
# QMD and code reads to Serena when those tools are present. Binds the
|
|
581
|
-
# main agent AND subagents (PreToolUse applies to subagent tool calls).
|
|
582
|
-
{
|
|
583
|
-
"matcher" => "Bash|Read|Grep|Glob",
|
|
584
|
-
"hooks" => [
|
|
585
|
-
{ "type" => "command", "command" => "#{hook_dir}/plastic-retrieval-gate", "statusMessage" => "Checking retrieval gate..." },
|
|
586
|
-
],
|
|
587
|
-
},
|
|
588
|
-
],
|
|
589
|
-
"PostToolUse" => {
|
|
590
|
-
"matcher" => "Write|Edit",
|
|
591
|
-
"hooks" => [
|
|
592
|
-
{ "type" => "command", "command" => "#{hook_dir}/plastic-gate-check", "statusMessage" => "Checking lifecycle gates..." },
|
|
593
|
-
],
|
|
594
|
-
},
|
|
595
|
-
"UserPromptSubmit" => {
|
|
596
|
-
"matcher" => "",
|
|
597
|
-
"hooks" => [
|
|
598
|
-
{ "type" => "command", "command" => "#{hook_dir}/plastic-continue", "statusMessage" => "Checking for continue..." },
|
|
599
|
-
{ "type" => "command", "command" => "#{hook_dir}/plastic-future-intent-check", "statusMessage" => "Checking future intents..." },
|
|
600
|
-
{ "type" => "command", "command" => "#{hook_dir}/plastic-auto-arm", "statusMessage" => "Checking auto mode..." },
|
|
601
|
-
{ "type" => "command", "command" => "#{hook_dir}/plastic-qmd-search", "statusMessage" => "Searching QMD..." },
|
|
602
|
-
],
|
|
603
|
-
},
|
|
604
|
-
}
|
|
548
|
+
# Single source of truth (intent 108, D7): registrations live in
|
|
549
|
+
# HookRegistry; this merge only translates them into settings.json.
|
|
550
|
+
plastic_hooks = HookRegistry.claude_settings_hooks(hook_dir: hook_dir)
|
|
605
551
|
|
|
606
552
|
plastic_hooks.each do |event, group|
|
|
607
553
|
hooks[event] ||= []
|
|
@@ -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.
|
|
@@ -189,7 +209,10 @@ During initial project creation, all decisions are non-destructive by definition
|
|
|
189
209
|
## Completion
|
|
190
210
|
|
|
191
211
|
1. Verify all checklist items are checked
|
|
192
|
-
2. Write `outcome.md` with detailed results
|
|
212
|
+
2. Write `outcome.md` with detailed results, from `${CLAUDE_PLUGIN_ROOT}/templates/outcome.md`.
|
|
213
|
+
Set the frontmatter `disposition: delivered` (this is the delivered terminal). `outcome.md`
|
|
214
|
+
is mandatory at every terminal and self-declares its disposition (see the canonical done-marker
|
|
215
|
+
and End tail in PLASTIC.md `## Delivery Isolation and the Single-Owner Lock`).
|
|
193
216
|
3. Write `## Outcome` summary in the intent file (1-2 sentences)
|
|
194
217
|
4. **Release (if configured)**
|
|
195
218
|
1. Detect project — match CWD against paths in `~/.plastic/projects.yml` to find the project slug. If no match, skip to step 5 (default commit-only behavior).
|
|
@@ -216,19 +239,15 @@ During initial project creation, all decisions are non-destructive by definition
|
|
|
216
239
|
```
|
|
217
240
|
(Use `"abandoned"` instead when the intent is being moved to `## Abandoned`.) Idempotent.
|
|
218
241
|
7. Auto-commit: `cd <store-root> && git add . && git commit -m "feat: deliver intent <ID> — <name>"`
|
|
219
|
-
8.
|
|
220
|
-
It runs in the background so it never blocks the turn:
|
|
221
|
-
```bash
|
|
222
|
-
ruby ~/.plastic/scripts/qmd-sync reindex --store <store-root> --async
|
|
223
|
-
```
|
|
224
|
-
Completion is the lifecycle event that keeps the search index fresh. `<store-root>` is the
|
|
225
|
-
store that holds this intent (the global store or the project store).
|
|
226
|
-
9. Disarm the lifecycle gate (auto delivery is finished):
|
|
242
|
+
8. Disarm the lifecycle gate (auto delivery is finished):
|
|
227
243
|
```bash
|
|
228
244
|
ruby -r ~/.plastic/scripts/lib/bridge -e 'Bridge.disarm_auto(ENV["CLAUDE_CODE_SESSION_ID"])'
|
|
229
245
|
```
|
|
230
|
-
|
|
231
|
-
|
|
246
|
+
Disarm runs the ordered End tail: it releases the worktrees first, then clears the
|
|
247
|
+
intent's `delivery.lock` (and the bridge's lock cache), and only then is the bridge
|
|
248
|
+
purge-eligible. Disarming also purges stale bridge files from the temp directory
|
|
249
|
+
automatically (it keeps the current bridge, any live run, and any bridge whose intent
|
|
250
|
+
still holds a delivery lock), so no manual `/tmp` cleanup is needed.
|
|
232
251
|
|
|
233
252
|
**Worktree cleanup (mandatory, intent 73c3).** Disarming performs the worktree release:
|
|
234
253
|
`disarm_auto` calls `Worktree.release`, which removes both per-intent worktrees (the code
|
|
@@ -243,6 +262,16 @@ During initial project creation, all decisions are non-destructive by definition
|
|
|
243
262
|
removed, so the integrated work is not lost. It does this with `Worktree.finish(bridge_data,
|
|
244
263
|
merge: true)` (merge-then-remove). Never leave an orphaned worktree, and run `git worktree
|
|
245
264
|
prune` if you hit a stale reference.
|
|
265
|
+
9. QMD reindex LAST (canonical End tail). AFTER disarm has released the worktrees, cleared the
|
|
266
|
+
`delivery.lock`, and purged the bridge, refresh the QMD search index for this store (no-op when
|
|
267
|
+
QMD is absent). It runs in the background so it never blocks the turn:
|
|
268
|
+
```bash
|
|
269
|
+
ruby ~/.plastic/scripts/qmd-sync reindex --store <store-root> --async
|
|
270
|
+
```
|
|
271
|
+
Completion is the lifecycle event that keeps the search index fresh. `<store-root>` is the store
|
|
272
|
+
that holds this intent (the global store or the project store). The reindex is the LAST End-tail
|
|
273
|
+
step, run after purge, so the index never references a bridge or lock that is about to disappear
|
|
274
|
+
(see PLASTIC.md `## Delivery Isolation and the Single-Owner Lock`).
|
|
246
275
|
10. Notify user: "Intent [ID] — [name] delivered. [1-2 sentence summary]. See outcome.md for details."
|
|
247
276
|
|
|
248
277
|
## Error Handling
|