@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.
Files changed (107) hide show
  1. package/PLASTIC-reference.md +133 -0
  2. package/PLASTIC.md +398 -82
  3. package/README.md +98 -82
  4. package/agents/plastic-brainstorming.md +12 -9
  5. package/agents/plastic-enforcer.md +52 -12
  6. package/agents/plastic-executor.md +10 -10
  7. package/agents/plastic-future-intent-researcher.md +4 -8
  8. package/agents/plastic-intent-curator.md +12 -14
  9. package/agents/plastic-intent-discovery.md +35 -0
  10. package/agents/plastic-planner.md +15 -10
  11. package/agents/plastic-spec-specialist.md +15 -10
  12. package/bin/plastic.js +7 -3
  13. package/hooks/code-gate +17 -2
  14. package/hooks/hooks.json +28 -3
  15. package/hooks/lock-gate +21 -0
  16. package/hooks/retrieval-gate +10 -0
  17. package/hooks/savepoint-pre +10 -0
  18. package/hooks/statusline +48 -6
  19. package/package.json +2 -1
  20. package/scripts/agent-report +21 -0
  21. package/scripts/dashboard.rb +98 -23
  22. package/scripts/doctor.rb +232 -5
  23. package/scripts/hook-auto-arm +1 -1
  24. package/scripts/hook-bash-gate +22 -4
  25. package/scripts/hook-code-gate +30 -7
  26. package/scripts/hook-continue +17 -0
  27. package/scripts/hook-create-gate +45 -9
  28. package/scripts/hook-gate-check +24 -23
  29. package/scripts/hook-lock-gate +83 -0
  30. package/scripts/hook-retrieval-gate +148 -0
  31. package/scripts/hook-savepoint-pre +32 -0
  32. package/scripts/hook-session-start +1 -1
  33. package/scripts/insight-append +51 -0
  34. package/scripts/install.rb +44 -7
  35. package/scripts/lib/agent_models.rb +43 -0
  36. package/scripts/lib/bridge.rb +924 -84
  37. package/scripts/lib/dashboard_banner.rb +42 -0
  38. package/scripts/lib/hook_registry.rb +95 -0
  39. package/scripts/lib/insights.rb +86 -0
  40. package/scripts/lib/installer_core.rb +101 -60
  41. package/scripts/lib/link_suggestions.rb +319 -0
  42. package/scripts/lib/lock.rb +375 -0
  43. package/scripts/lib/power_tools.rb +22 -21
  44. package/scripts/lib/preflight.rb +79 -0
  45. package/scripts/lib/qmd_sync.rb +48 -6
  46. package/scripts/lib/retrieval_gate.rb +211 -0
  47. package/scripts/lib/worktree.rb +384 -0
  48. package/scripts/link-suggest +213 -0
  49. package/scripts/new-intent +58 -17
  50. package/scripts/plastic-lock +164 -0
  51. package/scripts/read-config +4 -0
  52. package/scripts/spawn-preamble +36 -4
  53. package/scripts/update.rb +16 -7
  54. package/skills/auto/SKILL.md +127 -20
  55. package/skills/auto/references/agent-architecture.md +7 -4
  56. package/skills/auto/references/agent-report-contract.md +36 -1
  57. package/skills/auto/references/end-tail.md +56 -0
  58. package/skills/auto/references/human-report-contract.md +55 -0
  59. package/skills/auto/references/tiers.md +77 -0
  60. package/skills/brainstorming/SKILL.md +7 -34
  61. package/skills/brainstorming/references/design-principles.md +49 -0
  62. package/skills/continuing/SKILL.md +26 -7
  63. package/skills/creating-intent/SKILL.md +13 -28
  64. package/skills/creating-project/SKILL.md +11 -74
  65. package/skills/creating-project/references/project-scaffolding.md +97 -0
  66. package/skills/creating-skills/SKILL.md +65 -0
  67. package/skills/creating-skills/evals/evals.json +108 -0
  68. package/skills/creating-skills/references/agents.md +168 -0
  69. package/skills/creating-skills/references/evals.md +41 -0
  70. package/skills/creating-skills/references/hooks.md +248 -0
  71. package/skills/creating-skills/references/progressive-disclosure.md +176 -0
  72. package/skills/creating-skills/references/scripts.md +166 -0
  73. package/skills/creating-skills/references/skills.md +165 -0
  74. package/skills/creating-skills/scripts/scaffold.rb +313 -0
  75. package/skills/dashboard/SKILL.md +13 -11
  76. package/skills/dashboard/references/classification.md +22 -0
  77. package/skills/doctor/SKILL.md +10 -7
  78. package/skills/executing-plan/SKILL.md +4 -4
  79. package/skills/humanizer/SKILL.md +39 -0
  80. package/skills/humanizer/evals/evals.json +70 -0
  81. package/skills/humanizer/references/always-on-snippet.md +9 -0
  82. package/skills/humanizer/references/examples.md +48 -0
  83. package/skills/install/SKILL.md +75 -84
  84. package/skills/intent-curator/SKILL.md +5 -1
  85. package/skills/intent-discovery/SKILL.md +47 -0
  86. package/skills/intent-starting/SKILL.md +130 -0
  87. package/skills/intent-starting/evals/evals.json +117 -0
  88. package/skills/intent-starting/references/boarding-matrix.md +35 -0
  89. package/skills/linking-intents/SKILL.md +48 -12
  90. package/skills/lock/SKILL.md +41 -0
  91. package/skills/managing-index/SKILL.md +6 -0
  92. package/skills/releasing/SKILL.md +44 -29
  93. package/skills/releasing/references/promotion-and-tagging.md +60 -0
  94. package/skills/roadmap/SKILL.md +51 -0
  95. package/skills/roadmap/references/file-format.md +91 -0
  96. package/skills/roadmap/references/operations.md +88 -0
  97. package/skills/uninstall/SKILL.md +29 -11
  98. package/skills/update/SKILL.md +34 -23
  99. package/skills/versions/SKILL.md +27 -12
  100. package/skills/writing-plans/SKILL.md +10 -88
  101. package/skills/writing-plans/references/plan-format.md +102 -0
  102. package/templates/config.yml +8 -0
  103. package/templates/outcome.md +3 -0
  104. package/templates/revisions.md +58 -0
  105. package/templates/roadmap.md +30 -0
  106. package/skills/writing-instructions/SKILL.md +0 -159
  107. package/skills/writing-instructions/references/agentskills-spec.md +0 -135
@@ -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
- # Stale-bridge purge window (intent 67). The bridge file is ephemeral
22
- # live-session gate state, NOT a continuation source: an intent is resumed from
23
- # its savepoint.md ledger, never from a /tmp bridge. So any bridge older than
24
- # this window is dead weight and safe to purge, regardless of arm state. No
25
- # real session stays live for two days, so a 48h cutoff never removes a bridge
26
- # an active run depends on.
27
- PURGE_AGE_SECONDS = 48 * 3600 # 48 hours
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
- def self.path(session, tmp: tmp_dir)
44
- "#{tmp}/plastic-#{session}.json"
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, CLAUDE_SESSION_ID,
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) is additive: it only changes
65
- # behavior when CLAUDE_SESSION_ID is blank but CLAUDE_CODE_SESSION_ID is set
66
- # the bg/headless case where the real session id lives in CLAUDE_CODE_SESSION_ID.
67
- # Keying by the real id (instead of a derived hash) lets the statusline, which
68
- # receives that same id on stdin, find the bridge by direct filename lookup.
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
- # Resolve the active bridge. Exact-session lookup first; otherwise scan tmp:
99
- # for plastic-*.json, keep only valid bridges, prefer auto-armed, then prefer
100
- # the one whose intent.store matches cwd, tie-break by newest mtime.
101
- def self.discover_bridge(session:, cwd: Dir.pwd, tmp: tmp_dir)
102
- if !blank?(session) && File.exist?(path(session, tmp: tmp))
103
- exact = read(session, tmp: tmp)
104
- return exact if bridge_valid?(exact)
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
- pool = auto.empty? ? parsed : auto
206
+ auto_pool = auto.empty? ? parsed : auto
117
207
 
118
208
  unless blank?(cwd)
119
209
  cwd_abs = File.expand_path(cwd)
120
- matching = pool.select do |c|
121
- store = c[:data].dig("intent", "store").to_s
122
- next false if store.empty?
123
- store_abs = File.expand_path(store)
124
- cwd_abs == store_abs ||
125
- cwd_abs.start_with?("#{store_abs}/") ||
126
- store_abs.start_with?("#{cwd_abs}/")
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
- pool.max_by { |c| c[:mtime] }&.fetch(:data)
227
+ auto_pool.max_by { |c| c[:mtime] }&.fetch(:data)
132
228
  end
133
229
 
134
- # --- Stale-bridge purge (intent 67) ---------------------------------------
135
- #
136
- # Remove stale tmp/plastic-*.json bridge files so discover_bridge's per-fire
137
- # scan stays bounded. Best-effort and non-raising: returns the array of removed
138
- # paths. Continuation does not depend on these files (an intent resumes from its
139
- # savepoint.md ledger), so the only safety rule is age: a bridge older than
140
- # max_age_seconds is purged regardless of arm state, while anything newer is kept
141
- # (it may be a live run). The current session's own bridge is never purged
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.purge_stale_bridges(session:, now: Time.now, max_age_seconds: PURGE_AGE_SECONDS,
146
- tmp: tmp_dir)
147
- current = path(session, tmp: tmp)
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 == current
282
+ next if File.basename(f) == own_legacy_name || File.basename(f).start_with?(own_prefix)
151
283
  begin
152
- next if (now - File.mtime(f)) < max_age_seconds
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: purge_stale_bridges failed: #{e.message}"
310
+ $stderr.puts "plastic: purge_done_bridges failed: #{e.message}"
165
311
  removed || []
166
312
  end
167
313
 
168
- def self.read(session, tmp: tmp_dir)
169
- p = path(session, tmp: tmp)
170
- return nil unless File.exist?(p)
171
- JSON.parse(File.read(p))
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
- p = path(session, tmp: tmp)
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
- # Append a milestone line for file_path if (and only if) it is a milestone
269
- # not already recorded. Returns true when a line was written, false otherwise.
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
- line = "#{now.utc.iso8601} #{stage} #{milestone}\n"
280
- File.open(File.join(intent_dir, SAVEPOINT_FILE), "a") { |io| io.write(line) }
281
- true
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.filter_map do |basename|
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
- "#{File.mtime(path).utc.iso8601} #{stage} #{milestone}\n"
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
- # Arm auto mode for a session+intent. Works even when no bridge exists yet
395
- # (mid-session intent creation). Re-derives intent state, then sets build.auto.
396
- def self.arm_auto(session, intent_id:, intent_dir:, store:, name:)
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["CLAUDE_SESSION_ID"]) && blank?(ENV["CLAUDE_CODE_SESSION_ID"])
399
- $stderr.puts "plastic: no session id available; arming auto with derived bridge key #{key}"
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"] = true
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
- purge_stale_bridges(session: key)
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
- # Disarm auto mode. No-op if no bridge exists for the session.
409
- def self.disarm_auto(session)
410
- data = read(session)
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
- purge_stale_bridges(session: session)
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
- # Match optional leading fd digits, then > or >>, not followed by & , then path.
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
- # Decide whether a Bash command should be blocked under the auto-mode code
545
- # gate. Resolves each write target against cwd and applies the SAME policy as
546
- # code_gate_decision. Returns the first block reason, or nil to allow.
547
- def self.bash_gate_decision(bridge_data, command, cwd:, home: Dir.home)
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
- reason = code_gate_decision(bridge_data, abs, home: home)
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|