@zalom/plastic 1.0.0-beta.2 → 1.0.0-beta.21

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 (82) hide show
  1. package/PLASTIC.md +131 -7
  2. package/agents/plastic-brainstorming.md +9 -1
  3. package/agents/plastic-enforcer.md +1 -1
  4. package/agents/plastic-executor.md +11 -1
  5. package/agents/plastic-intent-curator.md +7 -5
  6. package/agents/plastic-planner.md +11 -1
  7. package/agents/plastic-spec-specialist.md +9 -1
  8. package/hooks/hooks.json +20 -0
  9. package/hooks/retrieval-gate +10 -0
  10. package/hooks/savepoint-pre +10 -0
  11. package/hooks/statusline +150 -41
  12. package/package.json +1 -1
  13. package/scripts/agent-report +163 -0
  14. package/scripts/doctor.rb +172 -0
  15. package/scripts/hook-auto-arm +1 -1
  16. package/scripts/hook-bash-gate +2 -2
  17. package/scripts/hook-code-gate +11 -6
  18. package/scripts/hook-create-gate +2 -2
  19. package/scripts/hook-gate-check +14 -23
  20. package/scripts/hook-retrieval-gate +136 -0
  21. package/scripts/hook-savepoint-pre +32 -0
  22. package/scripts/hook-session-start +1 -1
  23. package/scripts/insight-append +51 -0
  24. package/scripts/lib/bridge.rb +374 -34
  25. package/scripts/lib/frontmatter_writer.rb +130 -0
  26. package/scripts/lib/graph_rebuild.rb +328 -0
  27. package/scripts/lib/insights.rb +86 -0
  28. package/scripts/lib/installer_core.rb +23 -0
  29. package/scripts/lib/link_suggestions.rb +322 -0
  30. package/scripts/lib/links_projection.rb +160 -0
  31. package/scripts/lib/links_section.rb +207 -0
  32. package/scripts/lib/power_tools.rb +76 -0
  33. package/scripts/lib/qmd_hook.rb +38 -25
  34. package/scripts/lib/qmd_sync.rb +36 -0
  35. package/scripts/lib/retrieval_gate.rb +211 -0
  36. package/scripts/lib/worktree.rb +409 -0
  37. package/scripts/link-suggest +211 -0
  38. package/scripts/new-intent +138 -29
  39. package/scripts/project-links +287 -0
  40. package/scripts/qmd-sync +50 -3
  41. package/scripts/rebuild-graph +244 -0
  42. package/scripts/spawn-preamble +26 -1
  43. package/skills/auto/SKILL.md +58 -11
  44. package/skills/auto/evals/evals.json +48 -0
  45. package/skills/auto/references/agent-architecture.md +27 -4
  46. package/skills/auto/references/agent-report-contract.md +121 -0
  47. package/skills/brainstorming/SKILL.md +1 -0
  48. package/skills/brainstorming/evals/evals.json +22 -0
  49. package/skills/continuing/SKILL.md +30 -8
  50. package/skills/continuing/evals/evals.json +9 -0
  51. package/skills/creating-intent/SKILL.md +16 -2
  52. package/skills/creating-intent/evals/evals.json +16 -0
  53. package/skills/creating-intent/references/lifecycle.md +9 -4
  54. package/skills/creating-skills/SKILL.md +65 -0
  55. package/skills/creating-skills/evals/evals.json +108 -0
  56. package/skills/creating-skills/references/agents.md +168 -0
  57. package/skills/creating-skills/references/evals.md +41 -0
  58. package/skills/creating-skills/references/hooks.md +248 -0
  59. package/skills/creating-skills/references/progressive-disclosure.md +176 -0
  60. package/skills/creating-skills/references/scripts.md +166 -0
  61. package/skills/creating-skills/references/skills.md +165 -0
  62. package/skills/creating-skills/scripts/scaffold.rb +313 -0
  63. package/skills/dashboard/SKILL.md +5 -0
  64. package/skills/dashboard/evals/evals.json +22 -0
  65. package/skills/executing-plan/SKILL.md +2 -2
  66. package/skills/humanizer/SKILL.md +39 -0
  67. package/skills/humanizer/evals/evals.json +70 -0
  68. package/skills/humanizer/references/always-on-snippet.md +9 -0
  69. package/skills/humanizer/references/examples.md +48 -0
  70. package/skills/intent-curator/SKILL.md +6 -1
  71. package/skills/intent-curator/evals/evals.json +22 -0
  72. package/skills/linking-intents/SKILL.md +54 -12
  73. package/skills/linking-intents/evals/evals.json +22 -0
  74. package/skills/linking-intents/references/zettelkasten.md +7 -0
  75. package/skills/managing-index/SKILL.md +8 -0
  76. package/skills/managing-index/evals/evals.json +22 -0
  77. package/skills/managing-index/references/zettelkasten-linking.md +6 -1
  78. package/skills/releasing/SKILL.md +32 -0
  79. package/skills/research/SKILL.md +8 -0
  80. package/skills/research/evals/evals.json +22 -0
  81. package/skills/writing-instructions/SKILL.md +0 -159
  82. package/skills/writing-instructions/references/agentskills-spec.md +0 -135
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # insight-append - the blessed write path for an intent's `## Insights` section
6
+ # (intent 82). A thin CLI wrapper over Insights.append_insight: it formats the
7
+ # `{utc-iso8601} · {stage} · {author}` prefix, validates it, and appends one
8
+ # entry at the bottom of the section (creating the section or file if absent).
9
+ #
10
+ # Sibling to scripts/spawn-preamble and scripts/agent-report. The library's
11
+ # `now:` seam is the test seam; the CLI uses the default Time.now, which is fine
12
+ # because determinism is covered at the library level (test/insights_test.rb).
13
+ #
14
+ # Usage:
15
+ # insight-append <intent_dir> <text> --stage S --author A
16
+ #
17
+ # Exit codes: 0 (entry appended), 2 (usage).
18
+
19
+ require_relative "lib/insights"
20
+
21
+ def parse_args(argv)
22
+ stage = nil
23
+ author = nil
24
+ positional = []
25
+ i = 0
26
+ while i < argv.length
27
+ case argv[i]
28
+ when "--stage"
29
+ stage = argv[i + 1]
30
+ i += 2
31
+ when "--author"
32
+ author = argv[i + 1]
33
+ i += 2
34
+ else
35
+ positional << argv[i]
36
+ i += 1
37
+ end
38
+ end
39
+ [positional[0], positional[1], stage, author]
40
+ end
41
+
42
+ intent_dir, text, stage, author = parse_args(ARGV)
43
+
44
+ if [intent_dir, text, stage, author].any? { |v| v.nil? || v.to_s.empty? }
45
+ warn "usage: insight-append <intent_dir> <text> --stage S --author A"
46
+ exit 2
47
+ end
48
+
49
+ entry = Insights.append_insight(File.expand_path(intent_dir), text,
50
+ stage: stage, author: author)
51
+ puts "appended: #{entry}"
@@ -6,6 +6,8 @@ require "yaml"
6
6
  require "fileutils"
7
7
  require "tempfile"
8
8
  require "digest"
9
+ require "socket"
10
+ require_relative "worktree"
9
11
 
10
12
  module Bridge
11
13
  STAGES = %w[what why how exec done].freeze
@@ -18,13 +20,15 @@ module Bridge
18
20
  # (<id>--<slug>.md) is never sentineled; it is born complete.
19
21
  PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
20
22
 
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
23
+ # Bridge cleanup is terminal-state, not age-based (intent 80). A bridge is dead
24
+ # weight ONLY once its intent is terminal (no longer in its store's INDEX.md
25
+ # `## Active` block); such bridges are purged. An Active intent's bridge is kept
26
+ # unconditionally, because while the intent is live the bridge is still load-
27
+ # bearing: it is the continuation signal (a parked or interrupted run resumes
28
+ # from it) and the anti-collision lock (it keys the per-session statusline so
29
+ # parallel sessions do not overwrite each other). An age window was the wrong
30
+ # axis: it left dead bridges resident for ~2 days AND could reap bridges of
31
+ # interrupted-but-still-active intents, which are exactly the ones to preserve.
28
32
 
29
33
  def self.intent_file(intent_dir)
30
34
  dir_name = File.basename(intent_dir)
@@ -57,12 +61,19 @@ module Bridge
57
61
  "auto-" + Digest::SHA256.hexdigest("#{store}/#{intent_id}")[0, 10]
58
62
  end
59
63
 
60
- # Resolve a bridge session: first non-empty of explicit, CLAUDE_SESSION_ID,
61
- # then a derived key. Never returns nil/empty. Whitespace-only counts as empty.
64
+ # Resolve a bridge session: first non-empty of explicit (the stdin session_id),
65
+ # CLAUDE_CODE_SESSION_ID, then a derived key. Never returns nil/empty.
66
+ # Whitespace-only counts as empty.
67
+ #
68
+ # The CLAUDE_CODE_SESSION_ID fallback (intent 79) carries the bg/headless real
69
+ # session id (Claude Code passes session_id on stdin, not via an env var; the
70
+ # headless id lives in CLAUDE_CODE_SESSION_ID). Keying by the real id (instead of
71
+ # a derived hash) lets the statusline, which receives that same id on stdin, find
72
+ # the bridge by direct filename lookup.
62
73
  def self.resolve_session(explicit, intent_id:, store:)
63
74
  return explicit.to_s.strip unless blank?(explicit)
64
- env = ENV["CLAUDE_SESSION_ID"]
65
- return env.to_s.strip unless blank?(env)
75
+ code_env = ENV["CLAUDE_CODE_SESSION_ID"]
76
+ return code_env.to_s.strip unless blank?(code_env)
66
77
  derive_key(store, intent_id)
67
78
  end
68
79
 
@@ -103,6 +114,22 @@ module Bridge
103
114
  end
104
115
  return nil if parsed.empty?
105
116
 
117
+ has_session = !blank?(session)
118
+
119
+ # Strict per-session ownership (intent 90): when the caller HAS a session, a foreign
120
+ # session's bridge is NEVER a valid resolution. Own-session and the derived-key case both
121
+ # reduce to candidate["session"] == session (the derived key IS the session that armed the
122
+ # bridge). A caller that owns no bridge resolves to nil, so its gates fail open instead of
123
+ # inheriting another session's armed intent.
124
+ #
125
+ # When the caller has NO session (truly headless, intent 52), keep the legacy degraded
126
+ # selection below so a single armed derived-key bridge is still discoverable - the hook
127
+ # cannot know the session there, and a lone armed intent must still gate.
128
+ if has_session
129
+ parsed = parsed.select { |c| c[:data]["session"].to_s == session.to_s }
130
+ return nil if parsed.empty?
131
+ end
132
+
106
133
  auto = parsed.select { |c| c[:data].dig("build", "auto") == true }
107
134
  pool = auto.empty? ? parsed : auto
108
135
 
@@ -116,31 +143,71 @@ module Bridge
116
143
  cwd_abs.start_with?("#{store_abs}/") ||
117
144
  store_abs.start_with?("#{cwd_abs}/")
118
145
  end
119
- pool = matching unless matching.empty?
146
+ # Hard cwd filter when the caller has a session (intent 90): a non-matching store
147
+ # excludes the candidate outright. Without a session, keep the best-effort revert
148
+ # (intent 52) so a lone armed bridge is still found when cwd does not overlap its store.
149
+ pool = has_session ? matching : (matching.empty? ? pool : matching)
120
150
  end
121
151
 
122
152
  pool.max_by { |c| c[:mtime] }&.fetch(:data)
123
153
  end
124
154
 
125
- # --- Stale-bridge purge (intent 67) ---------------------------------------
126
- #
127
- # Remove stale tmp/plastic-*.json bridge files so discover_bridge's per-fire
128
- # scan stays bounded. Best-effort and non-raising: returns the array of removed
129
- # paths. Continuation does not depend on these files (an intent resumes from its
130
- # savepoint.md ledger), so the only safety rule is age: a bridge older than
131
- # max_age_seconds is purged regardless of arm state, while anything newer is kept
132
- # (it may be a live run). The current session's own bridge is never purged
155
+ # --- Terminal-state bridge purge (intent 80) -------------------------------
156
+
157
+ # True iff the intent is Active in its store's INDEX.md. An INDEX.md lives at
158
+ # the PARENT of the store/ dir the bridge records, so we resolve it from the
159
+ # bridge's intent.store. Non-raising: any failure (missing/unreadable INDEX,
160
+ # bad arg) returns false, which means "not active" so the caller treats the
161
+ # bridge as purgeable. `index_active_ids` is a pure-data test seam: when an
162
+ # Array of id strings is supplied, membership is checked against it directly
163
+ # with no file read.
164
+ def self.intent_active?(intent_id, store:, index_active_ids: nil)
165
+ target = intent_id.to_s
166
+ return index_active_ids.include?(target) if index_active_ids.is_a?(Array)
167
+
168
+ index = File.join(File.dirname(store.to_s), "INDEX.md")
169
+ return false unless File.exist?(index)
170
+
171
+ in_active = false
172
+ File.foreach(index) do |line|
173
+ stripped = line.chomp
174
+ if stripped == "## Active"
175
+ in_active = true
176
+ next
177
+ end
178
+ next unless in_active
179
+ break if stripped.start_with?("## ") # next section ends the Active block
180
+ m = stripped.match(/^- \[(\S+) +—/)
181
+ return true if m && m[1] == target
182
+ end
183
+ false
184
+ rescue StandardError
185
+ false
186
+ end
187
+
188
+ # Remove tmp/plastic-*.json bridge files whose intent is terminal, so
189
+ # discover_bridge's per-fire scan stays bounded. Best-effort and non-raising:
190
+ # returns the array of removed paths. A bridge is purged when it cannot be
191
+ # parsed, has no intent.id, has no intent.store, or its intent is not Active in
192
+ # its store's INDEX.md. An Active intent's bridge is kept (continuation signal +
193
+ # anti-collision lock), and the current session's own bridge is never purged
133
194
  # (preserves the disarm_auto contract that it stays readable). Wired into
134
195
  # arm_auto and disarm_auto so both manual and auto delivery keep the temp dir
135
- # clean.
136
- def self.purge_stale_bridges(session:, now: Time.now, max_age_seconds: PURGE_AGE_SECONDS,
137
- tmp: tmp_dir)
196
+ # clean at deterministic work boundaries.
197
+ def self.purge_done_bridges(session:, tmp: tmp_dir)
138
198
  current = path(session, tmp: tmp)
139
199
  removed = []
140
200
  Dir.glob(File.join(tmp, "plastic-*.json")).each do |f|
141
201
  next if f == current
142
202
  begin
143
- next if (now - File.mtime(f)) < max_age_seconds
203
+ data = JSON.parse(File.read(f)) rescue nil
204
+ keep = false
205
+ if data
206
+ id = data.dig("intent", "id")
207
+ store = data.dig("intent", "store")
208
+ keep = !blank?(id) && !blank?(store) && intent_active?(id, store: store)
209
+ end
210
+ next if keep
144
211
  File.delete(f)
145
212
  removed << f
146
213
  rescue Errno::ENOENT
@@ -152,7 +219,7 @@ module Bridge
152
219
  end
153
220
  removed
154
221
  rescue => e
155
- $stderr.puts "plastic: purge_stale_bridges failed: #{e.message}"
222
+ $stderr.puts "plastic: purge_done_bridges failed: #{e.message}"
156
223
  removed || []
157
224
  end
158
225
 
@@ -224,6 +291,51 @@ module Bridge
224
291
  end
225
292
  end
226
293
 
294
+ # --- Gate-boundary narration (intent 84, Lever 1) -------------------------
295
+ #
296
+ # ONE concise sentence that states what happened AND what's next, preserving
297
+ # the `Next: ...` hint the agent consumes. Pure and side-effect-free so the
298
+ # hook stays a thin caller and the formatter is unit-testable in isolation.
299
+ # No "Stage transition: X -> Y" prose, no arrow; a colon/parentheses carry the
300
+ # stage word. Returns a single line (no embedded newlines).
301
+ STAGE_LABELS = {
302
+ "what" => "What", "why" => "Why", "how" => "How",
303
+ "exec" => "Exec", "done" => "Done"
304
+ }.freeze
305
+
306
+ NEXT_HINTS = {
307
+ "why" => "write spec.md",
308
+ "how" => "Why complete. Invoke plastic-auto to deliver autonomously, or write plan.md manually.",
309
+ "exec" => "How complete. Invoke plastic-auto or plastic-executing-plan to execute, or work through the checklist manually.",
310
+ "done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic-auto or do it manually."
311
+ }.freeze
312
+
313
+ def self.stage_label(stage)
314
+ STAGE_LABELS[stage] || stage.to_s
315
+ end
316
+
317
+ # Build the gate-hook `additionalContext` sentence.
318
+ # transition: "PLASTIC: How reached (plan.md written). Next: <hint>"
319
+ # same-stage write: "PLASTIC: plan.md written (How). Next: <hint>"
320
+ # `new_missing` (missing files for the new stage) takes precedence over the
321
+ # stage hint, exactly as before, so the `Next:` content is unchanged.
322
+ def self.gate_narration(old_stage:, new_stage:, basename:, new_missing:, next_hints: NEXT_HINTS)
323
+ head = if old_stage != new_stage
324
+ "PLASTIC: #{stage_label(new_stage)} reached (#{basename} written)."
325
+ else
326
+ "PLASTIC: #{basename} written (#{stage_label(new_stage)})."
327
+ end
328
+
329
+ nxt =
330
+ if Array(new_missing).any?
331
+ "Next: #{Array(new_missing).join(", ")}"
332
+ elsif next_hints[new_stage]
333
+ "Next: #{next_hints[new_stage]}"
334
+ end
335
+
336
+ nxt ? "#{head} #{nxt}" : head
337
+ end
338
+
227
339
  # --- Cycle-step savepoint ledger (intent 34) ------------------------------
228
340
  #
229
341
  # savepoint.md is a deterministic, append-only, one-line-per-milestone ledger
@@ -256,8 +368,31 @@ module Bridge
256
368
  end.compact
257
369
  end
258
370
 
259
- # Append a milestone line for file_path if (and only if) it is a milestone
260
- # not already recorded. Returns true when a line was written, false otherwise.
371
+ # (stage, milestone) pairs already recorded in the ledger. The pair (not the
372
+ # milestone text alone) is the dedup key, because state-from-ledger lines like
373
+ # `Why started` and `How started` share the milestone text "started" while
374
+ # being distinct events (intent 81).
375
+ def self.savepoint_recorded_pairs(intent_dir)
376
+ f = File.join(intent_dir, SAVEPOINT_FILE)
377
+ return [] unless File.exist?(f)
378
+ File.read(f).each_line.filter_map do |line|
379
+ parts = line.strip.split(/\s{2,}/)
380
+ parts.length >= 3 ? [parts[1], parts[2]] : nil
381
+ end
382
+ end
383
+
384
+ # Append one ledger line for (stage, milestone) unless that pair is already
385
+ # recorded. The single append primitive shared by every line class. Returns
386
+ # true when a line was written, false when it was a no-op.
387
+ def self.append_savepoint_line(intent_dir, stage, milestone, now)
388
+ return false if savepoint_recorded_pairs(intent_dir).include?([stage, milestone])
389
+ line = "#{now.utc.iso8601} #{stage} #{milestone}\n"
390
+ File.open(File.join(intent_dir, SAVEPOINT_FILE), "a") { |io| io.write(line) }
391
+ true
392
+ end
393
+
394
+ # Append the artifact-landing milestone for file_path if (and only if) it is a
395
+ # milestone not already recorded. Returns true when a line was written.
261
396
  def self.append_savepoint(intent_dir, file_path, now: Time.now)
262
397
  basename = File.basename(file_path)
263
398
  stage, milestone = savepoint_milestone(intent_dir, basename)
@@ -265,11 +400,62 @@ module Bridge
265
400
  # A sentinel-marked lifecycle file logs NO milestone (the stage is not real
266
401
  # yet). The intent file is never sentineled, so it still logs its What line.
267
402
  return false unless stage_file_present?(File.join(intent_dir, basename))
268
- return false if savepoint_recorded_milestones(intent_dir).include?(milestone)
269
403
 
270
- line = "#{now.utc.iso8601} #{stage} #{milestone}\n"
271
- File.open(File.join(intent_dir, SAVEPOINT_FILE), "a") { |io| io.write(line) }
272
- true
404
+ append_savepoint_line(intent_dir, stage, milestone, now)
405
+ end
406
+
407
+ # --- State-from-ledger: pre-stage, exec-start, and terminal lines (81) ------
408
+ #
409
+ # On top of intent 34's artifact-landing milestones, the ledger gains:
410
+ # - `started` lines, one per cycle stage entry (pre-stage, written by the
411
+ # PreToolUse savepoint hook the moment a stage's artifact is first written);
412
+ # - an `Exec started` companion emitted when checklist.md lands;
413
+ # - a terminal `Done delivered|abandoned` line written by the completion path.
414
+ # None of these are derivable from files on disk, so they are deliberately NOT
415
+ # part of savepoint_milestone and are never regenerated by rebuild_savepoint:
416
+ # a rebuilt ledger is the file-landing skeleton, the live ledger is richer.
417
+
418
+ # Map a written filename to the [stage, "started"] pre-stage milestone, or nil.
419
+ # spec.md => entering Why, plan.md => entering How. checklist.md/outcome.md do
420
+ # not open a stage (checklist's Exec-start is the append_exec_started companion).
421
+ def self.savepoint_started_milestone(basename)
422
+ case basename
423
+ when "spec.md" then ["Why", "started"]
424
+ when "plan.md" then ["How", "started"]
425
+ end
426
+ end
427
+
428
+ # Append the pre-stage `started` line for file_path, iff: the basename opens a
429
+ # stage, the stage is genuinely starting (its artifact is not yet a REAL file,
430
+ # so a sentinel placeholder still counts as "starting"), and the pair is not
431
+ # already recorded. Returns true when a line was written.
432
+ def self.append_started_savepoint(intent_dir, file_path, now: Time.now)
433
+ basename = File.basename(file_path)
434
+ stage, milestone = savepoint_started_milestone(basename)
435
+ return false unless milestone
436
+ return false if stage_file_present?(File.join(intent_dir, basename))
437
+
438
+ append_savepoint_line(intent_dir, stage, milestone, now)
439
+ end
440
+
441
+ # Append the `Exec started` companion (emitted when checklist.md lands, in the
442
+ # same PostToolUse event as the `How checklist.md created` line). Idempotent.
443
+ def self.append_exec_started(intent_dir, now: Time.now)
444
+ append_savepoint_line(intent_dir, "Exec", "started", now)
445
+ end
446
+
447
+ TERMINAL_DISPOSITIONS = %w[delivered abandoned].freeze
448
+
449
+ # Append the terminal bookend `Done delivered|abandoned`, written by the
450
+ # completion path when an intent transfers to INDEX's Completed/Abandoned
451
+ # section. Idempotent per disposition. Raises on an unknown disposition.
452
+ def self.append_terminal_savepoint(intent_dir, disposition, now: Time.now)
453
+ unless TERMINAL_DISPOSITIONS.include?(disposition)
454
+ raise ArgumentError,
455
+ "disposition must be one of #{TERMINAL_DISPOSITIONS.join(', ')}, got #{disposition.inspect}"
456
+ end
457
+
458
+ append_savepoint_line(intent_dir, "Done", disposition, now)
273
459
  end
274
460
 
275
461
  # Reconstruct the ledger from files on disk (timestamps from mtimes), in
@@ -320,6 +506,23 @@ module Bridge
320
506
  "context_pct" => 0,
321
507
  "warning_at" => 80,
322
508
  "critical_at" => 90
509
+ },
510
+ # Worktree isolation block (intent 73c). Born unprovisioned; arm_auto calls
511
+ # Worktree.provision to fill it. code/store are abs paths or null.
512
+ "worktree" => {
513
+ "code" => nil,
514
+ "code_branch" => nil,
515
+ "store" => nil,
516
+ "store_branch" => nil,
517
+ "provisioned" => false
518
+ },
519
+ # Delivery lock block (intent 73c). The bridge IS the lock; the owner is
520
+ # whoever armed it. Born unowned; arm_auto stamps owner_session/pid/etc.
521
+ "lock" => {
522
+ "owner_session" => nil,
523
+ "pid" => nil,
524
+ "acquired_at" => nil,
525
+ "host" => nil
323
526
  }
324
527
  }
325
528
 
@@ -386,13 +589,32 @@ module Bridge
386
589
  # (mid-session intent creation). Re-derives intent state, then sets build.auto.
387
590
  def self.arm_auto(session, intent_id:, intent_dir:, store:, name:)
388
591
  key = resolve_session(session, intent_id: intent_id, store: store)
389
- if blank?(session) && blank?(ENV["CLAUDE_SESSION_ID"])
592
+ if blank?(session) && blank?(ENV["CLAUDE_CODE_SESSION_ID"])
390
593
  $stderr.puts "plastic: no session id available; arming auto with derived bridge key #{key}"
391
594
  end
392
595
  data = derive(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
393
596
  data["build"]["auto"] = true
597
+
598
+ # Acquire the delivery lock: this armed bridge is now the single owner of the
599
+ # intent's delivery (intent 73c). Stamp owner + pid liveness fields.
600
+ data["lock"] = {
601
+ "owner_session" => key,
602
+ "pid" => Process.pid,
603
+ "acquired_at" => Time.now.utc.iso8601,
604
+ "host" => (Socket.gethostname rescue nil)
605
+ }
606
+
607
+ # Provision the per-intent worktrees (mandatory code worktree for project
608
+ # intents; fail-open for non-git / global-only). Never let a provision error
609
+ # break arming: the lock and auto flag still matter.
610
+ begin
611
+ Worktree.provision(data)
612
+ rescue => e
613
+ $stderr.puts "plastic: worktree provision raised, continuing unprovisioned: #{e.message}"
614
+ end
615
+
394
616
  write(key, data)
395
- purge_stale_bridges(session: key)
617
+ purge_done_bridges(session: key)
396
618
  data
397
619
  end
398
620
 
@@ -402,8 +624,18 @@ module Bridge
402
624
  return nil unless data
403
625
  data["build"] ||= {}
404
626
  data["build"]["auto"] = false
627
+
628
+ # Release the worktrees the matching arm provisioned (intent 73c). Non-fatal:
629
+ # a release error must not block disarming. CLEANUP (73c3) refines the
630
+ # merge-vs-remove policy on the completion/release path.
631
+ begin
632
+ Worktree.release(data)
633
+ rescue => e
634
+ $stderr.puts "plastic: worktree release raised, continuing: #{e.message}"
635
+ end
636
+
405
637
  write(session, data)
406
- purge_stale_bridges(session: session)
638
+ purge_done_bridges(session: session)
407
639
  data
408
640
  end
409
641
 
@@ -441,6 +673,114 @@ module Bridge
441
673
  "(blocked edit: #{file_abs})"
442
674
  end
443
675
 
676
+ # --- Worktree isolation gate (intent 73c2) ---
677
+
678
+ # Returns a reason String to BLOCK, or nil to ALLOW. Two independent rules,
679
+ # both fail-open by construction:
680
+ #
681
+ # 1. When the bridge has a provisioned code worktree, a code edit (a target
682
+ # outside ~/.plastic and outside this intent's store dir) MUST land inside
683
+ # worktree["code"]; otherwise BLOCK and name the expected worktree path.
684
+ # 2. When the target lives inside ANOTHER intent's store dir whose bridge lock
685
+ # is held by a LIVE non-owner session, BLOCK (non-owner edit to an active
686
+ # intent).
687
+ #
688
+ # Fails open (returns nil) when provisioned is false (non-git / global-only) or
689
+ # the bridge carries no worktree/lock blocks. Logs nothing on the allow path.
690
+ def self.worktree_gate_decision(bridge_data, file_path, home: Dir.home, current_session: nil)
691
+ return nil unless bridge_data.is_a?(Hash)
692
+ return nil if blank?(file_path)
693
+
694
+ file_abs = File.expand_path(file_path.to_s)
695
+ plastic_home = File.expand_path(File.join(home, ".plastic"))
696
+ under_plastic = file_abs == plastic_home || file_abs.start_with?("#{plastic_home}/")
697
+
698
+ intent_info = bridge_data["intent"] || {}
699
+ store = intent_info["store"]
700
+ dir = intent_info["dir"]
701
+ intent_dir_abs = (store && dir) ? File.expand_path("#{store}/#{dir}") : nil
702
+ under_own_intent = intent_dir_abs &&
703
+ (file_abs == intent_dir_abs || file_abs.start_with?("#{intent_dir_abs}/"))
704
+
705
+ # Rule 1: provisioned code worktree confines project-code edits.
706
+ worktree = bridge_data["worktree"] || {}
707
+ if worktree["provisioned"] == true
708
+ code = worktree["code"].to_s
709
+ # Project code = outside ~/.plastic and outside this intent's store dir.
710
+ is_project_code = !under_plastic && !under_own_intent
711
+ if is_project_code && !blank?(code)
712
+ code_abs = File.expand_path(code)
713
+ inside_code = file_abs == code_abs || file_abs.start_with?("#{code_abs}/")
714
+ unless inside_code
715
+ id = intent_info["id"]
716
+ return "intent #{id} is isolated to its worktree — edit project code " \
717
+ "inside #{code_abs}, not the shared checkout. (blocked edit: #{file_abs})"
718
+ end
719
+ end
720
+ end
721
+
722
+ # Rule 2: do not edit another intent's locked, live store dir.
723
+ if under_plastic
724
+ reason = non_owner_store_edit_reason(file_abs, plastic_home, intent_dir_abs,
725
+ home: home, current_session: current_session,
726
+ own_session: bridge_data["session"])
727
+ return reason if reason
728
+ end
729
+
730
+ nil
731
+ end
732
+
733
+ # Helper for rule 2. A store dir is `<plastic_home>/store/{id}--{slug}` (global)
734
+ # or `<plastic_home>/projects/{slug}/store/{id}--{slug}` (project). When the
735
+ # edit target sits inside such a dir that is NOT this intent's own dir, and a
736
+ # live non-owner session holds that intent's bridge lock, BLOCK.
737
+ def self.non_owner_store_edit_reason(file_abs, plastic_home, own_intent_dir_abs,
738
+ home:, current_session:, own_session:)
739
+ return nil if own_intent_dir_abs &&
740
+ (file_abs == own_intent_dir_abs || file_abs.start_with?("#{own_intent_dir_abs}/"))
741
+
742
+ parsed = parse_store_target(file_abs, plastic_home)
743
+ return nil unless parsed
744
+
745
+ session = blank?(current_session) ? own_session : current_session
746
+ held = Worktree.lock_held_by_other?(
747
+ intent_id: parsed[:id], store: parsed[:store],
748
+ current_session: session, home: home,
749
+ )
750
+ return nil unless held
751
+
752
+ "intent #{parsed[:id]} is owned by another live session — its delivery lock " \
753
+ "is held elsewhere. Back off; do not edit #{file_abs}."
754
+ end
755
+
756
+ # Resolve an edit target inside a store to {id:, store:} for the intent dir it
757
+ # belongs to, or nil if the path is not inside an `{id}--{slug}` intent dir.
758
+ def self.parse_store_target(file_abs, plastic_home)
759
+ rels = []
760
+ global_store = File.join(plastic_home, "store")
761
+ if file_abs.start_with?("#{global_store}/")
762
+ rels << [file_abs[(global_store.length + 1)..], global_store]
763
+ end
764
+ projects = File.join(plastic_home, "projects")
765
+ if file_abs.start_with?("#{projects}/")
766
+ tail = file_abs[(projects.length + 1)..].to_s
767
+ parts = tail.split(File::SEPARATOR)
768
+ if parts.length >= 2 && parts[1] == "store"
769
+ pstore = File.join(projects, parts[0], "store")
770
+ rels << [file_abs[(pstore.length + 1)..], pstore]
771
+ end
772
+ end
773
+
774
+ rels.each do |rel, store_dir|
775
+ next if blank?(rel)
776
+ first = rel.split(File::SEPARATOR).first.to_s
777
+ idx = first.index("--")
778
+ next unless idx && idx > 0
779
+ return { id: first[0...idx], store: store_dir }
780
+ end
781
+ nil
782
+ end
783
+
444
784
  # --- Bash-edit gate (intent 27a) ---
445
785
 
446
786
  # Extract the set of file paths a Bash command writes to. Conservative by