@zalom/plastic 1.0.0-beta.9 → 1.0.1
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-reference.md +133 -0
- package/PLASTIC.md +398 -82
- package/README.md +98 -82
- package/agents/plastic-brainstorming.md +12 -9
- package/agents/plastic-enforcer.md +52 -12
- package/agents/plastic-executor.md +10 -10
- package/agents/plastic-future-intent-researcher.md +4 -8
- package/agents/plastic-intent-curator.md +12 -14
- package/agents/plastic-intent-discovery.md +35 -0
- package/agents/plastic-planner.md +15 -10
- package/agents/plastic-spec-specialist.md +15 -10
- package/bin/plastic.js +7 -3
- package/hooks/code-gate +17 -2
- package/hooks/hooks.json +28 -3
- package/hooks/lock-gate +21 -0
- package/hooks/retrieval-gate +10 -0
- package/hooks/savepoint-pre +10 -0
- package/hooks/statusline +48 -6
- package/package.json +2 -1
- package/scripts/agent-report +21 -0
- package/scripts/dashboard.rb +98 -23
- package/scripts/doctor.rb +232 -5
- package/scripts/hook-auto-arm +1 -1
- package/scripts/hook-bash-gate +22 -4
- package/scripts/hook-code-gate +30 -7
- package/scripts/hook-continue +17 -0
- package/scripts/hook-create-gate +45 -9
- package/scripts/hook-gate-check +24 -23
- package/scripts/hook-lock-gate +83 -0
- package/scripts/hook-retrieval-gate +148 -0
- package/scripts/hook-savepoint-pre +32 -0
- package/scripts/hook-session-start +1 -1
- package/scripts/insight-append +51 -0
- package/scripts/install.rb +44 -7
- package/scripts/lib/agent_models.rb +43 -0
- package/scripts/lib/bridge.rb +924 -84
- package/scripts/lib/dashboard_banner.rb +42 -0
- package/scripts/lib/hook_registry.rb +95 -0
- package/scripts/lib/insights.rb +86 -0
- package/scripts/lib/installer_core.rb +101 -60
- package/scripts/lib/link_suggestions.rb +319 -0
- package/scripts/lib/lock.rb +375 -0
- package/scripts/lib/power_tools.rb +22 -21
- package/scripts/lib/preflight.rb +79 -0
- package/scripts/lib/qmd_sync.rb +48 -6
- package/scripts/lib/retrieval_gate.rb +211 -0
- package/scripts/lib/worktree.rb +384 -0
- package/scripts/link-suggest +213 -0
- package/scripts/new-intent +58 -17
- package/scripts/plastic-lock +164 -0
- package/scripts/read-config +4 -0
- package/scripts/spawn-preamble +36 -4
- package/scripts/update.rb +16 -7
- package/skills/auto/SKILL.md +127 -20
- package/skills/auto/references/agent-architecture.md +7 -4
- package/skills/auto/references/agent-report-contract.md +36 -1
- package/skills/auto/references/end-tail.md +56 -0
- package/skills/auto/references/human-report-contract.md +55 -0
- package/skills/auto/references/tiers.md +77 -0
- package/skills/brainstorming/SKILL.md +7 -34
- package/skills/brainstorming/references/design-principles.md +49 -0
- package/skills/continuing/SKILL.md +26 -7
- package/skills/creating-intent/SKILL.md +13 -28
- package/skills/creating-project/SKILL.md +11 -74
- package/skills/creating-project/references/project-scaffolding.md +97 -0
- package/skills/creating-skills/SKILL.md +65 -0
- package/skills/creating-skills/evals/evals.json +108 -0
- package/skills/creating-skills/references/agents.md +168 -0
- package/skills/creating-skills/references/evals.md +41 -0
- package/skills/creating-skills/references/hooks.md +248 -0
- package/skills/creating-skills/references/progressive-disclosure.md +176 -0
- package/skills/creating-skills/references/scripts.md +166 -0
- package/skills/creating-skills/references/skills.md +165 -0
- package/skills/creating-skills/scripts/scaffold.rb +313 -0
- package/skills/dashboard/SKILL.md +13 -11
- package/skills/dashboard/references/classification.md +22 -0
- package/skills/doctor/SKILL.md +10 -7
- package/skills/executing-plan/SKILL.md +4 -4
- package/skills/humanizer/SKILL.md +39 -0
- package/skills/humanizer/evals/evals.json +70 -0
- package/skills/humanizer/references/always-on-snippet.md +9 -0
- package/skills/humanizer/references/examples.md +48 -0
- package/skills/install/SKILL.md +75 -84
- package/skills/intent-curator/SKILL.md +5 -1
- package/skills/intent-discovery/SKILL.md +47 -0
- package/skills/intent-starting/SKILL.md +130 -0
- package/skills/intent-starting/evals/evals.json +117 -0
- package/skills/intent-starting/references/boarding-matrix.md +35 -0
- package/skills/linking-intents/SKILL.md +48 -12
- package/skills/lock/SKILL.md +41 -0
- package/skills/managing-index/SKILL.md +6 -0
- package/skills/releasing/SKILL.md +44 -29
- package/skills/releasing/references/promotion-and-tagging.md +60 -0
- package/skills/roadmap/SKILL.md +51 -0
- package/skills/roadmap/references/file-format.md +91 -0
- package/skills/roadmap/references/operations.md +88 -0
- package/skills/uninstall/SKILL.md +29 -11
- package/skills/update/SKILL.md +34 -23
- package/skills/versions/SKILL.md +27 -12
- package/skills/writing-plans/SKILL.md +10 -88
- package/skills/writing-plans/references/plan-format.md +102 -0
- package/templates/config.yml +8 -0
- package/templates/outcome.md +3 -0
- package/templates/revisions.md +58 -0
- package/templates/roadmap.md +30 -0
- package/skills/writing-instructions/SKILL.md +0 -159
- package/skills/writing-instructions/references/agentskills-spec.md +0 -135
package/scripts/lib/bridge.rb
CHANGED
|
@@ -6,6 +6,9 @@ require "yaml"
|
|
|
6
6
|
require "fileutils"
|
|
7
7
|
require "tempfile"
|
|
8
8
|
require "digest"
|
|
9
|
+
require "socket"
|
|
10
|
+
require_relative "worktree"
|
|
11
|
+
require_relative "lock"
|
|
9
12
|
|
|
10
13
|
module Bridge
|
|
11
14
|
STAGES = %w[what why how exec done].freeze
|
|
@@ -18,13 +21,15 @@ module Bridge
|
|
|
18
21
|
# (<id>--<slug>.md) is never sentineled; it is born complete.
|
|
19
22
|
PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
|
|
20
23
|
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
|
|
24
|
+
# Bridge cleanup is terminal-state, not age-based (intent 80). A bridge is dead
|
|
25
|
+
# weight ONLY once its intent is terminal (no longer in its store's INDEX.md
|
|
26
|
+
# `## Active` block); such bridges are purged. An Active intent's bridge is kept
|
|
27
|
+
# unconditionally, because while the intent is live the bridge is still load-
|
|
28
|
+
# bearing: it is the continuation signal (a parked or interrupted run resumes
|
|
29
|
+
# from it) and the anti-collision lock (it keys the per-session statusline so
|
|
30
|
+
# parallel sessions do not overwrite each other). An age window was the wrong
|
|
31
|
+
# axis: it left dead bridges resident for ~2 days AND could reap bridges of
|
|
32
|
+
# interrupted-but-still-active intents, which are exactly the ones to preserve.
|
|
28
33
|
|
|
29
34
|
def self.intent_file(intent_dir)
|
|
30
35
|
dir_name = File.basename(intent_dir)
|
|
@@ -40,8 +45,18 @@ module Bridge
|
|
|
40
45
|
(t.nil? || t.strip.empty?) ? "/tmp" : t
|
|
41
46
|
end
|
|
42
47
|
|
|
43
|
-
|
|
44
|
-
|
|
48
|
+
# Per-intent bridge key (intent 131): `plastic-<session>--<intent_id>.json`
|
|
49
|
+
# when intent_id is present, else the legacy single-key
|
|
50
|
+
# `plastic-<session>.json`. The per-intent key is what lets two concurrent
|
|
51
|
+
# deliveries under ONE session id keep separate bridge files instead of
|
|
52
|
+
# clobbering a shared one; the legacy form is still produced (and read) when
|
|
53
|
+
# no intent_id is given, so old single-key files stay valid.
|
|
54
|
+
def self.path(session, intent_id: nil, tmp: tmp_dir)
|
|
55
|
+
if blank?(intent_id)
|
|
56
|
+
"#{tmp}/plastic-#{session}.json"
|
|
57
|
+
else
|
|
58
|
+
"#{tmp}/plastic-#{session}--#{intent_id}.json"
|
|
59
|
+
end
|
|
45
60
|
end
|
|
46
61
|
|
|
47
62
|
# --- Session resolution (intent 52) ----------------------------------------
|
|
@@ -50,6 +65,31 @@ module Bridge
|
|
|
50
65
|
value.nil? || value.to_s.strip.empty?
|
|
51
66
|
end
|
|
52
67
|
|
|
68
|
+
# Raised by arm when the delivery lock cannot be acquired (held elsewhere,
|
|
69
|
+
# stale, excluded, or corrupt). The message names the resolving command.
|
|
70
|
+
class LockHeldError < StandardError; end
|
|
71
|
+
|
|
72
|
+
# The absolute intent dir a bridge points at, or nil.
|
|
73
|
+
def self.bridge_intent_dir(bridge_data)
|
|
74
|
+
return nil unless bridge_data.is_a?(Hash)
|
|
75
|
+
info = bridge_data["intent"] || {}
|
|
76
|
+
store = info["store"]
|
|
77
|
+
dir = info["dir"]
|
|
78
|
+
(store && dir) ? File.expand_path("#{store}/#{dir}") : nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Bridge-cache copy of the durable lock file's fields (D2: the bridge is a
|
|
82
|
+
# CACHE; the file is the truth). Never carries a pid.
|
|
83
|
+
def self.lock_cache(lock_data)
|
|
84
|
+
{
|
|
85
|
+
"owner_session" => lock_data["owner_session"],
|
|
86
|
+
"acquired_at" => lock_data["acquired_at"],
|
|
87
|
+
"host" => lock_data["host"],
|
|
88
|
+
"type" => lock_data["type"],
|
|
89
|
+
"delegates" => Array(lock_data["delegates"]),
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
53
93
|
# Deterministic, session-id-less bridge key derived from store + intent id.
|
|
54
94
|
# Stable across processes so a session-less arm and a later session-less
|
|
55
95
|
# gate-check resolve to the same bridge file.
|
|
@@ -57,19 +97,17 @@ module Bridge
|
|
|
57
97
|
"auto-" + Digest::SHA256.hexdigest("#{store}/#{intent_id}")[0, 10]
|
|
58
98
|
end
|
|
59
99
|
|
|
60
|
-
# Resolve a bridge session: first non-empty of explicit
|
|
100
|
+
# Resolve a bridge session: first non-empty of explicit (the stdin session_id),
|
|
61
101
|
# CLAUDE_CODE_SESSION_ID, then a derived key. Never returns nil/empty.
|
|
62
102
|
# Whitespace-only counts as empty.
|
|
63
103
|
#
|
|
64
|
-
# The CLAUDE_CODE_SESSION_ID fallback (intent 79)
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
104
|
+
# The CLAUDE_CODE_SESSION_ID fallback (intent 79) carries the bg/headless real
|
|
105
|
+
# session id (Claude Code passes session_id on stdin, not via an env var; the
|
|
106
|
+
# headless id lives in CLAUDE_CODE_SESSION_ID). Keying by the real id (instead of
|
|
107
|
+
# a derived hash) lets the statusline, which receives that same id on stdin, find
|
|
108
|
+
# the bridge by direct filename lookup.
|
|
69
109
|
def self.resolve_session(explicit, intent_id:, store:)
|
|
70
110
|
return explicit.to_s.strip unless blank?(explicit)
|
|
71
|
-
env = ENV["CLAUDE_SESSION_ID"]
|
|
72
|
-
return env.to_s.strip unless blank?(env)
|
|
73
111
|
code_env = ENV["CLAUDE_CODE_SESSION_ID"]
|
|
74
112
|
return code_env.to_s.strip unless blank?(code_env)
|
|
75
113
|
derive_key(store, intent_id)
|
|
@@ -95,15 +133,46 @@ module Bridge
|
|
|
95
133
|
data.is_a?(Hash) && !blank?(data["session"]) && data["intent"].is_a?(Hash)
|
|
96
134
|
end
|
|
97
135
|
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
136
|
+
# Tiered cwd discriminator for one bridge candidate (intent 131). A session
|
|
137
|
+
# now owns SEVERAL bridges (one per concurrent intent), so the discriminator
|
|
138
|
+
# that used to be "cwd overlaps intent.store" is too coarse: every sibling
|
|
139
|
+
# under the same store shares it. worktree.code is the only field that
|
|
140
|
+
# differs between siblings, so it is the strongest signal; the intent dir is
|
|
141
|
+
# next; the shared store is a last-resort coarse tie.
|
|
142
|
+
# 2 - cwd is the intent's provisioned code worktree (or under it)
|
|
143
|
+
# 1 - cwd is the intent's own dir (or under it)
|
|
144
|
+
# 0 - cwd merely overlaps the intent's store (shared by every sibling)
|
|
145
|
+
# -1 - no signal at all
|
|
146
|
+
def self.bridge_cwd_tier(data, cwd_abs)
|
|
147
|
+
worktree_code = data.dig("worktree", "code")
|
|
148
|
+
if !blank?(worktree_code)
|
|
149
|
+
wc_abs = File.expand_path(worktree_code)
|
|
150
|
+
return 2 if cwd_abs == wc_abs || cwd_abs.start_with?("#{wc_abs}/")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
dir_abs = bridge_intent_dir(data)
|
|
154
|
+
if dir_abs
|
|
155
|
+
return 1 if cwd_abs == dir_abs || cwd_abs.start_with?("#{dir_abs}/")
|
|
105
156
|
end
|
|
106
157
|
|
|
158
|
+
store = data.dig("intent", "store").to_s
|
|
159
|
+
unless store.empty?
|
|
160
|
+
store_abs = File.expand_path(store)
|
|
161
|
+
return 0 if cwd_abs == store_abs || cwd_abs.start_with?("#{store_abs}/") ||
|
|
162
|
+
store_abs.start_with?("#{cwd_abs}/")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
-1
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Resolve the active bridge: scan tmp for plastic-*.json (both per-intent and
|
|
169
|
+
# legacy-keyed files), keep only valid bridges, filter to the caller's own
|
|
170
|
+
# session when it has one, prefer auto-armed, then disambiguate by cwd tier
|
|
171
|
+
# (see bridge_cwd_tier), tie-break by newest mtime. No exact-session fast
|
|
172
|
+
# path: a session now legitimately owns several bridges (one per concurrent
|
|
173
|
+
# intent), so filename lookup alone cannot pick the right one; cwd must
|
|
174
|
+
# decide (intent 131).
|
|
175
|
+
def self.discover_bridge(session:, cwd: Dir.pwd, tmp: tmp_dir)
|
|
107
176
|
candidates = Dir.glob(File.join(tmp, "plastic-*.json")).reject { |f| f.end_with?(".tmp") }
|
|
108
177
|
parsed = candidates.filter_map do |f|
|
|
109
178
|
data = (JSON.parse(File.read(f)) rescue nil)
|
|
@@ -112,44 +181,121 @@ module Bridge
|
|
|
112
181
|
end
|
|
113
182
|
return nil if parsed.empty?
|
|
114
183
|
|
|
184
|
+
has_session = !blank?(session)
|
|
185
|
+
|
|
186
|
+
# Strict per-session ownership (intent 90): when the caller HAS a session, a foreign
|
|
187
|
+
# session's bridge is NEVER a valid resolution. Own-session and the derived-key case both
|
|
188
|
+
# reduce to candidate["session"] == session (the derived key IS the session that armed the
|
|
189
|
+
# bridge). A caller that owns no bridge resolves to nil, so its gates fail open instead of
|
|
190
|
+
# inheriting another session's armed intent.
|
|
191
|
+
#
|
|
192
|
+
# When the caller has NO session (truly headless, intent 52), keep the legacy degraded
|
|
193
|
+
# selection below so a single armed derived-key bridge is still discoverable - the hook
|
|
194
|
+
# cannot know the session there, and a lone armed intent must still gate.
|
|
195
|
+
if has_session
|
|
196
|
+
parsed = parsed.select { |c| c[:data]["session"].to_s == session.to_s }
|
|
197
|
+
return nil if parsed.empty?
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Auto-preference pool: a build-armed bridge is preferred over a merely
|
|
201
|
+
# derived one, but ONLY as a fallback when cwd cannot decide (below). cwd
|
|
202
|
+
# must win over auto-preference, so this pool is not applied before the
|
|
203
|
+
# cwd tiering (intent 131: a guided sibling in the caller's own worktree
|
|
204
|
+
# must beat an auto sibling in another worktree).
|
|
115
205
|
auto = parsed.select { |c| c[:data].dig("build", "auto") == true }
|
|
116
|
-
|
|
206
|
+
auto_pool = auto.empty? ? parsed : auto
|
|
117
207
|
|
|
118
208
|
unless blank?(cwd)
|
|
119
209
|
cwd_abs = File.expand_path(cwd)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
210
|
+
# Tier the FULL session pool by cwd BEFORE the auto-preference filter.
|
|
211
|
+
# When cwd overlaps ANY candidate (tier >= 0) it decides outright, even
|
|
212
|
+
# against a newer or auto-armed sibling: worktree.code (tier 2) and the
|
|
213
|
+
# intent dir (tier 1) disambiguate same-store siblings (intent 131), and
|
|
214
|
+
# a store overlap (tier 0) still selects the overlapping bridge over an
|
|
215
|
+
# off-cwd one in another store (the intent 90/52 store filter, preserved).
|
|
216
|
+
# Only when NO candidate overlaps cwd (max tier -1) do we fall through to
|
|
217
|
+
# the auto-preference pool and newest mtime, so a lone armed bridge
|
|
218
|
+
# off-cwd still resolves (intent 52 headless).
|
|
219
|
+
tiered = parsed.map { |c| [bridge_cwd_tier(c[:data], cwd_abs), c] }
|
|
220
|
+
max_tier = tiered.map(&:first).max
|
|
221
|
+
if max_tier && max_tier >= 0
|
|
222
|
+
winners = tiered.select { |tier, _| tier == max_tier }.map { |_, c| c }
|
|
223
|
+
return winners.max_by { |c| c[:mtime] }&.fetch(:data)
|
|
127
224
|
end
|
|
128
|
-
pool = matching unless matching.empty?
|
|
129
225
|
end
|
|
130
226
|
|
|
131
|
-
|
|
227
|
+
auto_pool.max_by { |c| c[:mtime] }&.fetch(:data)
|
|
132
228
|
end
|
|
133
229
|
|
|
134
|
-
# ---
|
|
135
|
-
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
#
|
|
230
|
+
# --- Terminal-state bridge purge (intent 80) -------------------------------
|
|
231
|
+
|
|
232
|
+
# True iff the intent is Active in its store's INDEX.md. An INDEX.md lives at
|
|
233
|
+
# the PARENT of the store/ dir the bridge records, so we resolve it from the
|
|
234
|
+
# bridge's intent.store. Non-raising: any failure (missing/unreadable INDEX,
|
|
235
|
+
# bad arg) returns false, which means "not active" so the caller treats the
|
|
236
|
+
# bridge as purgeable. `index_active_ids` is a pure-data test seam: when an
|
|
237
|
+
# Array of id strings is supplied, membership is checked against it directly
|
|
238
|
+
# with no file read.
|
|
239
|
+
def self.intent_active?(intent_id, store:, index_active_ids: nil)
|
|
240
|
+
target = intent_id.to_s
|
|
241
|
+
return index_active_ids.include?(target) if index_active_ids.is_a?(Array)
|
|
242
|
+
|
|
243
|
+
index = File.join(File.dirname(store.to_s), "INDEX.md")
|
|
244
|
+
return false unless File.exist?(index)
|
|
245
|
+
|
|
246
|
+
in_active = false
|
|
247
|
+
File.foreach(index) do |line|
|
|
248
|
+
stripped = line.chomp
|
|
249
|
+
if stripped == "## Active"
|
|
250
|
+
in_active = true
|
|
251
|
+
next
|
|
252
|
+
end
|
|
253
|
+
next unless in_active
|
|
254
|
+
break if stripped.start_with?("## ") # next section ends the Active block
|
|
255
|
+
m = stripped.match(/^- \[(\S+) +—/)
|
|
256
|
+
return true if m && m[1] == target
|
|
257
|
+
end
|
|
258
|
+
false
|
|
259
|
+
rescue StandardError
|
|
260
|
+
false
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Remove tmp/plastic-*.json bridge files whose intent is terminal, so
|
|
264
|
+
# discover_bridge's per-fire scan stays bounded. Best-effort and non-raising:
|
|
265
|
+
# returns the array of removed paths. A bridge is purged when it cannot be
|
|
266
|
+
# parsed, has no intent.id, has no intent.store, or its intent is not Active in
|
|
267
|
+
# its store's INDEX.md. An Active intent's bridge is kept (continuation signal +
|
|
268
|
+
# anti-collision lock), and the current session's own bridge is never purged
|
|
142
269
|
# (preserves the disarm_auto contract that it stays readable). Wired into
|
|
143
270
|
# arm_auto and disarm_auto so both manual and auto delivery keep the temp dir
|
|
144
|
-
# clean.
|
|
145
|
-
def self.
|
|
146
|
-
|
|
147
|
-
|
|
271
|
+
# clean at deterministic work boundaries.
|
|
272
|
+
def self.purge_done_bridges(session:, tmp: tmp_dir)
|
|
273
|
+
# Own-bridge predicate (intent 131): a session now legitimately owns
|
|
274
|
+
# SEVERAL bridges (one per concurrent intent), so "current" is no longer
|
|
275
|
+
# one filename. Skip the legacy single-key file for this session AND every
|
|
276
|
+
# per-intent-keyed file for this session; none of the session's own live
|
|
277
|
+
# bridges may be reaped mid-run.
|
|
278
|
+
own_legacy_name = File.basename(path(session, tmp: tmp))
|
|
279
|
+
own_prefix = "plastic-#{session}--"
|
|
148
280
|
removed = []
|
|
149
281
|
Dir.glob(File.join(tmp, "plastic-*.json")).each do |f|
|
|
150
|
-
next if f ==
|
|
282
|
+
next if File.basename(f) == own_legacy_name || File.basename(f).start_with?(own_prefix)
|
|
151
283
|
begin
|
|
152
|
-
|
|
284
|
+
data = JSON.parse(File.read(f)) rescue nil
|
|
285
|
+
keep = false
|
|
286
|
+
if data
|
|
287
|
+
id = data.dig("intent", "id")
|
|
288
|
+
store = data.dig("intent", "store")
|
|
289
|
+
keep = !blank?(id) && !blank?(store) && intent_active?(id, store: store)
|
|
290
|
+
# Never purge a bridge whose intent still holds a delivery lock
|
|
291
|
+
# (intent 108, D6): the End tail clears the lock BEFORE the bridge
|
|
292
|
+
# becomes purge-eligible, so a held lock means the tail is not done.
|
|
293
|
+
unless keep
|
|
294
|
+
dir = bridge_intent_dir(data)
|
|
295
|
+
keep = !dir.nil? && File.exist?(Lock.path(dir))
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
next if keep
|
|
153
299
|
File.delete(f)
|
|
154
300
|
removed << f
|
|
155
301
|
rescue Errno::ENOENT
|
|
@@ -161,21 +307,39 @@ module Bridge
|
|
|
161
307
|
end
|
|
162
308
|
removed
|
|
163
309
|
rescue => e
|
|
164
|
-
$stderr.puts "plastic:
|
|
310
|
+
$stderr.puts "plastic: purge_done_bridges failed: #{e.message}"
|
|
165
311
|
removed || []
|
|
166
312
|
end
|
|
167
313
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
314
|
+
# Try the per-intent path first; when it is absent and an intent_id was
|
|
315
|
+
# given, fall back to the legacy single-key path (migration + legacy
|
|
316
|
+
# tolerance, intent 131): a live `plastic-<session>.json` from before this
|
|
317
|
+
# intent keeps resolving during the transition. The legacy fallback is
|
|
318
|
+
# honored for a specific intent_id ONLY when the legacy file actually carries
|
|
319
|
+
# that intent (or carries none), so a caller asking for intent A never acts
|
|
320
|
+
# on a legacy file that still holds sibling B.
|
|
321
|
+
def self.read(session, intent_id: nil, tmp: tmp_dir)
|
|
322
|
+
p = path(session, intent_id: intent_id, tmp: tmp)
|
|
323
|
+
return JSON.parse(File.read(p)) if File.exist?(p)
|
|
324
|
+
return nil if blank?(intent_id)
|
|
325
|
+
legacy = path(session, tmp: tmp)
|
|
326
|
+
return nil unless File.exist?(legacy)
|
|
327
|
+
data = JSON.parse(File.read(legacy))
|
|
328
|
+
id = data.is_a?(Hash) ? data.dig("intent", "id") : nil
|
|
329
|
+
(blank?(id) || id.to_s == intent_id.to_s) ? data : nil
|
|
172
330
|
rescue JSON::ParserError
|
|
173
331
|
nil
|
|
174
332
|
end
|
|
175
333
|
|
|
334
|
+
# Self-keying (intent 131): the file `write` targets is derived from
|
|
335
|
+
# `data.dig("intent", "id")`, not a caller-supplied intent_id, so every
|
|
336
|
+
# existing `write(session, data)` call site keys itself correctly for free
|
|
337
|
+
# as long as `data["intent"]["id"]` is set (arm/derive/disarm_auto/
|
|
338
|
+
# repair_lock/hook-gate-check/plastic-lock all carry it).
|
|
176
339
|
def self.write(session, data, tmp: tmp_dir)
|
|
177
340
|
raise ArgumentError, "bridge session must be present" if blank?(session)
|
|
178
|
-
|
|
341
|
+
intent_id = data.is_a?(Hash) ? data.dig("intent", "id") : nil
|
|
342
|
+
p = path(session, intent_id: intent_id, tmp: tmp)
|
|
179
343
|
# Atomic write: tmp file + rename to prevent partial reads
|
|
180
344
|
tmp_file = "#{p}.tmp.#{Process.pid}"
|
|
181
345
|
File.write(tmp_file, JSON.pretty_generate(data.merge("updated_at" => Time.now.utc.iso8601)))
|
|
@@ -233,6 +397,51 @@ module Bridge
|
|
|
233
397
|
end
|
|
234
398
|
end
|
|
235
399
|
|
|
400
|
+
# --- Gate-boundary narration (intent 84, Lever 1) -------------------------
|
|
401
|
+
#
|
|
402
|
+
# ONE concise sentence that states what happened AND what's next, preserving
|
|
403
|
+
# the `Next: ...` hint the agent consumes. Pure and side-effect-free so the
|
|
404
|
+
# hook stays a thin caller and the formatter is unit-testable in isolation.
|
|
405
|
+
# No "Stage transition: X -> Y" prose, no arrow; a colon/parentheses carry the
|
|
406
|
+
# stage word. Returns a single line (no embedded newlines).
|
|
407
|
+
STAGE_LABELS = {
|
|
408
|
+
"what" => "What", "why" => "Why", "how" => "How",
|
|
409
|
+
"exec" => "Exec", "done" => "Done"
|
|
410
|
+
}.freeze
|
|
411
|
+
|
|
412
|
+
NEXT_HINTS = {
|
|
413
|
+
"why" => "write spec.md",
|
|
414
|
+
"how" => "Why complete. Invoke plastic-auto to deliver autonomously, or write plan.md manually.",
|
|
415
|
+
"exec" => "How complete. Invoke plastic-auto or plastic-executing-plan to execute, or work through the checklist manually.",
|
|
416
|
+
"done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic-auto or do it manually."
|
|
417
|
+
}.freeze
|
|
418
|
+
|
|
419
|
+
def self.stage_label(stage)
|
|
420
|
+
STAGE_LABELS[stage] || stage.to_s
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Build the gate-hook `additionalContext` sentence.
|
|
424
|
+
# transition: "PLASTIC: How reached (plan.md written). Next: <hint>"
|
|
425
|
+
# same-stage write: "PLASTIC: plan.md written (How). Next: <hint>"
|
|
426
|
+
# `new_missing` (missing files for the new stage) takes precedence over the
|
|
427
|
+
# stage hint, exactly as before, so the `Next:` content is unchanged.
|
|
428
|
+
def self.gate_narration(old_stage:, new_stage:, basename:, new_missing:, next_hints: NEXT_HINTS)
|
|
429
|
+
head = if old_stage != new_stage
|
|
430
|
+
"PLASTIC: #{stage_label(new_stage)} reached (#{basename} written)."
|
|
431
|
+
else
|
|
432
|
+
"PLASTIC: #{basename} written (#{stage_label(new_stage)})."
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
nxt =
|
|
436
|
+
if Array(new_missing).any?
|
|
437
|
+
"Next: #{Array(new_missing).join(", ")}"
|
|
438
|
+
elsif next_hints[new_stage]
|
|
439
|
+
"Next: #{next_hints[new_stage]}"
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
nxt ? "#{head} #{nxt}" : head
|
|
443
|
+
end
|
|
444
|
+
|
|
236
445
|
# --- Cycle-step savepoint ledger (intent 34) ------------------------------
|
|
237
446
|
#
|
|
238
447
|
# savepoint.md is a deterministic, append-only, one-line-per-milestone ledger
|
|
@@ -265,8 +474,31 @@ module Bridge
|
|
|
265
474
|
end.compact
|
|
266
475
|
end
|
|
267
476
|
|
|
268
|
-
#
|
|
269
|
-
#
|
|
477
|
+
# (stage, milestone) pairs already recorded in the ledger. The pair (not the
|
|
478
|
+
# milestone text alone) is the dedup key, because state-from-ledger lines like
|
|
479
|
+
# `Why started` and `How started` share the milestone text "started" while
|
|
480
|
+
# being distinct events (intent 81).
|
|
481
|
+
def self.savepoint_recorded_pairs(intent_dir)
|
|
482
|
+
f = File.join(intent_dir, SAVEPOINT_FILE)
|
|
483
|
+
return [] unless File.exist?(f)
|
|
484
|
+
File.read(f).each_line.filter_map do |line|
|
|
485
|
+
parts = line.strip.split(/\s{2,}/)
|
|
486
|
+
parts.length >= 3 ? [parts[1], parts[2]] : nil
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Append one ledger line for (stage, milestone) unless that pair is already
|
|
491
|
+
# recorded. The single append primitive shared by every line class. Returns
|
|
492
|
+
# true when a line was written, false when it was a no-op.
|
|
493
|
+
def self.append_savepoint_line(intent_dir, stage, milestone, now)
|
|
494
|
+
return false if savepoint_recorded_pairs(intent_dir).include?([stage, milestone])
|
|
495
|
+
line = "#{now.utc.iso8601} #{stage} #{milestone}\n"
|
|
496
|
+
File.open(File.join(intent_dir, SAVEPOINT_FILE), "a") { |io| io.write(line) }
|
|
497
|
+
true
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# Append the artifact-landing milestone for file_path if (and only if) it is a
|
|
501
|
+
# milestone not already recorded. Returns true when a line was written.
|
|
270
502
|
def self.append_savepoint(intent_dir, file_path, now: Time.now)
|
|
271
503
|
basename = File.basename(file_path)
|
|
272
504
|
stage, milestone = savepoint_milestone(intent_dir, basename)
|
|
@@ -274,32 +506,109 @@ module Bridge
|
|
|
274
506
|
# A sentinel-marked lifecycle file logs NO milestone (the stage is not real
|
|
275
507
|
# yet). The intent file is never sentineled, so it still logs its What line.
|
|
276
508
|
return false unless stage_file_present?(File.join(intent_dir, basename))
|
|
277
|
-
return false if savepoint_recorded_milestones(intent_dir).include?(milestone)
|
|
278
509
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
510
|
+
append_savepoint_line(intent_dir, stage, milestone, now)
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# --- State-from-ledger: pre-stage, exec-start, and terminal lines (81) ------
|
|
514
|
+
#
|
|
515
|
+
# On top of intent 34's artifact-landing milestones, the ledger gains:
|
|
516
|
+
# - `started` lines, one per cycle stage entry (pre-stage, written by the
|
|
517
|
+
# PreToolUse savepoint hook the moment a stage's artifact is first written);
|
|
518
|
+
# - an `Exec started` companion emitted when checklist.md lands;
|
|
519
|
+
# - a terminal `Done delivered|abandoned` line written by the completion path.
|
|
520
|
+
# None of these are derivable from files on disk, so they are deliberately NOT
|
|
521
|
+
# part of savepoint_milestone and are never regenerated by rebuild_savepoint:
|
|
522
|
+
# a rebuilt ledger is the file-landing skeleton, the live ledger is richer.
|
|
523
|
+
|
|
524
|
+
# Map a written filename to the [stage, "started"] pre-stage milestone, or nil.
|
|
525
|
+
# spec.md => entering Why, plan.md => entering How. checklist.md/outcome.md do
|
|
526
|
+
# not open a stage (checklist's Exec-start is the append_exec_started companion).
|
|
527
|
+
def self.savepoint_started_milestone(basename)
|
|
528
|
+
case basename
|
|
529
|
+
when "spec.md" then ["Why", "started"]
|
|
530
|
+
when "plan.md" then ["How", "started"]
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# Append the pre-stage `started` line for file_path, iff: the basename opens a
|
|
535
|
+
# stage, the stage is genuinely starting (its artifact is not yet a REAL file,
|
|
536
|
+
# so a sentinel placeholder still counts as "starting"), and the pair is not
|
|
537
|
+
# already recorded. Returns true when a line was written.
|
|
538
|
+
def self.append_started_savepoint(intent_dir, file_path, now: Time.now)
|
|
539
|
+
basename = File.basename(file_path)
|
|
540
|
+
stage, milestone = savepoint_started_milestone(basename)
|
|
541
|
+
return false unless milestone
|
|
542
|
+
return false if stage_file_present?(File.join(intent_dir, basename))
|
|
543
|
+
|
|
544
|
+
append_savepoint_line(intent_dir, stage, milestone, now)
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# Append the `Exec started` companion (emitted when checklist.md lands, in the
|
|
548
|
+
# same PostToolUse event as the `How checklist.md created` line). Idempotent.
|
|
549
|
+
def self.append_exec_started(intent_dir, now: Time.now)
|
|
550
|
+
append_savepoint_line(intent_dir, "Exec", "started", now)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
TERMINAL_DISPOSITIONS = %w[delivered abandoned].freeze
|
|
554
|
+
|
|
555
|
+
# Append the terminal bookend `Done delivered|abandoned`, written by the
|
|
556
|
+
# completion path when an intent transfers to INDEX's Completed/Abandoned
|
|
557
|
+
# section. Idempotent per disposition. Raises on an unknown disposition.
|
|
558
|
+
def self.append_terminal_savepoint(intent_dir, disposition, now: Time.now)
|
|
559
|
+
unless TERMINAL_DISPOSITIONS.include?(disposition)
|
|
560
|
+
raise ArgumentError,
|
|
561
|
+
"disposition must be one of #{TERMINAL_DISPOSITIONS.join(', ')}, got #{disposition.inspect}"
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
append_savepoint_line(intent_dir, "Done", disposition, now)
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
# --- Tier convenience line (intent 130, D-A) ------------------------------
|
|
568
|
+
#
|
|
569
|
+
# spec.md's top `Tier: S|M|L` line is the single authoritative record of an
|
|
570
|
+
# intent's proportional-auto-sizing tier (see PLASTIC.md `## Tiers`). This
|
|
571
|
+
# reads that line only; it never validates or enforces it (convention-only,
|
|
572
|
+
# matching the skill and agent contracts). Returns nil when spec.md is
|
|
573
|
+
# absent, empty, or its first line does not match, so a missing/malformed
|
|
574
|
+
# Tier line changes nothing about existing rebuild behavior.
|
|
575
|
+
def self.savepoint_tier(intent_dir)
|
|
576
|
+
path = File.join(intent_dir, "spec.md")
|
|
577
|
+
return nil unless File.exist?(path)
|
|
578
|
+
first = File.open(path, &:gets)
|
|
579
|
+
return nil if first.nil?
|
|
580
|
+
m = first.chomp.strip.match(/\ATier:\s*(S|M|L)\z/)
|
|
581
|
+
m && m[1]
|
|
282
582
|
end
|
|
283
583
|
|
|
284
584
|
# Reconstruct the ledger from files on disk (timestamps from mtimes), in
|
|
285
585
|
# stage order, overwriting savepoint.md. Returns the number of lines written.
|
|
586
|
+
# When spec.md carries a Tier line, one convenience `Tier <value>` line is
|
|
587
|
+
# echoed right after the spec.md milestone line (same mtime), so the tier
|
|
588
|
+
# survives a rebuild without becoming a new source of truth.
|
|
286
589
|
def self.rebuild_savepoint(intent_dir)
|
|
287
590
|
ordered = [
|
|
288
591
|
File.basename(intent_file(intent_dir)),
|
|
289
592
|
"spec.md", "plan.md", "checklist.md", "outcome.md",
|
|
290
593
|
]
|
|
291
|
-
lines = ordered.
|
|
594
|
+
lines = ordered.flat_map do |basename|
|
|
292
595
|
path = File.join(intent_dir, basename)
|
|
293
|
-
next unless stage_file_present?(path)
|
|
596
|
+
next [] unless stage_file_present?(path)
|
|
294
597
|
stage, milestone = savepoint_milestone(intent_dir, basename)
|
|
295
|
-
next unless milestone
|
|
296
|
-
|
|
598
|
+
next [] unless milestone
|
|
599
|
+
stamp = File.mtime(path).utc.iso8601
|
|
600
|
+
entry = "#{stamp} #{stage} #{milestone}\n"
|
|
601
|
+
if basename == "spec.md" && (tier = savepoint_tier(intent_dir))
|
|
602
|
+
[entry, "#{stamp} Tier #{tier}\n"]
|
|
603
|
+
else
|
|
604
|
+
[entry]
|
|
605
|
+
end
|
|
297
606
|
end
|
|
298
607
|
File.write(File.join(intent_dir, SAVEPOINT_FILE), lines.join)
|
|
299
608
|
lines.length
|
|
300
609
|
end
|
|
301
610
|
|
|
302
|
-
def self.derive(session, intent_id:, intent_dir:, store:, name:)
|
|
611
|
+
def self.derive(session, intent_id:, intent_dir:, store:, name:, tmp: tmp_dir)
|
|
303
612
|
stage = derive_stage(intent_dir)
|
|
304
613
|
has = has_files(intent_dir)
|
|
305
614
|
missing = missing_for_stage(stage, intent_dir) - has
|
|
@@ -329,10 +638,28 @@ module Bridge
|
|
|
329
638
|
"context_pct" => 0,
|
|
330
639
|
"warning_at" => 80,
|
|
331
640
|
"critical_at" => 90
|
|
641
|
+
},
|
|
642
|
+
# Worktree isolation block (intent 73c). Born unprovisioned; arm_auto calls
|
|
643
|
+
# Worktree.provision to fill it. code/store are abs paths or null.
|
|
644
|
+
"worktree" => {
|
|
645
|
+
"code" => nil,
|
|
646
|
+
"code_branch" => nil,
|
|
647
|
+
"store" => nil,
|
|
648
|
+
"store_branch" => nil,
|
|
649
|
+
"provisioned" => false
|
|
650
|
+
},
|
|
651
|
+
# Delivery-lock CACHE block (intent 108, D2). The durable truth is the
|
|
652
|
+
# delivery.lock file in the intent dir; arm fills this cache from it.
|
|
653
|
+
"lock" => {
|
|
654
|
+
"owner_session" => nil,
|
|
655
|
+
"acquired_at" => nil,
|
|
656
|
+
"host" => nil,
|
|
657
|
+
"type" => nil,
|
|
658
|
+
"delegates" => []
|
|
332
659
|
}
|
|
333
660
|
}
|
|
334
661
|
|
|
335
|
-
write(session, data)
|
|
662
|
+
write(session, data, tmp: tmp)
|
|
336
663
|
data
|
|
337
664
|
end
|
|
338
665
|
|
|
@@ -391,31 +718,175 @@ module Bridge
|
|
|
391
718
|
|
|
392
719
|
# --- Auto mode (intent 27) ---
|
|
393
720
|
|
|
394
|
-
#
|
|
395
|
-
#
|
|
396
|
-
|
|
721
|
+
# Shared arming spine (intent 96): resolve the session key, derive intent state,
|
|
722
|
+
# set the caller-controlled auto flag, acquire the delivery lock, provision the
|
|
723
|
+
# per-intent worktrees, persist, and purge terminal bridges. arm_auto (auto: true)
|
|
724
|
+
# and arm_guided (auto: false) are thin delegators so the lock-stamp + provision
|
|
725
|
+
# behaviour stays identical across both modes. Works even when no bridge exists
|
|
726
|
+
# yet (mid-session intent creation).
|
|
727
|
+
def self.arm(session, intent_id:, intent_dir:, store:, name:, auto:)
|
|
397
728
|
key = resolve_session(session, intent_id: intent_id, store: store)
|
|
398
|
-
if blank?(session) && blank?(ENV["
|
|
399
|
-
$stderr.puts "plastic: no session id available; arming
|
|
729
|
+
if blank?(session) && blank?(ENV["CLAUDE_CODE_SESSION_ID"])
|
|
730
|
+
$stderr.puts "plastic: no session id available; arming with derived bridge key #{key}"
|
|
400
731
|
end
|
|
401
732
|
data = derive(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
|
|
402
|
-
data["build"]["auto"] =
|
|
733
|
+
data["build"]["auto"] = auto
|
|
734
|
+
|
|
735
|
+
# Acquire the durable delivery lock (D1/D2): session-keyed, O_EXCL, in the
|
|
736
|
+
# intent dir. The bridge lock block is a cache of the file.
|
|
737
|
+
intent_dir_abs = File.expand_path(intent_dir)
|
|
738
|
+
status, lock_data = Lock.acquire(intent_dir_abs, session: key)
|
|
739
|
+
case status
|
|
740
|
+
when :acquired, :owned
|
|
741
|
+
data["lock"] = lock_cache(lock_data)
|
|
742
|
+
when :held
|
|
743
|
+
raise LockHeldError, "delivery lock for intent #{intent_id} is held by " \
|
|
744
|
+
"session #{lock_data && lock_data['owner_session']}; run /plastic-lock status"
|
|
745
|
+
when :stale
|
|
746
|
+
raise LockHeldError, "delivery lock for intent #{intent_id} is stale " \
|
|
747
|
+
"(owner #{lock_data && lock_data['owner_session']}); run /plastic-lock " \
|
|
748
|
+
"reclaim to take it over with an audit"
|
|
749
|
+
when :excluded
|
|
750
|
+
raise LockHeldError, "a #{lock_data && lock_data['type']} lock is active on " \
|
|
751
|
+
"intent #{intent_id}; run /plastic-lock status"
|
|
752
|
+
when :corrupt
|
|
753
|
+
raise LockHeldError, "delivery.lock for intent #{intent_id} is unreadable; " \
|
|
754
|
+
"run /plastic-lock fix"
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
# Provision the per-intent worktrees (mandatory code worktree for project
|
|
758
|
+
# intents; fail-open for non-git / global-only). Never let a provision error
|
|
759
|
+
# break arming: the lock and auto flag still matter.
|
|
760
|
+
begin
|
|
761
|
+
Worktree.provision(data)
|
|
762
|
+
rescue => e
|
|
763
|
+
$stderr.puts "plastic: worktree provision raised, continuing unprovisioned: #{e.message}"
|
|
764
|
+
end
|
|
765
|
+
|
|
403
766
|
write(key, data)
|
|
404
|
-
|
|
767
|
+
purge_done_bridges(session: key)
|
|
405
768
|
data
|
|
406
769
|
end
|
|
770
|
+
private_class_method :arm
|
|
771
|
+
|
|
772
|
+
# Arm auto mode for a session+intent. Works even when no bridge exists yet
|
|
773
|
+
# (mid-session intent creation). Re-derives intent state, then sets build.auto.
|
|
774
|
+
def self.arm_auto(session, intent_id:, intent_dir:, store:, name:)
|
|
775
|
+
arm(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name, auto: true)
|
|
776
|
+
end
|
|
407
777
|
|
|
408
|
-
#
|
|
409
|
-
|
|
410
|
-
|
|
778
|
+
# Acquire the delivery lock WITHOUT auto mode (intent 96 / Start guided branch).
|
|
779
|
+
# Mirrors arm_auto's lock-stamp + worktree provision but leaves build.auto = false.
|
|
780
|
+
# Same signature as arm_auto; disarm_auto (mode-agnostic) releases a guided lock.
|
|
781
|
+
def self.arm_guided(session, intent_id:, intent_dir:, store:, name:)
|
|
782
|
+
arm(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name, auto: false)
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
# Degrade path for disarm_auto when no intent_id is given (intent 131): the
|
|
786
|
+
# session's sole per-intent bridge when there is exactly one, else the
|
|
787
|
+
# legacy single-key file. Keeps the common single-intent auto path working
|
|
788
|
+
# without every caller having to name the intent id explicitly.
|
|
789
|
+
def self.sole_bridge_data(session, tmp: tmp_dir)
|
|
790
|
+
matches = Dir.glob(File.join(tmp, "plastic-#{session}--*.json")).reject { |f| f.end_with?(".tmp") }
|
|
791
|
+
if matches.length == 1
|
|
792
|
+
data = (JSON.parse(File.read(matches.first)) rescue nil)
|
|
793
|
+
return data if data
|
|
794
|
+
end
|
|
795
|
+
read(session, tmp: tmp)
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
# Disarm. No-op if no bridge exists for the session. End-tail order (D6):
|
|
799
|
+
# worktrees are merged/removed FIRST (the verify step is the caller's,
|
|
800
|
+
# before disarm), then the delivery lock is cleared, and only then does the
|
|
801
|
+
# bridge become purge-eligible. purge_done_bridges enforces the same order
|
|
802
|
+
# defensively by skipping any bridge whose intent still holds a lock.
|
|
803
|
+
#
|
|
804
|
+
# Now takes intent_id (intent 131): a session can own SEVERAL live bridges
|
|
805
|
+
# (one per concurrent intent), so disarm must target ONE of them. When
|
|
806
|
+
# intent_id is nil, degrades to the session's sole bridge (see
|
|
807
|
+
# sole_bridge_data) so the common single-intent path keeps working.
|
|
808
|
+
def self.disarm_auto(session, intent_id: nil)
|
|
809
|
+
data = blank?(intent_id) ? sole_bridge_data(session) : read(session, intent_id: intent_id)
|
|
411
810
|
return nil unless data
|
|
412
811
|
data["build"] ||= {}
|
|
413
812
|
data["build"]["auto"] = false
|
|
813
|
+
|
|
814
|
+
# Release the worktrees the matching arm provisioned (intent 73c). Non-fatal:
|
|
815
|
+
# a release error must not block disarming. CLEANUP (73c3) refines the
|
|
816
|
+
# merge-vs-remove policy on the completion/release path.
|
|
817
|
+
begin
|
|
818
|
+
Worktree.release(data)
|
|
819
|
+
rescue => e
|
|
820
|
+
$stderr.puts "plastic: worktree release raised, continuing: #{e.message}"
|
|
821
|
+
end
|
|
822
|
+
|
|
823
|
+
dir = bridge_intent_dir(data)
|
|
824
|
+
if dir
|
|
825
|
+
owner = data.dig("lock", "owner_session")
|
|
826
|
+
owner = session if blank?(owner)
|
|
827
|
+
Lock.release(dir, session: owner)
|
|
828
|
+
end
|
|
829
|
+
data["lock"] = { "owner_session" => nil, "acquired_at" => nil,
|
|
830
|
+
"host" => nil, "type" => nil, "delegates" => [] }
|
|
831
|
+
|
|
414
832
|
write(session, data)
|
|
415
|
-
|
|
833
|
+
purge_done_bridges(session: session)
|
|
416
834
|
data
|
|
417
835
|
end
|
|
418
836
|
|
|
837
|
+
# One deterministic, idempotent repair (intent 108, D5): diagnose, remove
|
|
838
|
+
# faulty own-side state, rebuild the durable lock AND the bridge cache from
|
|
839
|
+
# disk truth for the current session. Legacy /tmp-only pid locks are
|
|
840
|
+
# migrated here: the delivery.lock file is created and the cache rebuilt
|
|
841
|
+
# without a pid. NEVER touches a fresh foreign lock (reports "held"); a
|
|
842
|
+
# stale foreign lock reports "stale" and is taken only by the explicit
|
|
843
|
+
# reclaim verb (Lock.takeover). Two entry points call this: the
|
|
844
|
+
# plastic-lock CLI and /plastic-intent-starting (self-healing boarding).
|
|
845
|
+
def self.repair_lock(session, intent_id:, intent_dir:, store:, name:,
|
|
846
|
+
now: Time.now, tmp: tmp_dir)
|
|
847
|
+
key = resolve_session(session, intent_id: intent_id, store: store)
|
|
848
|
+
dir = File.expand_path(intent_dir)
|
|
849
|
+
actions = []
|
|
850
|
+
|
|
851
|
+
if Lock.corrupt?(dir)
|
|
852
|
+
File.delete(Lock.path(dir))
|
|
853
|
+
actions << "removed corrupt delivery.lock"
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
lock = Lock.read(dir)
|
|
857
|
+
if lock && !Lock.authorized?(lock, key)
|
|
858
|
+
if Lock.fresh?(dir, now: now)
|
|
859
|
+
return { "status" => "held", "owner" => lock["owner_session"],
|
|
860
|
+
"actions" => actions, "session" => key }
|
|
861
|
+
end
|
|
862
|
+
return { "status" => "stale", "owner" => lock["owner_session"],
|
|
863
|
+
"actions" => actions, "session" => key,
|
|
864
|
+
"hint" => "run /plastic-lock reclaim to take over with an audit" }
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
if lock
|
|
868
|
+
Lock.heartbeat(dir, session: key, now: now)
|
|
869
|
+
lock_data = Lock.read(dir)
|
|
870
|
+
role = lock_data["owner_session"].to_s == key ? "owner" : "delegate"
|
|
871
|
+
actions << "lock kept (#{role})"
|
|
872
|
+
else
|
|
873
|
+
status, lock_data = Lock.acquire(dir, session: key, now: now)
|
|
874
|
+
actions << "lock #{status}"
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
previous = read(key, intent_id: intent_id, tmp: tmp)
|
|
878
|
+
auto = !!(previous && previous.dig("build", "auto"))
|
|
879
|
+
data = derive(key, intent_id: intent_id, intent_dir: dir, store: store,
|
|
880
|
+
name: name, tmp: tmp)
|
|
881
|
+
data["build"]["auto"] = auto
|
|
882
|
+
data["lock"] = lock_cache(lock_data)
|
|
883
|
+
write(key, data, tmp: tmp)
|
|
884
|
+
actions << "bridge rebuilt from disk (stage #{data['build']['stage']})"
|
|
885
|
+
|
|
886
|
+
{ "status" => "repaired", "actions" => actions,
|
|
887
|
+
"lock" => lock_data, "session" => key }
|
|
888
|
+
end
|
|
889
|
+
|
|
419
890
|
# Decide whether a code edit should be blocked while auto mode is armed.
|
|
420
891
|
# Returns a reason string to BLOCK, or nil to ALLOW.
|
|
421
892
|
#
|
|
@@ -450,6 +921,253 @@ module Bridge
|
|
|
450
921
|
"(blocked edit: #{file_abs})"
|
|
451
922
|
end
|
|
452
923
|
|
|
924
|
+
# --- Solo-mode detection (intent 128) ---------------------------------------
|
|
925
|
+
#
|
|
926
|
+
# Positive-only confirmation that exactly one session is delivering, from the
|
|
927
|
+
# durable delivery.lock files (never the /tmp bridge cache, D2). Used to relax
|
|
928
|
+
# the two ARBITRATION gates (lock_gate_decision, worktree_gate_decision) from
|
|
929
|
+
# a hard deny to an advisory allow when there is nothing to arbitrate.
|
|
930
|
+
#
|
|
931
|
+
# SOLO iff exactly ONE fresh delivery.lock exists across scan_roots, that
|
|
932
|
+
# lock's owner_session equals the resolved session, and its delegates array
|
|
933
|
+
# is empty. Any ambiguity (more than one fresh lock, including several under
|
|
934
|
+
# the SAME owner_session, which reads as parallel-in-play), a foreign owner,
|
|
935
|
+
# a non-empty delegates array, a blank/unresolvable session, or any error
|
|
936
|
+
# during the scan all return false (fail-closed direction preserved).
|
|
937
|
+
def self.solo_delivery?(scan_roots:, session:, ttl: Lock::TTL_SECONDS, now: Time.now)
|
|
938
|
+
return false if blank?(session)
|
|
939
|
+
|
|
940
|
+
lock_dirs = Array(scan_roots).compact.flat_map { |root|
|
|
941
|
+
Dir.glob(File.join(File.expand_path(root), "*", "delivery.lock"))
|
|
942
|
+
}.uniq.map { |lock_file| File.dirname(lock_file) }
|
|
943
|
+
|
|
944
|
+
fresh_dirs = lock_dirs.select { |dir| Lock.fresh?(dir, ttl: ttl, now: now) }
|
|
945
|
+
fresh_locks = fresh_dirs.map { |dir| Lock.read(dir) }
|
|
946
|
+
|
|
947
|
+
# A fresh-but-unreadable (corrupt) lock is real ambiguity, not an absence:
|
|
948
|
+
# dropping it via filter_map could leave exactly one READABLE lock and
|
|
949
|
+
# misconfirm solo while a second, unreadable-but-live lock is in play.
|
|
950
|
+
# Any unreadable fresh lock keeps this fail-closed (review finding 2).
|
|
951
|
+
return false if fresh_locks.any?(&:nil?)
|
|
952
|
+
return false unless fresh_locks.length == 1
|
|
953
|
+
|
|
954
|
+
lock = fresh_locks.first
|
|
955
|
+
lock["owner_session"].to_s == session.to_s && Array(lock["delegates"]).empty?
|
|
956
|
+
rescue StandardError
|
|
957
|
+
false
|
|
958
|
+
end
|
|
959
|
+
|
|
960
|
+
# One terse advisory line (no em-dashes), then ALLOW (nil). Shared by both
|
|
961
|
+
# arbitration gates so a relaxed deny always logs the same shape.
|
|
962
|
+
def self.solo_allow(id, reason)
|
|
963
|
+
$stderr.puts "plastic: solo delivery confirmed for intent #{id} (#{reason}); allowing"
|
|
964
|
+
nil
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
# --- Fail-closed lock gate (intent 96) -------------------------------------
|
|
968
|
+
|
|
969
|
+
# Returns a reason String to BLOCK, or nil to ALLOW. Decides from the
|
|
970
|
+
# durable delivery.lock in the TARGET intent dir (D2): the bridge argument
|
|
971
|
+
# only supplies a fallback session id, so a missing or disagreeing bridge
|
|
972
|
+
# never changes the verdict. Every deny names the exact resolving command
|
|
973
|
+
# (D5). ALLOW: non-intent paths, not-yet-active intents, and any session the
|
|
974
|
+
# target's lock names as owner or delegate (even when stale: a stale lock is
|
|
975
|
+
# still its owner's until an explicit takeover).
|
|
976
|
+
def self.lock_gate_decision(bridge_data, file_path, session: nil,
|
|
977
|
+
ttl: Lock::TTL_SECONDS, now: Time.now, home: Dir.home)
|
|
978
|
+
return nil if blank?(file_path)
|
|
979
|
+
|
|
980
|
+
target_dir = intent_dir_for(file_path)
|
|
981
|
+
return nil unless target_dir
|
|
982
|
+
id = intent_id_from_dir(target_dir)
|
|
983
|
+
store = File.dirname(target_dir)
|
|
984
|
+
return nil unless id && intent_active?(id, store: store)
|
|
985
|
+
|
|
986
|
+
sess = session
|
|
987
|
+
sess = bridge_data["session"] if blank?(sess) && bridge_data.is_a?(Hash)
|
|
988
|
+
|
|
989
|
+
# Solo-mode detection (intent 128): scan this intent's store plus the
|
|
990
|
+
# global store under `home` for fresh delivery locks. Computed once; used
|
|
991
|
+
# at every arbitration deny below to relax a hard deny to an advisory
|
|
992
|
+
# allow when solo delivery is positively confirmed.
|
|
993
|
+
scan_roots = [store, File.join(File.expand_path(home), ".plastic", "store")]
|
|
994
|
+
solo = solo_delivery?(scan_roots: scan_roots, session: sess, ttl: ttl, now: now)
|
|
995
|
+
|
|
996
|
+
lock = Lock.read(target_dir)
|
|
997
|
+
if lock
|
|
998
|
+
return nil if Lock.authorized?(lock, sess)
|
|
999
|
+
if Lock.fresh?(target_dir, ttl: ttl, now: now)
|
|
1000
|
+
return solo_allow(id, "fresh delivery lock") if solo
|
|
1001
|
+
return "intent #{id} delivery lock is held by session " \
|
|
1002
|
+
"#{lock['owner_session']}. Back off; if you are the owner's " \
|
|
1003
|
+
"subagent, the owner must run: plastic-lock delegate " \
|
|
1004
|
+
"--intent-dir #{target_dir} --session <your-session-id>. " \
|
|
1005
|
+
"Inspect with /plastic-lock status"
|
|
1006
|
+
end
|
|
1007
|
+
return solo_allow(id, "stale delivery lock") if solo
|
|
1008
|
+
return "intent #{id} has a stale delivery lock (owner " \
|
|
1009
|
+
"#{lock['owner_session']}); run /plastic-lock reclaim to take " \
|
|
1010
|
+
"it over, or /plastic-lock fix"
|
|
1011
|
+
end
|
|
1012
|
+
if Lock.corrupt?(target_dir)
|
|
1013
|
+
return solo_allow(id, "unreadable delivery.lock") if solo
|
|
1014
|
+
return "delivery.lock for intent #{id} is unreadable; run /plastic-lock fix"
|
|
1015
|
+
end
|
|
1016
|
+
return solo_allow(id, "no delivery lock") if solo
|
|
1017
|
+
"no delivery lock held for intent #{id}; run /plastic-intent-starting " \
|
|
1018
|
+
"to lock and begin"
|
|
1019
|
+
end
|
|
1020
|
+
|
|
1021
|
+
# A session holds an intent's lock iff the durable delivery.lock in the
|
|
1022
|
+
# intent dir names it as owner or delegate (D1/D4). The bridge is only a
|
|
1023
|
+
# cache: the lock FILE decides, so a wiped /tmp or a clobbered bridge never
|
|
1024
|
+
# strands the owner. No pid is consulted anywhere.
|
|
1025
|
+
def self.holds_live_lock?(bridge_data, session: nil)
|
|
1026
|
+
sess = session
|
|
1027
|
+
sess = bridge_data["session"] if blank?(sess) && bridge_data.is_a?(Hash)
|
|
1028
|
+
return false if blank?(sess)
|
|
1029
|
+
dir = bridge_intent_dir(bridge_data)
|
|
1030
|
+
return false unless dir
|
|
1031
|
+
Lock.holds?(dir, session: sess)
|
|
1032
|
+
end
|
|
1033
|
+
|
|
1034
|
+
# "<id>" from a ".../store/<id>--<slug>" dir, else nil.
|
|
1035
|
+
def self.intent_id_from_dir(dir)
|
|
1036
|
+
base = File.basename(dir.to_s)
|
|
1037
|
+
base.include?("--") ? base.split("--", 2).first : nil
|
|
1038
|
+
end
|
|
1039
|
+
|
|
1040
|
+
# --- Worktree isolation gate (intent 73c2) ---
|
|
1041
|
+
|
|
1042
|
+
# Returns a reason String to BLOCK, or nil to ALLOW. Two independent rules,
|
|
1043
|
+
# both fail-open by construction:
|
|
1044
|
+
#
|
|
1045
|
+
# 1. When the bridge has a provisioned code worktree, a code edit (a target
|
|
1046
|
+
# outside ~/.plastic and outside this intent's store dir) MUST land inside
|
|
1047
|
+
# worktree["code"]; otherwise BLOCK and name the expected worktree path.
|
|
1048
|
+
# 2. When the target lives inside ANOTHER intent's store dir whose bridge lock
|
|
1049
|
+
# is held by a LIVE non-owner session, BLOCK (non-owner edit to an active
|
|
1050
|
+
# intent).
|
|
1051
|
+
#
|
|
1052
|
+
# Fails open (returns nil) when provisioned is false (non-git / global-only) or
|
|
1053
|
+
# the bridge carries no worktree/lock blocks. Logs nothing on the allow path.
|
|
1054
|
+
def self.worktree_gate_decision(bridge_data, file_path, home: Dir.home, current_session: nil)
|
|
1055
|
+
return nil unless bridge_data.is_a?(Hash)
|
|
1056
|
+
return nil if blank?(file_path)
|
|
1057
|
+
|
|
1058
|
+
file_abs = File.expand_path(file_path.to_s)
|
|
1059
|
+
plastic_home = File.expand_path(File.join(home, ".plastic"))
|
|
1060
|
+
under_plastic = file_abs == plastic_home || file_abs.start_with?("#{plastic_home}/")
|
|
1061
|
+
|
|
1062
|
+
intent_info = bridge_data["intent"] || {}
|
|
1063
|
+
store = intent_info["store"]
|
|
1064
|
+
dir = intent_info["dir"]
|
|
1065
|
+
intent_dir_abs = (store && dir) ? File.expand_path("#{store}/#{dir}") : nil
|
|
1066
|
+
under_own_intent = intent_dir_abs &&
|
|
1067
|
+
(file_abs == intent_dir_abs || file_abs.start_with?("#{intent_dir_abs}/"))
|
|
1068
|
+
|
|
1069
|
+
# Solo-mode detection (intent 128): current session first, else the
|
|
1070
|
+
# bridge's own session; scan roots are this intent's store, the global
|
|
1071
|
+
# store under `home`, AND the EDIT TARGET's own store (when the target
|
|
1072
|
+
# lives inside a store dir), so a live foreign lock on the intent being
|
|
1073
|
+
# edited is never invisible to the scan just because it belongs to a
|
|
1074
|
+
# different project than the acting bridge's own store (review finding 1;
|
|
1075
|
+
# duplicate roots are harmless, solo_delivery? dedupes). Computed once;
|
|
1076
|
+
# used by both rules below.
|
|
1077
|
+
sess = blank?(current_session) ? bridge_data["session"] : current_session
|
|
1078
|
+
target_store = parse_store_target(file_abs, plastic_home)&.fetch(:store, nil)
|
|
1079
|
+
scan_roots = [store, File.join(plastic_home, "store"), target_store]
|
|
1080
|
+
solo = solo_delivery?(scan_roots: scan_roots, session: sess)
|
|
1081
|
+
|
|
1082
|
+
# Rule 1 (fixed in intent 108, D7): confinement applies ONLY to paths
|
|
1083
|
+
# inside the project repo. The repo root is derived from the provisioned
|
|
1084
|
+
# code worktree path, which is <repo>/.claude/worktrees/{id}--{slug} by
|
|
1085
|
+
# construction, so no git call is needed. Paths outside the repo (agent
|
|
1086
|
+
# memory dirs, scratch files, unrelated checkouts) are not this gate's
|
|
1087
|
+
# business; the 2026-07-02 memory-dir denial came from treating everything
|
|
1088
|
+
# outside the worktree as the shared checkout.
|
|
1089
|
+
worktree = bridge_data["worktree"] || {}
|
|
1090
|
+
if worktree["provisioned"] == true
|
|
1091
|
+
code = worktree["code"].to_s
|
|
1092
|
+
if !blank?(code) && !under_plastic && !under_own_intent
|
|
1093
|
+
code_abs = File.expand_path(code)
|
|
1094
|
+
repo_abs = File.expand_path(File.join(code_abs, "..", "..", ".."))
|
|
1095
|
+
inside_repo = file_abs == repo_abs || file_abs.start_with?("#{repo_abs}/")
|
|
1096
|
+
inside_code = file_abs == code_abs || file_abs.start_with?("#{code_abs}/")
|
|
1097
|
+
if inside_repo && !inside_code
|
|
1098
|
+
id = intent_info["id"]
|
|
1099
|
+
return solo_allow(id, "worktree confinement") if solo
|
|
1100
|
+
return "intent #{id} is isolated to its worktree - edit project code " \
|
|
1101
|
+
"inside #{code_abs}, not the shared checkout. (blocked edit: #{file_abs})"
|
|
1102
|
+
end
|
|
1103
|
+
end
|
|
1104
|
+
end
|
|
1105
|
+
|
|
1106
|
+
# Rule 2: do not edit another intent's locked, live store dir.
|
|
1107
|
+
if under_plastic
|
|
1108
|
+
reason = non_owner_store_edit_reason(file_abs, plastic_home, intent_dir_abs,
|
|
1109
|
+
home: home, current_session: current_session,
|
|
1110
|
+
own_session: bridge_data["session"])
|
|
1111
|
+
if reason
|
|
1112
|
+
return solo_allow(intent_info["id"], "non-owner store edit") if solo
|
|
1113
|
+
return reason
|
|
1114
|
+
end
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1117
|
+
nil
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
# Helper for rule 2. A store dir is `<plastic_home>/store/{id}--{slug}` (global)
|
|
1121
|
+
# or `<plastic_home>/projects/{slug}/store/{id}--{slug}` (project). When the
|
|
1122
|
+
# edit target sits inside such a dir that is NOT this intent's own dir, and a
|
|
1123
|
+
# live non-owner session holds that intent's bridge lock, BLOCK.
|
|
1124
|
+
def self.non_owner_store_edit_reason(file_abs, plastic_home, own_intent_dir_abs,
|
|
1125
|
+
home:, current_session:, own_session:)
|
|
1126
|
+
return nil if own_intent_dir_abs &&
|
|
1127
|
+
(file_abs == own_intent_dir_abs || file_abs.start_with?("#{own_intent_dir_abs}/"))
|
|
1128
|
+
|
|
1129
|
+
parsed = parse_store_target(file_abs, plastic_home)
|
|
1130
|
+
return nil unless parsed
|
|
1131
|
+
|
|
1132
|
+
session = blank?(current_session) ? own_session : current_session
|
|
1133
|
+
held = Worktree.lock_held_by_other?(
|
|
1134
|
+
intent_id: parsed[:id], store: parsed[:store],
|
|
1135
|
+
current_session: session, home: home,
|
|
1136
|
+
)
|
|
1137
|
+
return nil unless held
|
|
1138
|
+
|
|
1139
|
+
"intent #{parsed[:id]} is owned by another live session — its delivery lock " \
|
|
1140
|
+
"is held elsewhere. Back off; do not edit #{file_abs}."
|
|
1141
|
+
end
|
|
1142
|
+
|
|
1143
|
+
# Resolve an edit target inside a store to {id:, store:} for the intent dir it
|
|
1144
|
+
# belongs to, or nil if the path is not inside an `{id}--{slug}` intent dir.
|
|
1145
|
+
def self.parse_store_target(file_abs, plastic_home)
|
|
1146
|
+
rels = []
|
|
1147
|
+
global_store = File.join(plastic_home, "store")
|
|
1148
|
+
if file_abs.start_with?("#{global_store}/")
|
|
1149
|
+
rels << [file_abs[(global_store.length + 1)..], global_store]
|
|
1150
|
+
end
|
|
1151
|
+
projects = File.join(plastic_home, "projects")
|
|
1152
|
+
if file_abs.start_with?("#{projects}/")
|
|
1153
|
+
tail = file_abs[(projects.length + 1)..].to_s
|
|
1154
|
+
parts = tail.split(File::SEPARATOR)
|
|
1155
|
+
if parts.length >= 2 && parts[1] == "store"
|
|
1156
|
+
pstore = File.join(projects, parts[0], "store")
|
|
1157
|
+
rels << [file_abs[(pstore.length + 1)..], pstore]
|
|
1158
|
+
end
|
|
1159
|
+
end
|
|
1160
|
+
|
|
1161
|
+
rels.each do |rel, store_dir|
|
|
1162
|
+
next if blank?(rel)
|
|
1163
|
+
first = rel.split(File::SEPARATOR).first.to_s
|
|
1164
|
+
idx = first.index("--")
|
|
1165
|
+
next unless idx && idx > 0
|
|
1166
|
+
return { id: first[0...idx], store: store_dir }
|
|
1167
|
+
end
|
|
1168
|
+
nil
|
|
1169
|
+
end
|
|
1170
|
+
|
|
453
1171
|
# --- Bash-edit gate (intent 27a) ---
|
|
454
1172
|
|
|
455
1173
|
# Extract the set of file paths a Bash command writes to. Conservative by
|
|
@@ -466,16 +1184,22 @@ module Bridge
|
|
|
466
1184
|
# Split on command separators for per-segment utility parsing.
|
|
467
1185
|
command.split(/[;\n]|&&|\|\||\|/).each do |segment|
|
|
468
1186
|
targets.concat(bash_utility_targets(segment))
|
|
1187
|
+
targets.concat(interpreter_write_targets(segment))
|
|
469
1188
|
end
|
|
470
1189
|
targets.uniq
|
|
471
1190
|
end
|
|
472
1191
|
|
|
473
1192
|
# Redirections: `> path` / `>> path`, but not fd dups (`2>&1`) or /dev/null.
|
|
474
1193
|
# A leading digit (fd number) before > is fine; `>&` is a dup and excluded.
|
|
1194
|
+
# Quote- and heredoc-aware: a `>` inside a single/double-quoted span or inside a
|
|
1195
|
+
# heredoc body is NOT a redirect. Fails OPEN (returns []) on an ambiguous parse
|
|
1196
|
+
# (unbalanced quote or unterminated heredoc) rather than guessing a target.
|
|
475
1197
|
def self.bash_redirect_targets(command)
|
|
1198
|
+
return [] unless command.is_a?(String)
|
|
1199
|
+
scannable = scannable_redirect_text(command)
|
|
1200
|
+
return [] if scannable.nil? # ambiguous parse -> fail open
|
|
476
1201
|
targets = []
|
|
477
|
-
|
|
478
|
-
command.scan(/\d*>>?(?!&)\s*([^\s;|&<>]+)/) do |m|
|
|
1202
|
+
scannable.scan(/\d*>>?(?!&)\s*([^\s;|&<>]+)/) do |m|
|
|
479
1203
|
path = m[0]
|
|
480
1204
|
next if path.nil? || path.empty?
|
|
481
1205
|
next if dev_null?(path)
|
|
@@ -484,6 +1208,84 @@ module Bridge
|
|
|
484
1208
|
targets
|
|
485
1209
|
end
|
|
486
1210
|
|
|
1211
|
+
# Return a copy of `command` in which single-quoted spans, double-quoted spans,
|
|
1212
|
+
# and heredoc bodies are blanked to spaces, so the redirect regex only ever sees
|
|
1213
|
+
# operators that are genuinely outside quotes and heredoc bodies. Returns nil on
|
|
1214
|
+
# an ambiguous parse (a line ends inside a quote, or a heredoc is never closed).
|
|
1215
|
+
def self.scannable_redirect_text(command)
|
|
1216
|
+
out = +""
|
|
1217
|
+
pending = [] # queue of {word:, dash:} heredoc terminators awaiting bodies
|
|
1218
|
+
command.split("\n", -1).each do |line|
|
|
1219
|
+
if pending.any?
|
|
1220
|
+
term = pending.first
|
|
1221
|
+
probe = term[:dash] ? line.sub(/\A\t+/, "") : line
|
|
1222
|
+
pending.shift if probe == term[:word]
|
|
1223
|
+
out << "\n" # heredoc body/terminator line contributes nothing scannable
|
|
1224
|
+
next
|
|
1225
|
+
end
|
|
1226
|
+
masked, openers, balanced = mask_redirect_line(line)
|
|
1227
|
+
return nil unless balanced # unbalanced quote on this line -> ambiguous
|
|
1228
|
+
out << masked << "\n"
|
|
1229
|
+
pending.concat(openers)
|
|
1230
|
+
end
|
|
1231
|
+
return nil if pending.any? # unterminated heredoc -> ambiguous
|
|
1232
|
+
out
|
|
1233
|
+
end
|
|
1234
|
+
|
|
1235
|
+
# Walk one normal (non-heredoc-body) line, masking quoted spans to spaces and
|
|
1236
|
+
# recognizing heredoc openers. Returns [masked_line, [heredoc_openers], balanced?].
|
|
1237
|
+
def self.mask_redirect_line(line)
|
|
1238
|
+
out = +""
|
|
1239
|
+
openers = []
|
|
1240
|
+
state = :normal
|
|
1241
|
+
i = 0
|
|
1242
|
+
n = line.length
|
|
1243
|
+
while i < n
|
|
1244
|
+
c = line[i]
|
|
1245
|
+
case state
|
|
1246
|
+
when :single
|
|
1247
|
+
if c == "'" || c == "<" || c == ">"
|
|
1248
|
+
out << " "
|
|
1249
|
+
else
|
|
1250
|
+
out << c
|
|
1251
|
+
end
|
|
1252
|
+
state = :normal if c == "'"
|
|
1253
|
+
i += 1
|
|
1254
|
+
when :double
|
|
1255
|
+
if c == "\\" && i + 1 < n
|
|
1256
|
+
out << " "
|
|
1257
|
+
i += 2
|
|
1258
|
+
else
|
|
1259
|
+
if c == '"' || c == "<" || c == ">"
|
|
1260
|
+
out << " "
|
|
1261
|
+
else
|
|
1262
|
+
out << c
|
|
1263
|
+
end
|
|
1264
|
+
state = :normal if c == '"'
|
|
1265
|
+
i += 1
|
|
1266
|
+
end
|
|
1267
|
+
else # :normal
|
|
1268
|
+
if c == "'"
|
|
1269
|
+
out << " "; state = :single; i += 1
|
|
1270
|
+
elsif c == '"'
|
|
1271
|
+
out << " "; state = :double; i += 1
|
|
1272
|
+
elsif c == "<" && line[i + 1] == "<"
|
|
1273
|
+
m = line[i..].match(/\A<<(-?)\s*("|')?([A-Za-z0-9_][A-Za-z0-9_]*)\2?/)
|
|
1274
|
+
if m
|
|
1275
|
+
openers << { word: m[3], dash: m[1] == "-" }
|
|
1276
|
+
out << (" " * m[0].length)
|
|
1277
|
+
i += m[0].length
|
|
1278
|
+
else
|
|
1279
|
+
out << "<<"; i += 2 # here-string / no valid word: leave as-is
|
|
1280
|
+
end
|
|
1281
|
+
else
|
|
1282
|
+
out << c; i += 1
|
|
1283
|
+
end
|
|
1284
|
+
end
|
|
1285
|
+
end
|
|
1286
|
+
[out, openers, state == :normal]
|
|
1287
|
+
end
|
|
1288
|
+
|
|
487
1289
|
def self.bash_utility_targets(segment)
|
|
488
1290
|
tokens = segment.strip.split(/\s+/)
|
|
489
1291
|
return [] if tokens.empty?
|
|
@@ -541,18 +1343,56 @@ module Bridge
|
|
|
541
1343
|
path == "/dev/null" || path.start_with?("/dev/")
|
|
542
1344
|
end
|
|
543
1345
|
|
|
544
|
-
#
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
1346
|
+
# --- Interpreter inline-code writes (intent 108, D7) ---
|
|
1347
|
+
|
|
1348
|
+
INTERPRETER_RE = /\b(ruby|python3?|perl|node)\b(?:\s+\S+)*?\s+(-e|-c)\s+(.+)\z/m.freeze
|
|
1349
|
+
|
|
1350
|
+
# Write verbs that mark inline code as file-mutating. Conservative: reads
|
|
1351
|
+
# (File.read, puts) never match.
|
|
1352
|
+
WRITE_VERB_RE = /File\.(?:write|binwrite|open)|IO\.write|FileUtils\.|
|
|
1353
|
+
open\s*\([^)]*["'][wa]|writeFileSync|fs\.write/x.freeze
|
|
1354
|
+
|
|
1355
|
+
# Quoted absolute or ~/ paths inside the inline code.
|
|
1356
|
+
INLINE_PATH_RE = %r{["']((?:/|~/)[^"']+)["']}.freeze
|
|
1357
|
+
|
|
1358
|
+
# Paths an interpreter one-liner writes. Flagged only when the inline code
|
|
1359
|
+
# has BOTH a write verb AND a quoted absolute path; everything else (reads,
|
|
1360
|
+
# ARGV-driven paths, the sanctioned arm one-liners) yields no targets.
|
|
1361
|
+
def self.interpreter_write_targets(segment)
|
|
1362
|
+
m = INTERPRETER_RE.match(segment.to_s)
|
|
1363
|
+
return [] unless m
|
|
1364
|
+
util, flag, code = m[1], m[2], m[3]
|
|
1365
|
+
expected = { "ruby" => "-e", "python" => "-c", "python3" => "-c",
|
|
1366
|
+
"perl" => "-e", "node" => "-e" }[util]
|
|
1367
|
+
return [] unless flag == expected
|
|
1368
|
+
return [] unless WRITE_VERB_RE.match?(code)
|
|
1369
|
+
code.scan(INLINE_PATH_RE).flatten.map { |p| File.expand_path(p) }
|
|
1370
|
+
end
|
|
1371
|
+
|
|
1372
|
+
# Decide whether a Bash command should be blocked. Every write target runs
|
|
1373
|
+
# through the SAME policy stack as a direct tool write: the auto-mode code
|
|
1374
|
+
# gate AND the delivery-lock gate (intent 108, D7), so bash and interpreter
|
|
1375
|
+
# writes cannot bypass the lock. Returns the first block reason, or nil.
|
|
1376
|
+
def self.bash_gate_decision(bridge_data, command, cwd:, home: Dir.home, session: nil)
|
|
548
1377
|
bash_write_targets(command).each do |target|
|
|
549
1378
|
abs = File.absolute_path?(target) ? target : File.join(cwd, target)
|
|
550
|
-
|
|
1379
|
+
abs = File.expand_path(abs)
|
|
1380
|
+
reason = code_gate_decision(bridge_data, abs, home: home) ||
|
|
1381
|
+
lock_gate_decision(bridge_data, abs, session: session)
|
|
551
1382
|
return reason if reason
|
|
552
1383
|
end
|
|
553
1384
|
nil
|
|
554
1385
|
end
|
|
555
1386
|
|
|
1387
|
+
# A TRAILING `# plastic-ok` shell comment: the auditable escape for
|
|
1388
|
+
# sanctioned bash/interpreter writes (mirrors the retrieval gate's
|
|
1389
|
+
# `# qmd-ok`). The hook logs every use to ~/.plastic/.cache/gate-escapes.log.
|
|
1390
|
+
PLASTIC_OK_RE = /(?:\A|\s)#\s*plastic-ok\s*\z/.freeze
|
|
1391
|
+
|
|
1392
|
+
def self.bash_escape?(command)
|
|
1393
|
+
PLASTIC_OK_RE.match?(command.to_s.chomp)
|
|
1394
|
+
end
|
|
1395
|
+
|
|
556
1396
|
def self.deep_merge(base, overlay)
|
|
557
1397
|
result = base.dup
|
|
558
1398
|
overlay.each do |key, value|
|