@zalom/plastic 1.0.0-beta.3 → 1.0.0-beta.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PLASTIC.md +293 -7
- package/README.md +10 -0
- package/agents/plastic-brainstorming.md +2 -2
- package/agents/plastic-enforcer.md +17 -2
- package/agents/plastic-executor.md +3 -3
- package/agents/plastic-future-intent-researcher.md +2 -1
- package/agents/plastic-intent-curator.md +15 -7
- package/agents/plastic-intent-discovery.md +37 -0
- package/agents/plastic-planner.md +2 -2
- package/agents/plastic-spec-specialist.md +2 -2
- package/hooks/code-gate +10 -1
- 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 +150 -41
- package/package.json +1 -1
- package/scripts/agent-report +21 -0
- package/scripts/doctor.rb +404 -5
- package/scripts/hook-auto-arm +1 -1
- package/scripts/hook-bash-gate +22 -4
- package/scripts/hook-code-gate +11 -6
- 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/lib/agent_models.rb +43 -0
- package/scripts/lib/bridge.rb +625 -47
- package/scripts/lib/frontmatter_writer.rb +130 -0
- package/scripts/lib/graph_rebuild.rb +328 -0
- package/scripts/lib/hook_registry.rb +95 -0
- package/scripts/lib/insights.rb +86 -0
- package/scripts/lib/installer_core.rb +64 -54
- package/scripts/lib/link_suggestions.rb +319 -0
- package/scripts/lib/links_projection.rb +160 -0
- package/scripts/lib/links_section.rb +207 -0
- package/scripts/lib/lock.rb +375 -0
- package/scripts/lib/power_tools.rb +75 -0
- package/scripts/lib/qmd_hook.rb +38 -25
- package/scripts/lib/qmd_sync.rb +36 -0
- 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 +138 -29
- package/scripts/plastic-lock +164 -0
- package/scripts/project-links +287 -0
- package/scripts/qmd-sync +50 -3
- package/scripts/read-config +4 -0
- package/scripts/rebuild-graph +244 -0
- package/scripts/spawn-preamble +11 -3
- package/skills/auto/SKILL.md +98 -16
- package/skills/auto/evals/evals.json +48 -0
- package/skills/auto/references/agent-architecture.md +7 -4
- package/skills/auto/references/agent-report-contract.md +36 -1
- package/skills/brainstorming/SKILL.md +1 -0
- package/skills/brainstorming/evals/evals.json +22 -0
- package/skills/continuing/SKILL.md +30 -8
- package/skills/continuing/evals/evals.json +9 -0
- package/skills/creating-intent/SKILL.md +16 -2
- package/skills/creating-intent/evals/evals.json +16 -0
- package/skills/creating-intent/references/lifecycle.md +9 -4
- 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 +5 -0
- package/skills/dashboard/evals/evals.json +22 -0
- package/skills/doctor/SKILL.md +4 -1
- 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/intent-curator/SKILL.md +7 -1
- package/skills/intent-curator/evals/evals.json +22 -0
- package/skills/intent-discovery/SKILL.md +46 -0
- package/skills/intent-starting/SKILL.md +127 -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 +54 -12
- package/skills/linking-intents/evals/evals.json +22 -0
- package/skills/linking-intents/references/zettelkasten.md +7 -0
- package/skills/lock/SKILL.md +41 -0
- package/skills/managing-index/SKILL.md +12 -0
- package/skills/managing-index/evals/evals.json +22 -0
- package/skills/managing-index/references/zettelkasten-linking.md +6 -1
- package/skills/releasing/SKILL.md +32 -0
- package/skills/research/SKILL.md +8 -0
- package/skills/research/evals/evals.json +22 -0
- package/templates/config.yml +8 -0
- package/templates/outcome.md +3 -0
- package/templates/revisions.md +58 -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)
|
|
@@ -50,6 +55,31 @@ module Bridge
|
|
|
50
55
|
value.nil? || value.to_s.strip.empty?
|
|
51
56
|
end
|
|
52
57
|
|
|
58
|
+
# Raised by arm when the delivery lock cannot be acquired (held elsewhere,
|
|
59
|
+
# stale, excluded, or corrupt). The message names the resolving command.
|
|
60
|
+
class LockHeldError < StandardError; end
|
|
61
|
+
|
|
62
|
+
# The absolute intent dir a bridge points at, or nil.
|
|
63
|
+
def self.bridge_intent_dir(bridge_data)
|
|
64
|
+
return nil unless bridge_data.is_a?(Hash)
|
|
65
|
+
info = bridge_data["intent"] || {}
|
|
66
|
+
store = info["store"]
|
|
67
|
+
dir = info["dir"]
|
|
68
|
+
(store && dir) ? File.expand_path("#{store}/#{dir}") : nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Bridge-cache copy of the durable lock file's fields (D2: the bridge is a
|
|
72
|
+
# CACHE; the file is the truth). Never carries a pid.
|
|
73
|
+
def self.lock_cache(lock_data)
|
|
74
|
+
{
|
|
75
|
+
"owner_session" => lock_data["owner_session"],
|
|
76
|
+
"acquired_at" => lock_data["acquired_at"],
|
|
77
|
+
"host" => lock_data["host"],
|
|
78
|
+
"type" => lock_data["type"],
|
|
79
|
+
"delegates" => Array(lock_data["delegates"]),
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
53
83
|
# Deterministic, session-id-less bridge key derived from store + intent id.
|
|
54
84
|
# Stable across processes so a session-less arm and a later session-less
|
|
55
85
|
# gate-check resolve to the same bridge file.
|
|
@@ -57,12 +87,19 @@ module Bridge
|
|
|
57
87
|
"auto-" + Digest::SHA256.hexdigest("#{store}/#{intent_id}")[0, 10]
|
|
58
88
|
end
|
|
59
89
|
|
|
60
|
-
# Resolve a bridge session: first non-empty of explicit
|
|
61
|
-
# then a derived key. Never returns nil/empty.
|
|
90
|
+
# Resolve a bridge session: first non-empty of explicit (the stdin session_id),
|
|
91
|
+
# CLAUDE_CODE_SESSION_ID, then a derived key. Never returns nil/empty.
|
|
92
|
+
# Whitespace-only counts as empty.
|
|
93
|
+
#
|
|
94
|
+
# The CLAUDE_CODE_SESSION_ID fallback (intent 79) carries the bg/headless real
|
|
95
|
+
# session id (Claude Code passes session_id on stdin, not via an env var; the
|
|
96
|
+
# headless id lives in CLAUDE_CODE_SESSION_ID). Keying by the real id (instead of
|
|
97
|
+
# a derived hash) lets the statusline, which receives that same id on stdin, find
|
|
98
|
+
# the bridge by direct filename lookup.
|
|
62
99
|
def self.resolve_session(explicit, intent_id:, store:)
|
|
63
100
|
return explicit.to_s.strip unless blank?(explicit)
|
|
64
|
-
|
|
65
|
-
return
|
|
101
|
+
code_env = ENV["CLAUDE_CODE_SESSION_ID"]
|
|
102
|
+
return code_env.to_s.strip unless blank?(code_env)
|
|
66
103
|
derive_key(store, intent_id)
|
|
67
104
|
end
|
|
68
105
|
|
|
@@ -103,6 +140,22 @@ module Bridge
|
|
|
103
140
|
end
|
|
104
141
|
return nil if parsed.empty?
|
|
105
142
|
|
|
143
|
+
has_session = !blank?(session)
|
|
144
|
+
|
|
145
|
+
# Strict per-session ownership (intent 90): when the caller HAS a session, a foreign
|
|
146
|
+
# session's bridge is NEVER a valid resolution. Own-session and the derived-key case both
|
|
147
|
+
# reduce to candidate["session"] == session (the derived key IS the session that armed the
|
|
148
|
+
# bridge). A caller that owns no bridge resolves to nil, so its gates fail open instead of
|
|
149
|
+
# inheriting another session's armed intent.
|
|
150
|
+
#
|
|
151
|
+
# When the caller has NO session (truly headless, intent 52), keep the legacy degraded
|
|
152
|
+
# selection below so a single armed derived-key bridge is still discoverable - the hook
|
|
153
|
+
# cannot know the session there, and a lone armed intent must still gate.
|
|
154
|
+
if has_session
|
|
155
|
+
parsed = parsed.select { |c| c[:data]["session"].to_s == session.to_s }
|
|
156
|
+
return nil if parsed.empty?
|
|
157
|
+
end
|
|
158
|
+
|
|
106
159
|
auto = parsed.select { |c| c[:data].dig("build", "auto") == true }
|
|
107
160
|
pool = auto.empty? ? parsed : auto
|
|
108
161
|
|
|
@@ -116,31 +169,78 @@ module Bridge
|
|
|
116
169
|
cwd_abs.start_with?("#{store_abs}/") ||
|
|
117
170
|
store_abs.start_with?("#{cwd_abs}/")
|
|
118
171
|
end
|
|
119
|
-
|
|
172
|
+
# Hard cwd filter when the caller has a session (intent 90): a non-matching store
|
|
173
|
+
# excludes the candidate outright. Without a session, keep the best-effort revert
|
|
174
|
+
# (intent 52) so a lone armed bridge is still found when cwd does not overlap its store.
|
|
175
|
+
pool = has_session ? matching : (matching.empty? ? pool : matching)
|
|
120
176
|
end
|
|
121
177
|
|
|
122
178
|
pool.max_by { |c| c[:mtime] }&.fetch(:data)
|
|
123
179
|
end
|
|
124
180
|
|
|
125
|
-
# ---
|
|
126
|
-
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
#
|
|
181
|
+
# --- Terminal-state bridge purge (intent 80) -------------------------------
|
|
182
|
+
|
|
183
|
+
# True iff the intent is Active in its store's INDEX.md. An INDEX.md lives at
|
|
184
|
+
# the PARENT of the store/ dir the bridge records, so we resolve it from the
|
|
185
|
+
# bridge's intent.store. Non-raising: any failure (missing/unreadable INDEX,
|
|
186
|
+
# bad arg) returns false, which means "not active" so the caller treats the
|
|
187
|
+
# bridge as purgeable. `index_active_ids` is a pure-data test seam: when an
|
|
188
|
+
# Array of id strings is supplied, membership is checked against it directly
|
|
189
|
+
# with no file read.
|
|
190
|
+
def self.intent_active?(intent_id, store:, index_active_ids: nil)
|
|
191
|
+
target = intent_id.to_s
|
|
192
|
+
return index_active_ids.include?(target) if index_active_ids.is_a?(Array)
|
|
193
|
+
|
|
194
|
+
index = File.join(File.dirname(store.to_s), "INDEX.md")
|
|
195
|
+
return false unless File.exist?(index)
|
|
196
|
+
|
|
197
|
+
in_active = false
|
|
198
|
+
File.foreach(index) do |line|
|
|
199
|
+
stripped = line.chomp
|
|
200
|
+
if stripped == "## Active"
|
|
201
|
+
in_active = true
|
|
202
|
+
next
|
|
203
|
+
end
|
|
204
|
+
next unless in_active
|
|
205
|
+
break if stripped.start_with?("## ") # next section ends the Active block
|
|
206
|
+
m = stripped.match(/^- \[(\S+) +—/)
|
|
207
|
+
return true if m && m[1] == target
|
|
208
|
+
end
|
|
209
|
+
false
|
|
210
|
+
rescue StandardError
|
|
211
|
+
false
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Remove tmp/plastic-*.json bridge files whose intent is terminal, so
|
|
215
|
+
# discover_bridge's per-fire scan stays bounded. Best-effort and non-raising:
|
|
216
|
+
# returns the array of removed paths. A bridge is purged when it cannot be
|
|
217
|
+
# parsed, has no intent.id, has no intent.store, or its intent is not Active in
|
|
218
|
+
# its store's INDEX.md. An Active intent's bridge is kept (continuation signal +
|
|
219
|
+
# anti-collision lock), and the current session's own bridge is never purged
|
|
133
220
|
# (preserves the disarm_auto contract that it stays readable). Wired into
|
|
134
221
|
# arm_auto and disarm_auto so both manual and auto delivery keep the temp dir
|
|
135
|
-
# clean.
|
|
136
|
-
def self.
|
|
137
|
-
tmp: tmp_dir)
|
|
222
|
+
# clean at deterministic work boundaries.
|
|
223
|
+
def self.purge_done_bridges(session:, tmp: tmp_dir)
|
|
138
224
|
current = path(session, tmp: tmp)
|
|
139
225
|
removed = []
|
|
140
226
|
Dir.glob(File.join(tmp, "plastic-*.json")).each do |f|
|
|
141
227
|
next if f == current
|
|
142
228
|
begin
|
|
143
|
-
|
|
229
|
+
data = JSON.parse(File.read(f)) rescue nil
|
|
230
|
+
keep = false
|
|
231
|
+
if data
|
|
232
|
+
id = data.dig("intent", "id")
|
|
233
|
+
store = data.dig("intent", "store")
|
|
234
|
+
keep = !blank?(id) && !blank?(store) && intent_active?(id, store: store)
|
|
235
|
+
# Never purge a bridge whose intent still holds a delivery lock
|
|
236
|
+
# (intent 108, D6): the End tail clears the lock BEFORE the bridge
|
|
237
|
+
# becomes purge-eligible, so a held lock means the tail is not done.
|
|
238
|
+
unless keep
|
|
239
|
+
dir = bridge_intent_dir(data)
|
|
240
|
+
keep = !dir.nil? && File.exist?(Lock.path(dir))
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
next if keep
|
|
144
244
|
File.delete(f)
|
|
145
245
|
removed << f
|
|
146
246
|
rescue Errno::ENOENT
|
|
@@ -152,7 +252,7 @@ module Bridge
|
|
|
152
252
|
end
|
|
153
253
|
removed
|
|
154
254
|
rescue => e
|
|
155
|
-
$stderr.puts "plastic:
|
|
255
|
+
$stderr.puts "plastic: purge_done_bridges failed: #{e.message}"
|
|
156
256
|
removed || []
|
|
157
257
|
end
|
|
158
258
|
|
|
@@ -224,6 +324,51 @@ module Bridge
|
|
|
224
324
|
end
|
|
225
325
|
end
|
|
226
326
|
|
|
327
|
+
# --- Gate-boundary narration (intent 84, Lever 1) -------------------------
|
|
328
|
+
#
|
|
329
|
+
# ONE concise sentence that states what happened AND what's next, preserving
|
|
330
|
+
# the `Next: ...` hint the agent consumes. Pure and side-effect-free so the
|
|
331
|
+
# hook stays a thin caller and the formatter is unit-testable in isolation.
|
|
332
|
+
# No "Stage transition: X -> Y" prose, no arrow; a colon/parentheses carry the
|
|
333
|
+
# stage word. Returns a single line (no embedded newlines).
|
|
334
|
+
STAGE_LABELS = {
|
|
335
|
+
"what" => "What", "why" => "Why", "how" => "How",
|
|
336
|
+
"exec" => "Exec", "done" => "Done"
|
|
337
|
+
}.freeze
|
|
338
|
+
|
|
339
|
+
NEXT_HINTS = {
|
|
340
|
+
"why" => "write spec.md",
|
|
341
|
+
"how" => "Why complete. Invoke plastic-auto to deliver autonomously, or write plan.md manually.",
|
|
342
|
+
"exec" => "How complete. Invoke plastic-auto or plastic-executing-plan to execute, or work through the checklist manually.",
|
|
343
|
+
"done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic-auto or do it manually."
|
|
344
|
+
}.freeze
|
|
345
|
+
|
|
346
|
+
def self.stage_label(stage)
|
|
347
|
+
STAGE_LABELS[stage] || stage.to_s
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Build the gate-hook `additionalContext` sentence.
|
|
351
|
+
# transition: "PLASTIC: How reached (plan.md written). Next: <hint>"
|
|
352
|
+
# same-stage write: "PLASTIC: plan.md written (How). Next: <hint>"
|
|
353
|
+
# `new_missing` (missing files for the new stage) takes precedence over the
|
|
354
|
+
# stage hint, exactly as before, so the `Next:` content is unchanged.
|
|
355
|
+
def self.gate_narration(old_stage:, new_stage:, basename:, new_missing:, next_hints: NEXT_HINTS)
|
|
356
|
+
head = if old_stage != new_stage
|
|
357
|
+
"PLASTIC: #{stage_label(new_stage)} reached (#{basename} written)."
|
|
358
|
+
else
|
|
359
|
+
"PLASTIC: #{basename} written (#{stage_label(new_stage)})."
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
nxt =
|
|
363
|
+
if Array(new_missing).any?
|
|
364
|
+
"Next: #{Array(new_missing).join(", ")}"
|
|
365
|
+
elsif next_hints[new_stage]
|
|
366
|
+
"Next: #{next_hints[new_stage]}"
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
nxt ? "#{head} #{nxt}" : head
|
|
370
|
+
end
|
|
371
|
+
|
|
227
372
|
# --- Cycle-step savepoint ledger (intent 34) ------------------------------
|
|
228
373
|
#
|
|
229
374
|
# savepoint.md is a deterministic, append-only, one-line-per-milestone ledger
|
|
@@ -256,8 +401,31 @@ module Bridge
|
|
|
256
401
|
end.compact
|
|
257
402
|
end
|
|
258
403
|
|
|
259
|
-
#
|
|
260
|
-
#
|
|
404
|
+
# (stage, milestone) pairs already recorded in the ledger. The pair (not the
|
|
405
|
+
# milestone text alone) is the dedup key, because state-from-ledger lines like
|
|
406
|
+
# `Why started` and `How started` share the milestone text "started" while
|
|
407
|
+
# being distinct events (intent 81).
|
|
408
|
+
def self.savepoint_recorded_pairs(intent_dir)
|
|
409
|
+
f = File.join(intent_dir, SAVEPOINT_FILE)
|
|
410
|
+
return [] unless File.exist?(f)
|
|
411
|
+
File.read(f).each_line.filter_map do |line|
|
|
412
|
+
parts = line.strip.split(/\s{2,}/)
|
|
413
|
+
parts.length >= 3 ? [parts[1], parts[2]] : nil
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Append one ledger line for (stage, milestone) unless that pair is already
|
|
418
|
+
# recorded. The single append primitive shared by every line class. Returns
|
|
419
|
+
# true when a line was written, false when it was a no-op.
|
|
420
|
+
def self.append_savepoint_line(intent_dir, stage, milestone, now)
|
|
421
|
+
return false if savepoint_recorded_pairs(intent_dir).include?([stage, milestone])
|
|
422
|
+
line = "#{now.utc.iso8601} #{stage} #{milestone}\n"
|
|
423
|
+
File.open(File.join(intent_dir, SAVEPOINT_FILE), "a") { |io| io.write(line) }
|
|
424
|
+
true
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Append the artifact-landing milestone for file_path if (and only if) it is a
|
|
428
|
+
# milestone not already recorded. Returns true when a line was written.
|
|
261
429
|
def self.append_savepoint(intent_dir, file_path, now: Time.now)
|
|
262
430
|
basename = File.basename(file_path)
|
|
263
431
|
stage, milestone = savepoint_milestone(intent_dir, basename)
|
|
@@ -265,11 +433,62 @@ module Bridge
|
|
|
265
433
|
# A sentinel-marked lifecycle file logs NO milestone (the stage is not real
|
|
266
434
|
# yet). The intent file is never sentineled, so it still logs its What line.
|
|
267
435
|
return false unless stage_file_present?(File.join(intent_dir, basename))
|
|
268
|
-
return false if savepoint_recorded_milestones(intent_dir).include?(milestone)
|
|
269
436
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
437
|
+
append_savepoint_line(intent_dir, stage, milestone, now)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# --- State-from-ledger: pre-stage, exec-start, and terminal lines (81) ------
|
|
441
|
+
#
|
|
442
|
+
# On top of intent 34's artifact-landing milestones, the ledger gains:
|
|
443
|
+
# - `started` lines, one per cycle stage entry (pre-stage, written by the
|
|
444
|
+
# PreToolUse savepoint hook the moment a stage's artifact is first written);
|
|
445
|
+
# - an `Exec started` companion emitted when checklist.md lands;
|
|
446
|
+
# - a terminal `Done delivered|abandoned` line written by the completion path.
|
|
447
|
+
# None of these are derivable from files on disk, so they are deliberately NOT
|
|
448
|
+
# part of savepoint_milestone and are never regenerated by rebuild_savepoint:
|
|
449
|
+
# a rebuilt ledger is the file-landing skeleton, the live ledger is richer.
|
|
450
|
+
|
|
451
|
+
# Map a written filename to the [stage, "started"] pre-stage milestone, or nil.
|
|
452
|
+
# spec.md => entering Why, plan.md => entering How. checklist.md/outcome.md do
|
|
453
|
+
# not open a stage (checklist's Exec-start is the append_exec_started companion).
|
|
454
|
+
def self.savepoint_started_milestone(basename)
|
|
455
|
+
case basename
|
|
456
|
+
when "spec.md" then ["Why", "started"]
|
|
457
|
+
when "plan.md" then ["How", "started"]
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Append the pre-stage `started` line for file_path, iff: the basename opens a
|
|
462
|
+
# stage, the stage is genuinely starting (its artifact is not yet a REAL file,
|
|
463
|
+
# so a sentinel placeholder still counts as "starting"), and the pair is not
|
|
464
|
+
# already recorded. Returns true when a line was written.
|
|
465
|
+
def self.append_started_savepoint(intent_dir, file_path, now: Time.now)
|
|
466
|
+
basename = File.basename(file_path)
|
|
467
|
+
stage, milestone = savepoint_started_milestone(basename)
|
|
468
|
+
return false unless milestone
|
|
469
|
+
return false if stage_file_present?(File.join(intent_dir, basename))
|
|
470
|
+
|
|
471
|
+
append_savepoint_line(intent_dir, stage, milestone, now)
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# Append the `Exec started` companion (emitted when checklist.md lands, in the
|
|
475
|
+
# same PostToolUse event as the `How checklist.md created` line). Idempotent.
|
|
476
|
+
def self.append_exec_started(intent_dir, now: Time.now)
|
|
477
|
+
append_savepoint_line(intent_dir, "Exec", "started", now)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
TERMINAL_DISPOSITIONS = %w[delivered abandoned].freeze
|
|
481
|
+
|
|
482
|
+
# Append the terminal bookend `Done delivered|abandoned`, written by the
|
|
483
|
+
# completion path when an intent transfers to INDEX's Completed/Abandoned
|
|
484
|
+
# section. Idempotent per disposition. Raises on an unknown disposition.
|
|
485
|
+
def self.append_terminal_savepoint(intent_dir, disposition, now: Time.now)
|
|
486
|
+
unless TERMINAL_DISPOSITIONS.include?(disposition)
|
|
487
|
+
raise ArgumentError,
|
|
488
|
+
"disposition must be one of #{TERMINAL_DISPOSITIONS.join(', ')}, got #{disposition.inspect}"
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
append_savepoint_line(intent_dir, "Done", disposition, now)
|
|
273
492
|
end
|
|
274
493
|
|
|
275
494
|
# Reconstruct the ledger from files on disk (timestamps from mtimes), in
|
|
@@ -290,7 +509,7 @@ module Bridge
|
|
|
290
509
|
lines.length
|
|
291
510
|
end
|
|
292
511
|
|
|
293
|
-
def self.derive(session, intent_id:, intent_dir:, store:, name:)
|
|
512
|
+
def self.derive(session, intent_id:, intent_dir:, store:, name:, tmp: tmp_dir)
|
|
294
513
|
stage = derive_stage(intent_dir)
|
|
295
514
|
has = has_files(intent_dir)
|
|
296
515
|
missing = missing_for_stage(stage, intent_dir) - has
|
|
@@ -320,10 +539,28 @@ module Bridge
|
|
|
320
539
|
"context_pct" => 0,
|
|
321
540
|
"warning_at" => 80,
|
|
322
541
|
"critical_at" => 90
|
|
542
|
+
},
|
|
543
|
+
# Worktree isolation block (intent 73c). Born unprovisioned; arm_auto calls
|
|
544
|
+
# Worktree.provision to fill it. code/store are abs paths or null.
|
|
545
|
+
"worktree" => {
|
|
546
|
+
"code" => nil,
|
|
547
|
+
"code_branch" => nil,
|
|
548
|
+
"store" => nil,
|
|
549
|
+
"store_branch" => nil,
|
|
550
|
+
"provisioned" => false
|
|
551
|
+
},
|
|
552
|
+
# Delivery-lock CACHE block (intent 108, D2). The durable truth is the
|
|
553
|
+
# delivery.lock file in the intent dir; arm fills this cache from it.
|
|
554
|
+
"lock" => {
|
|
555
|
+
"owner_session" => nil,
|
|
556
|
+
"acquired_at" => nil,
|
|
557
|
+
"host" => nil,
|
|
558
|
+
"type" => nil,
|
|
559
|
+
"delegates" => []
|
|
323
560
|
}
|
|
324
561
|
}
|
|
325
562
|
|
|
326
|
-
write(session, data)
|
|
563
|
+
write(session, data, tmp: tmp)
|
|
327
564
|
data
|
|
328
565
|
end
|
|
329
566
|
|
|
@@ -382,31 +619,157 @@ module Bridge
|
|
|
382
619
|
|
|
383
620
|
# --- Auto mode (intent 27) ---
|
|
384
621
|
|
|
385
|
-
#
|
|
386
|
-
#
|
|
387
|
-
|
|
622
|
+
# Shared arming spine (intent 96): resolve the session key, derive intent state,
|
|
623
|
+
# set the caller-controlled auto flag, acquire the delivery lock, provision the
|
|
624
|
+
# per-intent worktrees, persist, and purge terminal bridges. arm_auto (auto: true)
|
|
625
|
+
# and arm_guided (auto: false) are thin delegators so the lock-stamp + provision
|
|
626
|
+
# behaviour stays identical across both modes. Works even when no bridge exists
|
|
627
|
+
# yet (mid-session intent creation).
|
|
628
|
+
def self.arm(session, intent_id:, intent_dir:, store:, name:, auto:)
|
|
388
629
|
key = resolve_session(session, intent_id: intent_id, store: store)
|
|
389
|
-
if blank?(session) && blank?(ENV["
|
|
390
|
-
$stderr.puts "plastic: no session id available; arming
|
|
630
|
+
if blank?(session) && blank?(ENV["CLAUDE_CODE_SESSION_ID"])
|
|
631
|
+
$stderr.puts "plastic: no session id available; arming with derived bridge key #{key}"
|
|
391
632
|
end
|
|
392
633
|
data = derive(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
|
|
393
|
-
data["build"]["auto"] =
|
|
634
|
+
data["build"]["auto"] = auto
|
|
635
|
+
|
|
636
|
+
# Acquire the durable delivery lock (D1/D2): session-keyed, O_EXCL, in the
|
|
637
|
+
# intent dir. The bridge lock block is a cache of the file.
|
|
638
|
+
intent_dir_abs = File.expand_path(intent_dir)
|
|
639
|
+
status, lock_data = Lock.acquire(intent_dir_abs, session: key)
|
|
640
|
+
case status
|
|
641
|
+
when :acquired, :owned
|
|
642
|
+
data["lock"] = lock_cache(lock_data)
|
|
643
|
+
when :held
|
|
644
|
+
raise LockHeldError, "delivery lock for intent #{intent_id} is held by " \
|
|
645
|
+
"session #{lock_data && lock_data['owner_session']}; run /plastic-lock status"
|
|
646
|
+
when :stale
|
|
647
|
+
raise LockHeldError, "delivery lock for intent #{intent_id} is stale " \
|
|
648
|
+
"(owner #{lock_data && lock_data['owner_session']}); run /plastic-lock " \
|
|
649
|
+
"reclaim to take it over with an audit"
|
|
650
|
+
when :excluded
|
|
651
|
+
raise LockHeldError, "a #{lock_data && lock_data['type']} lock is active on " \
|
|
652
|
+
"intent #{intent_id}; run /plastic-lock status"
|
|
653
|
+
when :corrupt
|
|
654
|
+
raise LockHeldError, "delivery.lock for intent #{intent_id} is unreadable; " \
|
|
655
|
+
"run /plastic-lock fix"
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# Provision the per-intent worktrees (mandatory code worktree for project
|
|
659
|
+
# intents; fail-open for non-git / global-only). Never let a provision error
|
|
660
|
+
# break arming: the lock and auto flag still matter.
|
|
661
|
+
begin
|
|
662
|
+
Worktree.provision(data)
|
|
663
|
+
rescue => e
|
|
664
|
+
$stderr.puts "plastic: worktree provision raised, continuing unprovisioned: #{e.message}"
|
|
665
|
+
end
|
|
666
|
+
|
|
394
667
|
write(key, data)
|
|
395
|
-
|
|
668
|
+
purge_done_bridges(session: key)
|
|
396
669
|
data
|
|
397
670
|
end
|
|
671
|
+
private_class_method :arm
|
|
398
672
|
|
|
399
|
-
#
|
|
673
|
+
# Arm auto mode for a session+intent. Works even when no bridge exists yet
|
|
674
|
+
# (mid-session intent creation). Re-derives intent state, then sets build.auto.
|
|
675
|
+
def self.arm_auto(session, intent_id:, intent_dir:, store:, name:)
|
|
676
|
+
arm(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name, auto: true)
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# Acquire the delivery lock WITHOUT auto mode (intent 96 / Start guided branch).
|
|
680
|
+
# Mirrors arm_auto's lock-stamp + worktree provision but leaves build.auto = false.
|
|
681
|
+
# Same signature as arm_auto; disarm_auto (mode-agnostic) releases a guided lock.
|
|
682
|
+
def self.arm_guided(session, intent_id:, intent_dir:, store:, name:)
|
|
683
|
+
arm(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name, auto: false)
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
# Disarm. No-op if no bridge exists for the session. End-tail order (D6):
|
|
687
|
+
# worktrees are merged/removed FIRST (the verify step is the caller's,
|
|
688
|
+
# before disarm), then the delivery lock is cleared, and only then does the
|
|
689
|
+
# bridge become purge-eligible. purge_done_bridges enforces the same order
|
|
690
|
+
# defensively by skipping any bridge whose intent still holds a lock.
|
|
400
691
|
def self.disarm_auto(session)
|
|
401
692
|
data = read(session)
|
|
402
693
|
return nil unless data
|
|
403
694
|
data["build"] ||= {}
|
|
404
695
|
data["build"]["auto"] = false
|
|
696
|
+
|
|
697
|
+
# Release the worktrees the matching arm provisioned (intent 73c). Non-fatal:
|
|
698
|
+
# a release error must not block disarming. CLEANUP (73c3) refines the
|
|
699
|
+
# merge-vs-remove policy on the completion/release path.
|
|
700
|
+
begin
|
|
701
|
+
Worktree.release(data)
|
|
702
|
+
rescue => e
|
|
703
|
+
$stderr.puts "plastic: worktree release raised, continuing: #{e.message}"
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
dir = bridge_intent_dir(data)
|
|
707
|
+
if dir
|
|
708
|
+
owner = data.dig("lock", "owner_session")
|
|
709
|
+
owner = session if blank?(owner)
|
|
710
|
+
Lock.release(dir, session: owner)
|
|
711
|
+
end
|
|
712
|
+
data["lock"] = { "owner_session" => nil, "acquired_at" => nil,
|
|
713
|
+
"host" => nil, "type" => nil, "delegates" => [] }
|
|
714
|
+
|
|
405
715
|
write(session, data)
|
|
406
|
-
|
|
716
|
+
purge_done_bridges(session: session)
|
|
407
717
|
data
|
|
408
718
|
end
|
|
409
719
|
|
|
720
|
+
# One deterministic, idempotent repair (intent 108, D5): diagnose, remove
|
|
721
|
+
# faulty own-side state, rebuild the durable lock AND the bridge cache from
|
|
722
|
+
# disk truth for the current session. Legacy /tmp-only pid locks are
|
|
723
|
+
# migrated here: the delivery.lock file is created and the cache rebuilt
|
|
724
|
+
# without a pid. NEVER touches a fresh foreign lock (reports "held"); a
|
|
725
|
+
# stale foreign lock reports "stale" and is taken only by the explicit
|
|
726
|
+
# reclaim verb (Lock.takeover). Two entry points call this: the
|
|
727
|
+
# plastic-lock CLI and /plastic-intent-starting (self-healing boarding).
|
|
728
|
+
def self.repair_lock(session, intent_id:, intent_dir:, store:, name:,
|
|
729
|
+
now: Time.now, tmp: tmp_dir)
|
|
730
|
+
key = resolve_session(session, intent_id: intent_id, store: store)
|
|
731
|
+
dir = File.expand_path(intent_dir)
|
|
732
|
+
actions = []
|
|
733
|
+
|
|
734
|
+
if Lock.corrupt?(dir)
|
|
735
|
+
File.delete(Lock.path(dir))
|
|
736
|
+
actions << "removed corrupt delivery.lock"
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
lock = Lock.read(dir)
|
|
740
|
+
if lock && !Lock.authorized?(lock, key)
|
|
741
|
+
if Lock.fresh?(dir, now: now)
|
|
742
|
+
return { "status" => "held", "owner" => lock["owner_session"],
|
|
743
|
+
"actions" => actions, "session" => key }
|
|
744
|
+
end
|
|
745
|
+
return { "status" => "stale", "owner" => lock["owner_session"],
|
|
746
|
+
"actions" => actions, "session" => key,
|
|
747
|
+
"hint" => "run /plastic-lock reclaim to take over with an audit" }
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
if lock
|
|
751
|
+
Lock.heartbeat(dir, session: key, now: now)
|
|
752
|
+
lock_data = Lock.read(dir)
|
|
753
|
+
role = lock_data["owner_session"].to_s == key ? "owner" : "delegate"
|
|
754
|
+
actions << "lock kept (#{role})"
|
|
755
|
+
else
|
|
756
|
+
status, lock_data = Lock.acquire(dir, session: key, now: now)
|
|
757
|
+
actions << "lock #{status}"
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
previous = read(key, tmp: tmp)
|
|
761
|
+
auto = !!(previous && previous.dig("build", "auto"))
|
|
762
|
+
data = derive(key, intent_id: intent_id, intent_dir: dir, store: store,
|
|
763
|
+
name: name, tmp: tmp)
|
|
764
|
+
data["build"]["auto"] = auto
|
|
765
|
+
data["lock"] = lock_cache(lock_data)
|
|
766
|
+
write(key, data, tmp: tmp)
|
|
767
|
+
actions << "bridge rebuilt from disk (stage #{data['build']['stage']})"
|
|
768
|
+
|
|
769
|
+
{ "status" => "repaired", "actions" => actions,
|
|
770
|
+
"lock" => lock_data, "session" => key }
|
|
771
|
+
end
|
|
772
|
+
|
|
410
773
|
# Decide whether a code edit should be blocked while auto mode is armed.
|
|
411
774
|
# Returns a reason string to BLOCK, or nil to ALLOW.
|
|
412
775
|
#
|
|
@@ -441,6 +804,182 @@ module Bridge
|
|
|
441
804
|
"(blocked edit: #{file_abs})"
|
|
442
805
|
end
|
|
443
806
|
|
|
807
|
+
# --- Fail-closed lock gate (intent 96) -------------------------------------
|
|
808
|
+
|
|
809
|
+
# Returns a reason String to BLOCK, or nil to ALLOW. Decides from the
|
|
810
|
+
# durable delivery.lock in the TARGET intent dir (D2): the bridge argument
|
|
811
|
+
# only supplies a fallback session id, so a missing or disagreeing bridge
|
|
812
|
+
# never changes the verdict. Every deny names the exact resolving command
|
|
813
|
+
# (D5). ALLOW: non-intent paths, not-yet-active intents, and any session the
|
|
814
|
+
# target's lock names as owner or delegate (even when stale: a stale lock is
|
|
815
|
+
# still its owner's until an explicit takeover).
|
|
816
|
+
def self.lock_gate_decision(bridge_data, file_path, session: nil,
|
|
817
|
+
ttl: Lock::TTL_SECONDS, now: Time.now)
|
|
818
|
+
return nil if blank?(file_path)
|
|
819
|
+
|
|
820
|
+
target_dir = intent_dir_for(file_path)
|
|
821
|
+
return nil unless target_dir
|
|
822
|
+
id = intent_id_from_dir(target_dir)
|
|
823
|
+
store = File.dirname(target_dir)
|
|
824
|
+
return nil unless id && intent_active?(id, store: store)
|
|
825
|
+
|
|
826
|
+
sess = session
|
|
827
|
+
sess = bridge_data["session"] if blank?(sess) && bridge_data.is_a?(Hash)
|
|
828
|
+
|
|
829
|
+
lock = Lock.read(target_dir)
|
|
830
|
+
if lock
|
|
831
|
+
return nil if Lock.authorized?(lock, sess)
|
|
832
|
+
if Lock.fresh?(target_dir, ttl: ttl, now: now)
|
|
833
|
+
return "intent #{id} delivery lock is held by session " \
|
|
834
|
+
"#{lock['owner_session']}. Back off; if you are the owner's " \
|
|
835
|
+
"subagent, the owner must run: plastic-lock delegate " \
|
|
836
|
+
"--intent-dir #{target_dir} --session <your-session-id>. " \
|
|
837
|
+
"Inspect with /plastic-lock status"
|
|
838
|
+
end
|
|
839
|
+
return "intent #{id} has a stale delivery lock (owner " \
|
|
840
|
+
"#{lock['owner_session']}); run /plastic-lock reclaim to take " \
|
|
841
|
+
"it over, or /plastic-lock fix"
|
|
842
|
+
end
|
|
843
|
+
if Lock.corrupt?(target_dir)
|
|
844
|
+
return "delivery.lock for intent #{id} is unreadable; run /plastic-lock fix"
|
|
845
|
+
end
|
|
846
|
+
"no delivery lock held for intent #{id}; run /plastic-intent-starting " \
|
|
847
|
+
"to lock and begin"
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
# A session holds an intent's lock iff the durable delivery.lock in the
|
|
851
|
+
# intent dir names it as owner or delegate (D1/D4). The bridge is only a
|
|
852
|
+
# cache: the lock FILE decides, so a wiped /tmp or a clobbered bridge never
|
|
853
|
+
# strands the owner. No pid is consulted anywhere.
|
|
854
|
+
def self.holds_live_lock?(bridge_data, session: nil)
|
|
855
|
+
sess = session
|
|
856
|
+
sess = bridge_data["session"] if blank?(sess) && bridge_data.is_a?(Hash)
|
|
857
|
+
return false if blank?(sess)
|
|
858
|
+
dir = bridge_intent_dir(bridge_data)
|
|
859
|
+
return false unless dir
|
|
860
|
+
Lock.holds?(dir, session: sess)
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
# "<id>" from a ".../store/<id>--<slug>" dir, else nil.
|
|
864
|
+
def self.intent_id_from_dir(dir)
|
|
865
|
+
base = File.basename(dir.to_s)
|
|
866
|
+
base.include?("--") ? base.split("--", 2).first : nil
|
|
867
|
+
end
|
|
868
|
+
|
|
869
|
+
# --- Worktree isolation gate (intent 73c2) ---
|
|
870
|
+
|
|
871
|
+
# Returns a reason String to BLOCK, or nil to ALLOW. Two independent rules,
|
|
872
|
+
# both fail-open by construction:
|
|
873
|
+
#
|
|
874
|
+
# 1. When the bridge has a provisioned code worktree, a code edit (a target
|
|
875
|
+
# outside ~/.plastic and outside this intent's store dir) MUST land inside
|
|
876
|
+
# worktree["code"]; otherwise BLOCK and name the expected worktree path.
|
|
877
|
+
# 2. When the target lives inside ANOTHER intent's store dir whose bridge lock
|
|
878
|
+
# is held by a LIVE non-owner session, BLOCK (non-owner edit to an active
|
|
879
|
+
# intent).
|
|
880
|
+
#
|
|
881
|
+
# Fails open (returns nil) when provisioned is false (non-git / global-only) or
|
|
882
|
+
# the bridge carries no worktree/lock blocks. Logs nothing on the allow path.
|
|
883
|
+
def self.worktree_gate_decision(bridge_data, file_path, home: Dir.home, current_session: nil)
|
|
884
|
+
return nil unless bridge_data.is_a?(Hash)
|
|
885
|
+
return nil if blank?(file_path)
|
|
886
|
+
|
|
887
|
+
file_abs = File.expand_path(file_path.to_s)
|
|
888
|
+
plastic_home = File.expand_path(File.join(home, ".plastic"))
|
|
889
|
+
under_plastic = file_abs == plastic_home || file_abs.start_with?("#{plastic_home}/")
|
|
890
|
+
|
|
891
|
+
intent_info = bridge_data["intent"] || {}
|
|
892
|
+
store = intent_info["store"]
|
|
893
|
+
dir = intent_info["dir"]
|
|
894
|
+
intent_dir_abs = (store && dir) ? File.expand_path("#{store}/#{dir}") : nil
|
|
895
|
+
under_own_intent = intent_dir_abs &&
|
|
896
|
+
(file_abs == intent_dir_abs || file_abs.start_with?("#{intent_dir_abs}/"))
|
|
897
|
+
|
|
898
|
+
# Rule 1 (fixed in intent 108, D7): confinement applies ONLY to paths
|
|
899
|
+
# inside the project repo. The repo root is derived from the provisioned
|
|
900
|
+
# code worktree path, which is <repo>/.claude/worktrees/{id}--{slug} by
|
|
901
|
+
# construction, so no git call is needed. Paths outside the repo (agent
|
|
902
|
+
# memory dirs, scratch files, unrelated checkouts) are not this gate's
|
|
903
|
+
# business; the 2026-07-02 memory-dir denial came from treating everything
|
|
904
|
+
# outside the worktree as the shared checkout.
|
|
905
|
+
worktree = bridge_data["worktree"] || {}
|
|
906
|
+
if worktree["provisioned"] == true
|
|
907
|
+
code = worktree["code"].to_s
|
|
908
|
+
if !blank?(code) && !under_plastic && !under_own_intent
|
|
909
|
+
code_abs = File.expand_path(code)
|
|
910
|
+
repo_abs = File.expand_path(File.join(code_abs, "..", "..", ".."))
|
|
911
|
+
inside_repo = file_abs == repo_abs || file_abs.start_with?("#{repo_abs}/")
|
|
912
|
+
inside_code = file_abs == code_abs || file_abs.start_with?("#{code_abs}/")
|
|
913
|
+
if inside_repo && !inside_code
|
|
914
|
+
id = intent_info["id"]
|
|
915
|
+
return "intent #{id} is isolated to its worktree - edit project code " \
|
|
916
|
+
"inside #{code_abs}, not the shared checkout. (blocked edit: #{file_abs})"
|
|
917
|
+
end
|
|
918
|
+
end
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
# Rule 2: do not edit another intent's locked, live store dir.
|
|
922
|
+
if under_plastic
|
|
923
|
+
reason = non_owner_store_edit_reason(file_abs, plastic_home, intent_dir_abs,
|
|
924
|
+
home: home, current_session: current_session,
|
|
925
|
+
own_session: bridge_data["session"])
|
|
926
|
+
return reason if reason
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
nil
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
# Helper for rule 2. A store dir is `<plastic_home>/store/{id}--{slug}` (global)
|
|
933
|
+
# or `<plastic_home>/projects/{slug}/store/{id}--{slug}` (project). When the
|
|
934
|
+
# edit target sits inside such a dir that is NOT this intent's own dir, and a
|
|
935
|
+
# live non-owner session holds that intent's bridge lock, BLOCK.
|
|
936
|
+
def self.non_owner_store_edit_reason(file_abs, plastic_home, own_intent_dir_abs,
|
|
937
|
+
home:, current_session:, own_session:)
|
|
938
|
+
return nil if own_intent_dir_abs &&
|
|
939
|
+
(file_abs == own_intent_dir_abs || file_abs.start_with?("#{own_intent_dir_abs}/"))
|
|
940
|
+
|
|
941
|
+
parsed = parse_store_target(file_abs, plastic_home)
|
|
942
|
+
return nil unless parsed
|
|
943
|
+
|
|
944
|
+
session = blank?(current_session) ? own_session : current_session
|
|
945
|
+
held = Worktree.lock_held_by_other?(
|
|
946
|
+
intent_id: parsed[:id], store: parsed[:store],
|
|
947
|
+
current_session: session, home: home,
|
|
948
|
+
)
|
|
949
|
+
return nil unless held
|
|
950
|
+
|
|
951
|
+
"intent #{parsed[:id]} is owned by another live session — its delivery lock " \
|
|
952
|
+
"is held elsewhere. Back off; do not edit #{file_abs}."
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
# Resolve an edit target inside a store to {id:, store:} for the intent dir it
|
|
956
|
+
# belongs to, or nil if the path is not inside an `{id}--{slug}` intent dir.
|
|
957
|
+
def self.parse_store_target(file_abs, plastic_home)
|
|
958
|
+
rels = []
|
|
959
|
+
global_store = File.join(plastic_home, "store")
|
|
960
|
+
if file_abs.start_with?("#{global_store}/")
|
|
961
|
+
rels << [file_abs[(global_store.length + 1)..], global_store]
|
|
962
|
+
end
|
|
963
|
+
projects = File.join(plastic_home, "projects")
|
|
964
|
+
if file_abs.start_with?("#{projects}/")
|
|
965
|
+
tail = file_abs[(projects.length + 1)..].to_s
|
|
966
|
+
parts = tail.split(File::SEPARATOR)
|
|
967
|
+
if parts.length >= 2 && parts[1] == "store"
|
|
968
|
+
pstore = File.join(projects, parts[0], "store")
|
|
969
|
+
rels << [file_abs[(pstore.length + 1)..], pstore]
|
|
970
|
+
end
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
rels.each do |rel, store_dir|
|
|
974
|
+
next if blank?(rel)
|
|
975
|
+
first = rel.split(File::SEPARATOR).first.to_s
|
|
976
|
+
idx = first.index("--")
|
|
977
|
+
next unless idx && idx > 0
|
|
978
|
+
return { id: first[0...idx], store: store_dir }
|
|
979
|
+
end
|
|
980
|
+
nil
|
|
981
|
+
end
|
|
982
|
+
|
|
444
983
|
# --- Bash-edit gate (intent 27a) ---
|
|
445
984
|
|
|
446
985
|
# Extract the set of file paths a Bash command writes to. Conservative by
|
|
@@ -457,6 +996,7 @@ module Bridge
|
|
|
457
996
|
# Split on command separators for per-segment utility parsing.
|
|
458
997
|
command.split(/[;\n]|&&|\|\||\|/).each do |segment|
|
|
459
998
|
targets.concat(bash_utility_targets(segment))
|
|
999
|
+
targets.concat(interpreter_write_targets(segment))
|
|
460
1000
|
end
|
|
461
1001
|
targets.uniq
|
|
462
1002
|
end
|
|
@@ -532,18 +1072,56 @@ module Bridge
|
|
|
532
1072
|
path == "/dev/null" || path.start_with?("/dev/")
|
|
533
1073
|
end
|
|
534
1074
|
|
|
535
|
-
#
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
1075
|
+
# --- Interpreter inline-code writes (intent 108, D7) ---
|
|
1076
|
+
|
|
1077
|
+
INTERPRETER_RE = /\b(ruby|python3?|perl|node)\b(?:\s+\S+)*?\s+(-e|-c)\s+(.+)\z/m.freeze
|
|
1078
|
+
|
|
1079
|
+
# Write verbs that mark inline code as file-mutating. Conservative: reads
|
|
1080
|
+
# (File.read, puts) never match.
|
|
1081
|
+
WRITE_VERB_RE = /File\.(?:write|binwrite|open)|IO\.write|FileUtils\.|
|
|
1082
|
+
open\s*\([^)]*["'][wa]|writeFileSync|fs\.write/x.freeze
|
|
1083
|
+
|
|
1084
|
+
# Quoted absolute or ~/ paths inside the inline code.
|
|
1085
|
+
INLINE_PATH_RE = %r{["']((?:/|~/)[^"']+)["']}.freeze
|
|
1086
|
+
|
|
1087
|
+
# Paths an interpreter one-liner writes. Flagged only when the inline code
|
|
1088
|
+
# has BOTH a write verb AND a quoted absolute path; everything else (reads,
|
|
1089
|
+
# ARGV-driven paths, the sanctioned arm one-liners) yields no targets.
|
|
1090
|
+
def self.interpreter_write_targets(segment)
|
|
1091
|
+
m = INTERPRETER_RE.match(segment.to_s)
|
|
1092
|
+
return [] unless m
|
|
1093
|
+
util, flag, code = m[1], m[2], m[3]
|
|
1094
|
+
expected = { "ruby" => "-e", "python" => "-c", "python3" => "-c",
|
|
1095
|
+
"perl" => "-e", "node" => "-e" }[util]
|
|
1096
|
+
return [] unless flag == expected
|
|
1097
|
+
return [] unless WRITE_VERB_RE.match?(code)
|
|
1098
|
+
code.scan(INLINE_PATH_RE).flatten.map { |p| File.expand_path(p) }
|
|
1099
|
+
end
|
|
1100
|
+
|
|
1101
|
+
# Decide whether a Bash command should be blocked. Every write target runs
|
|
1102
|
+
# through the SAME policy stack as a direct tool write: the auto-mode code
|
|
1103
|
+
# gate AND the delivery-lock gate (intent 108, D7), so bash and interpreter
|
|
1104
|
+
# writes cannot bypass the lock. Returns the first block reason, or nil.
|
|
1105
|
+
def self.bash_gate_decision(bridge_data, command, cwd:, home: Dir.home, session: nil)
|
|
539
1106
|
bash_write_targets(command).each do |target|
|
|
540
1107
|
abs = File.absolute_path?(target) ? target : File.join(cwd, target)
|
|
541
|
-
|
|
1108
|
+
abs = File.expand_path(abs)
|
|
1109
|
+
reason = code_gate_decision(bridge_data, abs, home: home) ||
|
|
1110
|
+
lock_gate_decision(bridge_data, abs, session: session)
|
|
542
1111
|
return reason if reason
|
|
543
1112
|
end
|
|
544
1113
|
nil
|
|
545
1114
|
end
|
|
546
1115
|
|
|
1116
|
+
# A TRAILING `# plastic-ok` shell comment: the auditable escape for
|
|
1117
|
+
# sanctioned bash/interpreter writes (mirrors the retrieval gate's
|
|
1118
|
+
# `# qmd-ok`). The hook logs every use to ~/.plastic/.cache/gate-escapes.log.
|
|
1119
|
+
PLASTIC_OK_RE = /(?:\A|\s)#\s*plastic-ok\s*\z/.freeze
|
|
1120
|
+
|
|
1121
|
+
def self.bash_escape?(command)
|
|
1122
|
+
PLASTIC_OK_RE.match?(command.to_s.chomp)
|
|
1123
|
+
end
|
|
1124
|
+
|
|
547
1125
|
def self.deep_merge(base, overlay)
|
|
548
1126
|
result = base.dup
|
|
549
1127
|
overlay.each do |key, value|
|