@zalom/plastic 1.0.0-beta.11 → 1.0.0-beta.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/PLASTIC.md CHANGED
@@ -205,6 +205,26 @@ ALL work flows through intents.
205
205
 
206
206
  Hard blocking — hooks exit code 2 on gate failure.
207
207
 
208
+ ## Delivery Isolation and the Single-Owner Lock
209
+
210
+ Exactly one session or agent develops an intent's delivery at a time. Ownership is the armed
211
+ session bridge, which doubles as the delivery lock: arming records the owning session, the
212
+ owner pid, an acquired-at timestamp, and the host. Another session that finds an armed bridge
213
+ for the same intent with a live owner backs off; if the owner pid is dead the lock is
214
+ reclaimable. This is mandatory, not a convention.
215
+
216
+ Every code-touching intent gets its own git worktree named `{id}--{slug}`, and all code edits
217
+ for that intent happen only inside it. Plastic provisions the worktree deterministically: it
218
+ resolves the project repo from `projects.yml` and runs `git -C <repo> worktree add`, so
219
+ isolation never depends on the current working directory. There are two worktrees per project
220
+ intent: a code worktree at `<repo>/.claude/worktrees/{id}--{slug}` (branch `plastic/{id}--{slug}`)
221
+ and a store worktree at `<plastic_home>/.worktrees/{id}--{slug}` (branch
222
+ `plastic-store/{id}--{slug}`), so lifecycle-doc commits and code commits move as one unit.
223
+
224
+ Provisioning fails open for intents that touch no project code (pure research or decision
225
+ intents in the global store, or a non-git repo): those get the lock only, and the worktree
226
+ block stays unprovisioned. The fail-open path is always logged, never silent.
227
+
208
228
  ## Deprecation Process
209
229
 
210
230
  Deprecations live in `deprecations.yml` and are shown at SessionStart. While Plastic is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.0.0-beta.11",
3
+ "version": "1.0.0-beta.12",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,11 +3,15 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  # Usage: hook-code-gate <file_path>
6
- # PreToolUse gate (intent 27): when auto mode is armed and the active intent has not
7
- # reached How (plan.md + checklist.md), block edits to project code outside the store.
6
+ # PreToolUse gate. Composes two independent block rules; EITHER blocks the edit:
7
+ # - Stage rule (intent 27): when auto mode is armed and the active intent has not
8
+ # reached How (plan.md + checklist.md), block edits to project code outside the store.
9
+ # - Worktree isolation rule (intent 73c2): when the intent has a provisioned code
10
+ # worktree, block project-code edits outside it; and block edits to another
11
+ # intent's store dir whose delivery lock is held by a live non-owner session.
8
12
  #
9
13
  # Exit 0 = allow. Exit 2 = block (reason on stderr, shown to the agent).
10
- # No bridge / auto not armed / How reached / path under ~/.plastic or the intent dir = allow.
14
+ # No bridge = allow. Each rule fails open on its own conditions (see bridge.rb).
11
15
 
12
16
  require_relative "lib/bridge"
13
17
 
@@ -20,7 +24,8 @@ session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_SESSION_ID"]
20
24
  bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
21
25
  exit 0 unless bridge_data
22
26
 
23
- reason = Bridge.code_gate_decision(bridge_data, file_path)
27
+ reason = Bridge.code_gate_decision(bridge_data, file_path) ||
28
+ Bridge.worktree_gate_decision(bridge_data, file_path, current_session: session)
24
29
  exit 0 unless reason
25
30
 
26
31
  $stderr.puts "PLASTIC GATE — #{reason}"
@@ -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
@@ -368,6 +370,23 @@ module Bridge
368
370
  "context_pct" => 0,
369
371
  "warning_at" => 80,
370
372
  "critical_at" => 90
373
+ },
374
+ # Worktree isolation block (intent 73c). Born unprovisioned; arm_auto calls
375
+ # Worktree.provision to fill it. code/store are abs paths or null.
376
+ "worktree" => {
377
+ "code" => nil,
378
+ "code_branch" => nil,
379
+ "store" => nil,
380
+ "store_branch" => nil,
381
+ "provisioned" => false
382
+ },
383
+ # Delivery lock block (intent 73c). The bridge IS the lock; the owner is
384
+ # whoever armed it. Born unowned; arm_auto stamps owner_session/pid/etc.
385
+ "lock" => {
386
+ "owner_session" => nil,
387
+ "pid" => nil,
388
+ "acquired_at" => nil,
389
+ "host" => nil
371
390
  }
372
391
  }
373
392
 
@@ -439,6 +458,25 @@ module Bridge
439
458
  end
440
459
  data = derive(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
441
460
  data["build"]["auto"] = true
461
+
462
+ # Acquire the delivery lock: this armed bridge is now the single owner of the
463
+ # intent's delivery (intent 73c). Stamp owner + pid liveness fields.
464
+ data["lock"] = {
465
+ "owner_session" => key,
466
+ "pid" => Process.pid,
467
+ "acquired_at" => Time.now.utc.iso8601,
468
+ "host" => (Socket.gethostname rescue nil)
469
+ }
470
+
471
+ # Provision the per-intent worktrees (mandatory code worktree for project
472
+ # intents; fail-open for non-git / global-only). Never let a provision error
473
+ # break arming: the lock and auto flag still matter.
474
+ begin
475
+ Worktree.provision(data)
476
+ rescue => e
477
+ $stderr.puts "plastic: worktree provision raised, continuing unprovisioned: #{e.message}"
478
+ end
479
+
442
480
  write(key, data)
443
481
  purge_done_bridges(session: key)
444
482
  data
@@ -450,6 +488,16 @@ module Bridge
450
488
  return nil unless data
451
489
  data["build"] ||= {}
452
490
  data["build"]["auto"] = false
491
+
492
+ # Release the worktrees the matching arm provisioned (intent 73c). Non-fatal:
493
+ # a release error must not block disarming. CLEANUP (73c3) refines the
494
+ # merge-vs-remove policy on the completion/release path.
495
+ begin
496
+ Worktree.release(data)
497
+ rescue => e
498
+ $stderr.puts "plastic: worktree release raised, continuing: #{e.message}"
499
+ end
500
+
453
501
  write(session, data)
454
502
  purge_done_bridges(session: session)
455
503
  data
@@ -489,6 +537,114 @@ module Bridge
489
537
  "(blocked edit: #{file_abs})"
490
538
  end
491
539
 
540
+ # --- Worktree isolation gate (intent 73c2) ---
541
+
542
+ # Returns a reason String to BLOCK, or nil to ALLOW. Two independent rules,
543
+ # both fail-open by construction:
544
+ #
545
+ # 1. When the bridge has a provisioned code worktree, a code edit (a target
546
+ # outside ~/.plastic and outside this intent's store dir) MUST land inside
547
+ # worktree["code"]; otherwise BLOCK and name the expected worktree path.
548
+ # 2. When the target lives inside ANOTHER intent's store dir whose bridge lock
549
+ # is held by a LIVE non-owner session, BLOCK (non-owner edit to an active
550
+ # intent).
551
+ #
552
+ # Fails open (returns nil) when provisioned is false (non-git / global-only) or
553
+ # the bridge carries no worktree/lock blocks. Logs nothing on the allow path.
554
+ def self.worktree_gate_decision(bridge_data, file_path, home: Dir.home, current_session: nil)
555
+ return nil unless bridge_data.is_a?(Hash)
556
+ return nil if blank?(file_path)
557
+
558
+ file_abs = File.expand_path(file_path.to_s)
559
+ plastic_home = File.expand_path(File.join(home, ".plastic"))
560
+ under_plastic = file_abs == plastic_home || file_abs.start_with?("#{plastic_home}/")
561
+
562
+ intent_info = bridge_data["intent"] || {}
563
+ store = intent_info["store"]
564
+ dir = intent_info["dir"]
565
+ intent_dir_abs = (store && dir) ? File.expand_path("#{store}/#{dir}") : nil
566
+ under_own_intent = intent_dir_abs &&
567
+ (file_abs == intent_dir_abs || file_abs.start_with?("#{intent_dir_abs}/"))
568
+
569
+ # Rule 1: provisioned code worktree confines project-code edits.
570
+ worktree = bridge_data["worktree"] || {}
571
+ if worktree["provisioned"] == true
572
+ code = worktree["code"].to_s
573
+ # Project code = outside ~/.plastic and outside this intent's store dir.
574
+ is_project_code = !under_plastic && !under_own_intent
575
+ if is_project_code && !blank?(code)
576
+ code_abs = File.expand_path(code)
577
+ inside_code = file_abs == code_abs || file_abs.start_with?("#{code_abs}/")
578
+ unless inside_code
579
+ id = intent_info["id"]
580
+ return "intent #{id} is isolated to its worktree — edit project code " \
581
+ "inside #{code_abs}, not the shared checkout. (blocked edit: #{file_abs})"
582
+ end
583
+ end
584
+ end
585
+
586
+ # Rule 2: do not edit another intent's locked, live store dir.
587
+ if under_plastic
588
+ reason = non_owner_store_edit_reason(file_abs, plastic_home, intent_dir_abs,
589
+ home: home, current_session: current_session,
590
+ own_session: bridge_data["session"])
591
+ return reason if reason
592
+ end
593
+
594
+ nil
595
+ end
596
+
597
+ # Helper for rule 2. A store dir is `<plastic_home>/store/{id}--{slug}` (global)
598
+ # or `<plastic_home>/projects/{slug}/store/{id}--{slug}` (project). When the
599
+ # edit target sits inside such a dir that is NOT this intent's own dir, and a
600
+ # live non-owner session holds that intent's bridge lock, BLOCK.
601
+ def self.non_owner_store_edit_reason(file_abs, plastic_home, own_intent_dir_abs,
602
+ home:, current_session:, own_session:)
603
+ return nil if own_intent_dir_abs &&
604
+ (file_abs == own_intent_dir_abs || file_abs.start_with?("#{own_intent_dir_abs}/"))
605
+
606
+ parsed = parse_store_target(file_abs, plastic_home)
607
+ return nil unless parsed
608
+
609
+ session = blank?(current_session) ? own_session : current_session
610
+ held = Worktree.lock_held_by_other?(
611
+ intent_id: parsed[:id], store: parsed[:store],
612
+ current_session: session, home: home,
613
+ )
614
+ return nil unless held
615
+
616
+ "intent #{parsed[:id]} is owned by another live session — its delivery lock " \
617
+ "is held elsewhere. Back off; do not edit #{file_abs}."
618
+ end
619
+
620
+ # Resolve an edit target inside a store to {id:, store:} for the intent dir it
621
+ # belongs to, or nil if the path is not inside an `{id}--{slug}` intent dir.
622
+ def self.parse_store_target(file_abs, plastic_home)
623
+ rels = []
624
+ global_store = File.join(plastic_home, "store")
625
+ if file_abs.start_with?("#{global_store}/")
626
+ rels << [file_abs[(global_store.length + 1)..], global_store]
627
+ end
628
+ projects = File.join(plastic_home, "projects")
629
+ if file_abs.start_with?("#{projects}/")
630
+ tail = file_abs[(projects.length + 1)..].to_s
631
+ parts = tail.split(File::SEPARATOR)
632
+ if parts.length >= 2 && parts[1] == "store"
633
+ pstore = File.join(projects, parts[0], "store")
634
+ rels << [file_abs[(pstore.length + 1)..], pstore]
635
+ end
636
+ end
637
+
638
+ rels.each do |rel, store_dir|
639
+ next if blank?(rel)
640
+ first = rel.split(File::SEPARATOR).first.to_s
641
+ idx = first.index("--")
642
+ next unless idx && idx > 0
643
+ return { id: first[0...idx], store: store_dir }
644
+ end
645
+ nil
646
+ end
647
+
492
648
  # --- Bash-edit gate (intent 27a) ---
493
649
 
494
650
  # Extract the set of file paths a Bash command writes to. Conservative by
@@ -210,6 +210,7 @@ class InstallerCore
210
210
  "scripts/hook-bash-gate" => "scripts/hook-bash-gate",
211
211
  "scripts/hook-auto-arm" => "scripts/hook-auto-arm",
212
212
  "scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
213
+ "scripts/lib/worktree.rb" => "scripts/lib/worktree.rb",
213
214
  "scripts/lib/boot_banner.rb" => "scripts/lib/boot_banner.rb",
214
215
  "scripts/lib/qmd_sync.rb" => "scripts/lib/qmd_sync.rb",
215
216
  "scripts/qmd-sync" => "scripts/qmd-sync",
@@ -0,0 +1,409 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "yaml"
6
+ require "socket"
7
+ require "time"
8
+
9
+ # Worktree -- Plastic-supplied git worktree isolation and the delivery lock
10
+ # (intent 73c / 73c1).
11
+ #
12
+ # The harness `EnterWorktree` tool assumes cwd IS the repo root, which is false
13
+ # for Plastic (cwd is often the parent of the repo subdir). When the mismatch
14
+ # occurs the tool silently degrades to a plain feature branch on the shared
15
+ # checkout, so parallel intent deliveries are NOT isolated. This module makes
16
+ # isolation deterministic and cwd-independent: Plastic resolves the repo from
17
+ # projects.yml and runs `git -C <repo> worktree add`, so the cwd-not-root bug
18
+ # dies by construction (decision D6).
19
+ #
20
+ # Two worktrees per project intent, both named `{id}--{slug}` (decision D2):
21
+ # code worktree <repo>/.claude/worktrees/{id}--{slug} branch plastic/{id}--{slug}
22
+ # store worktree <plastic_home>/.worktrees/{id}--{slug} branch plastic-store/{id}--{slug}
23
+ #
24
+ # The bridge file doubles as the delivery lock (decision D3): single-owner,
25
+ # stale-lock reclaim via pid liveness.
26
+ #
27
+ # Pure and dependency-injected: every git call goes through an injected
28
+ # `ShellRunner`, so unit tests are hermetic (no real git; inject a fake runner).
29
+ # No eval, no global/ENV config injection.
30
+ module Worktree
31
+ module_function
32
+
33
+ # --- ShellRunner (DI seam) -------------------------------------------------
34
+
35
+ # The default runner shells out to real `git`. Tests inject a fake with the
36
+ # same `run(*args)` contract so no real git runs in unit tests.
37
+ class ShellRunner
38
+ Result = Struct.new(:status, :stdout, :stderr) do
39
+ def success?
40
+ status.zero?
41
+ end
42
+ end
43
+
44
+ def run(*args)
45
+ require "open3"
46
+ out, err, status = Open3.capture3("git", *args.map(&:to_s))
47
+ Result.new(status.exitstatus.to_i, out, err)
48
+ end
49
+ end
50
+
51
+ # --- pure helpers ----------------------------------------------------------
52
+
53
+ def blank?(value)
54
+ value.nil? || value.to_s.strip.empty?
55
+ end
56
+
57
+ # The `{id}--{slug}` identity shared by both worktrees and both branches.
58
+ def dir_name(intent_id, intent_slug)
59
+ "#{intent_id}--#{intent_slug}"
60
+ end
61
+
62
+ # Pure, deterministic. Returns the four paths/branches. No git calls.
63
+ # `repo_path` is resolved from projects.yml when nil; when it cannot be
64
+ # resolved the code worktree path/branch are nil (a global-store-only intent).
65
+ def paths(slug:, intent_id:, intent_slug:, home: Dir.home, repo_path: nil)
66
+ name = dir_name(intent_id, intent_slug)
67
+ repo = repo_path || repo_for(slug, home: home)
68
+ plastic_home = File.expand_path(File.join(home, ".plastic"))
69
+
70
+ code_path = repo ? File.join(File.expand_path(repo), ".claude", "worktrees", name) : nil
71
+ store_path = File.join(plastic_home, ".worktrees", name)
72
+
73
+ {
74
+ "code" => code_path,
75
+ "code_branch" => code_path ? "plastic/#{name}" : nil,
76
+ "store" => store_path,
77
+ "store_branch" => "plastic-store/#{name}",
78
+ }
79
+ end
80
+
81
+ # Absolute repo path for a project slug from `~/.plastic/projects.yml`, or nil.
82
+ # Reuses the qmd_sync safe-loader pattern: any failure yields nil.
83
+ def repo_for(slug, home: Dir.home)
84
+ return nil if blank?(slug)
85
+ projects = load_projects(home)
86
+ info = projects[slug.to_s]
87
+ path = info.is_a?(Hash) ? info["path"] : nil
88
+ return nil if blank?(path)
89
+ File.expand_path(path)
90
+ end
91
+
92
+ # --- provisioning ----------------------------------------------------------
93
+
94
+ # Resolve the slug from the bridge's intent.store, create code + store
95
+ # worktrees (idempotent: reuse an existing worktree path, do not error), write
96
+ # the `worktree` block plus `provisioned: true` onto bridge_data, return it.
97
+ #
98
+ # Fails open with a stderr log when the repo is non-git or unresolvable:
99
+ # sets `provisioned: false` and leaves `code: null`. All git ops use
100
+ # `git -C <resolved path>` -- never cwd (decision D6).
101
+ def provision(bridge_data, home: Dir.home, runner: ShellRunner.new)
102
+ return bridge_data unless bridge_data.is_a?(Hash)
103
+ intent = bridge_data["intent"] || {}
104
+ intent_id = intent["id"].to_s
105
+ store = intent["store"].to_s
106
+ intent_slug = slug_from_dir(intent["dir"]) || slug_from_dir(store)
107
+
108
+ slug = slug_for_store(store, home: home)
109
+ p = paths(slug: slug, intent_id: intent_id, intent_slug: intent_slug, home: home)
110
+
111
+ block = {
112
+ "code" => nil,
113
+ "code_branch" => nil,
114
+ "store" => p["store"],
115
+ "store_branch" => p["store_branch"],
116
+ "provisioned" => false,
117
+ }
118
+
119
+ plastic_home = File.expand_path(File.join(home, ".plastic"))
120
+
121
+ # Gitignore safety (intent 73c3): the store worktrees live under the store git
122
+ # repo, so without ignoring `.worktrees/` a `git add -A` sweeps their gitlinks
123
+ # into the store commit. Ensure both ignore entries before any worktree add.
124
+ ensure_gitignored(plastic_home, ".worktrees/", runner: runner)
125
+
126
+ # Store worktree: created against the plastic home git repo. Fail-open if the
127
+ # store repo is not a git repo (a fresh global store may be ungit'd).
128
+ store_ok = add_worktree(runner, repo: plastic_home,
129
+ worktree: p["store"], branch: p["store_branch"],
130
+ label: "store")
131
+
132
+ # Code worktree: MANDATORY for project intents. Fail-open when the repo is
133
+ # unresolvable or non-git -- that is the global-store-only / non-git case.
134
+ repo = repo_for(slug, home: home)
135
+ code_ok = false
136
+ if repo && git_repo?(runner, repo)
137
+ ensure_gitignored(repo, ".claude/worktrees/", runner: runner)
138
+ code_ok = add_worktree(runner, repo: repo,
139
+ worktree: p["code"], branch: p["code_branch"],
140
+ label: "code")
141
+ if code_ok
142
+ block["code"] = p["code"]
143
+ block["code_branch"] = p["code_branch"]
144
+ end
145
+ else
146
+ warn "plastic: worktree provision fail-open -- repo for slug #{slug.inspect} " \
147
+ "is unresolvable or not a git repo; code worktree skipped"
148
+ end
149
+
150
+ block["store"] = store_ok ? p["store"] : nil
151
+ block["store_branch"] = store_ok ? p["store_branch"] : nil
152
+
153
+ # provisioned is true only when the MANDATORY code worktree exists. The gate
154
+ # fails open on provisioned: false (non-git / global-only).
155
+ block["provisioned"] = code_ok
156
+
157
+ bridge_data["worktree"] = block
158
+ bridge_data
159
+ end
160
+
161
+ # Remove both worktrees (then `git worktree prune`), clear the worktree block.
162
+ # No-op when nothing was provisioned. CLEANUP (73c3) layers the merge-vs-remove
163
+ # policy on top via `finish`; this is the plain remove. Pass `remove: false` to
164
+ # clear the block WITHOUT touching git (so `finish` can merge first, then call
165
+ # release to drop the worktrees once the code branch is integrated).
166
+ def release(bridge_data, home: Dir.home, runner: ShellRunner.new, remove: true)
167
+ return bridge_data unless bridge_data.is_a?(Hash)
168
+ block = bridge_data["worktree"]
169
+ return bridge_data unless block.is_a?(Hash)
170
+
171
+ if remove
172
+ plastic_home = File.expand_path(File.join(home, ".plastic"))
173
+ slug = slug_for_store(bridge_data.dig("intent", "store").to_s, home: home)
174
+ repo = repo_for(slug, home: home)
175
+
176
+ remove_worktree(runner, repo: repo, worktree: block["code"]) if repo && block["code"]
177
+ remove_worktree(runner, repo: plastic_home, worktree: block["store"]) if block["store"]
178
+
179
+ prune(runner, repo: repo) if repo
180
+ prune(runner, repo: plastic_home)
181
+ end
182
+
183
+ bridge_data.delete("worktree")
184
+ bridge_data
185
+ end
186
+
187
+ # --- cleanup policy (merge-vs-remove) -------------------------------------
188
+
189
+ # Finish an intent's delivery by tearing down its worktrees, optionally merging
190
+ # the code branch back first (intent 73c3). The merge-vs-remove decision is the
191
+ # one piece of policy on top of the plain `release`:
192
+ #
193
+ # merge: true -> the releasing path. Merge the intent's code branch
194
+ # (`plastic/{id}--{slug}`) into the repo's default branch
195
+ # BEFORE removing the worktrees, so the work is integrated and
196
+ # not lost when the worktree disappears. Then `release`.
197
+ # merge: false -> the disarm / abandon path. Just `release` (plain remove);
198
+ # the branch survives and can be reclaimed.
199
+ #
200
+ # Fail-open and idempotent throughout: a missing block, missing branch, or any
201
+ # git failure never raises and never blocks teardown. All git ops use
202
+ # `git -C <path>`, never cwd (decision D6). No-op when nothing was provisioned.
203
+ def finish(bridge_data, home: Dir.home, runner: ShellRunner.new, merge: false)
204
+ return bridge_data unless bridge_data.is_a?(Hash)
205
+ block = bridge_data["worktree"]
206
+ return bridge_data unless block.is_a?(Hash)
207
+
208
+ if merge
209
+ slug = slug_for_store(bridge_data.dig("intent", "store").to_s, home: home)
210
+ repo = repo_for(slug, home: home)
211
+ branch = block["code_branch"]
212
+ merge_branch(runner, repo: repo, branch: branch) if repo && !blank?(branch)
213
+ end
214
+
215
+ release(bridge_data, home: home, runner: runner, remove: true)
216
+ end
217
+
218
+ # Merge `branch` into the repo's default branch from the main checkout. The
219
+ # worktree the branch is checked out in stays put; we merge in the repo dir
220
+ # itself (its own current branch is the integration target). Idempotent: a
221
+ # no-op merge ("Already up to date") still succeeds. Fail-open: a conflicting
222
+ # or otherwise failing merge is aborted and logged, never raised, so teardown
223
+ # still proceeds (CLEANUP must not strand a worktree).
224
+ def merge_branch(runner, repo:, branch:)
225
+ return false if blank?(repo) || blank?(branch)
226
+ target = current_branch(runner, repo: repo)
227
+ return false if blank?(target) || target == branch
228
+
229
+ res = runner.run("-C", repo, "merge", "--no-ff", "--no-edit", branch)
230
+ return true if res.success?
231
+
232
+ # Leave the integration branch clean: abort a half-applied/conflicted merge.
233
+ runner.run("-C", repo, "merge", "--abort")
234
+ warn "plastic: worktree finish could not merge #{branch.inspect} into " \
235
+ "#{target.inspect}: #{res.stderr.to_s.strip}; removing worktree without merge"
236
+ false
237
+ end
238
+
239
+ # The repo's current branch (the integration target), or nil when detached /
240
+ # unresolvable.
241
+ def current_branch(runner, repo:)
242
+ return nil if blank?(repo)
243
+ res = runner.run("-C", repo, "rev-parse", "--abbrev-ref", "HEAD")
244
+ return nil unless res.success?
245
+ name = res.stdout.to_s.strip
246
+ (name.empty? || name == "HEAD") ? nil : name
247
+ end
248
+
249
+ # --- gitignore safety ------------------------------------------------------
250
+
251
+ # Ensure `entry` is present in `<repo>/.gitignore`, appending it once if absent
252
+ # (idempotent). Without this, the store worktrees that live UNDER the store git
253
+ # repo (~/.plastic/.worktrees/) get swept into the store commit by a `git add
254
+ # -A`, polluting the index with worktree gitlinks (observed during 73c1
255
+ # integration). Provisioning and cleanup both call this so the repos' indexes
256
+ # stay clean. Best-effort and non-raising: any failure is logged, never raised.
257
+ def ensure_gitignored(repo, entry, runner: ShellRunner.new)
258
+ return false if blank?(repo) || blank?(entry) || !Dir.exist?(repo)
259
+ gitignore = File.join(File.expand_path(repo), ".gitignore")
260
+ want = entry.to_s.strip
261
+
262
+ existing = File.exist?(gitignore) ? File.read(gitignore) : ""
263
+ present = existing.each_line.any? { |line| line.strip == want }
264
+ return true if present
265
+
266
+ File.open(gitignore, "a") do |io|
267
+ io.write("\n") unless existing.empty? || existing.end_with?("\n")
268
+ io.write("#{want}\n")
269
+ end
270
+ true
271
+ rescue StandardError => e
272
+ warn "plastic: ensure_gitignored(#{entry.inspect}) failed for #{repo.inspect}: #{e.message}"
273
+ false
274
+ end
275
+
276
+ # --- lock ------------------------------------------------------------------
277
+
278
+ # pid liveness: signal 0 probes without sending. Any error (no such process,
279
+ # not ours) means not live.
280
+ def session_live?(pid)
281
+ n = Integer(pid) rescue nil
282
+ return false if n.nil? || n <= 0
283
+ Process.kill(0, n)
284
+ true
285
+ rescue StandardError
286
+ false
287
+ end
288
+
289
+ # True iff ANOTHER bridge for this intent has a LIVE owner pid that is not
290
+ # current_session. Scans /tmp/plastic-*.json (or `tmp`). The current session's
291
+ # own bridge never counts as "other". A dead owner does not hold the lock
292
+ # (stale-lock reclaim).
293
+ def lock_held_by_other?(intent_id:, store:, current_session:, home: Dir.home, tmp: nil)
294
+ tmp ||= default_tmp
295
+ id = intent_id.to_s
296
+ st = File.expand_path(store.to_s) unless blank?(store)
297
+
298
+ Dir.glob(File.join(tmp, "plastic-*.json")).each do |f|
299
+ next if f.end_with?(".tmp")
300
+ data = (JSON.parse(File.read(f)) rescue nil)
301
+ next unless data.is_a?(Hash)
302
+
303
+ intent = data["intent"] || {}
304
+ next unless intent["id"].to_s == id
305
+ unless st.nil?
306
+ bstore = intent["store"].to_s
307
+ next unless bstore.empty? || File.expand_path(bstore) == st
308
+ end
309
+
310
+ session = data["session"].to_s
311
+ next if !blank?(current_session) && session == current_session.to_s
312
+
313
+ lock = data["lock"] || {}
314
+ owner_pid = lock["pid"]
315
+ return true if session_live?(owner_pid)
316
+ end
317
+ false
318
+ rescue StandardError
319
+ false
320
+ end
321
+
322
+ # --- git operations (all use -C, never cwd) --------------------------------
323
+
324
+ # Idempotent worktree add. If `worktree` already exists on disk, treat as
325
+ # reuse (success, no git call). Otherwise `git -C <repo> worktree add <wt>
326
+ # -b <branch>`; if the branch already exists, retry without -b (reattach).
327
+ def add_worktree(runner, repo:, worktree:, branch:, label:)
328
+ return false if blank?(repo) || blank?(worktree)
329
+ return true if Dir.exist?(worktree) # idempotent reuse
330
+
331
+ res = runner.run("-C", repo, "worktree", "add", worktree, "-b", branch)
332
+ return true if res.success?
333
+
334
+ # Branch may already exist (a prior provision that was pruned but kept the
335
+ # branch). Retry attaching the existing branch.
336
+ res2 = runner.run("-C", repo, "worktree", "add", worktree, branch)
337
+ return true if res2.success?
338
+
339
+ warn "plastic: worktree add (#{label}) failed: #{res.stderr.to_s.strip}"
340
+ false
341
+ end
342
+
343
+ def remove_worktree(runner, repo:, worktree:)
344
+ return false if blank?(repo) || blank?(worktree)
345
+ res = runner.run("-C", repo, "worktree", "remove", worktree)
346
+ unless res.success?
347
+ # Force-remove tolerates dirty/locked worktrees; CLEANUP owns merge policy.
348
+ res = runner.run("-C", repo, "worktree", "remove", "--force", worktree)
349
+ end
350
+ res.success?
351
+ end
352
+
353
+ def prune(runner, repo:)
354
+ return false if blank?(repo)
355
+ runner.run("-C", repo, "worktree", "prune").success?
356
+ end
357
+
358
+ # True iff `repo` is a git work tree (idempotent, no mutation).
359
+ def git_repo?(runner, repo)
360
+ return false if blank?(repo) || !Dir.exist?(repo)
361
+ res = runner.run("-C", repo, "rev-parse", "--is-inside-work-tree")
362
+ res.success? && res.stdout.to_s.strip == "true"
363
+ end
364
+
365
+ # --- internals (projects.yml resolution, mirrors qmd_sync) -----------------
366
+
367
+ def load_projects(home)
368
+ path = File.join(File.expand_path(home), ".plastic", "projects.yml")
369
+ return {} unless File.exist?(path)
370
+ data = begin
371
+ YAML.safe_load(File.read(path)) || {}
372
+ rescue StandardError
373
+ {}
374
+ end
375
+ projects = data.is_a?(Hash) ? data["projects"] : nil
376
+ projects.is_a?(Hash) ? projects : {}
377
+ end
378
+
379
+ # Resolve a project slug from a store directory. A project's tactical store
380
+ # lives at <plastic_home>/projects/<slug>/store; the global store yields nil
381
+ # (no project repo). Mirrors qmd_sync's slug_for_store fallback.
382
+ def slug_for_store(store_dir, home: Dir.home)
383
+ return nil if blank?(store_dir)
384
+ plastic_home = File.expand_path(File.join(home, ".plastic"))
385
+ store_dir = File.expand_path(store_dir)
386
+ return nil if store_dir == File.join(plastic_home, "store")
387
+
388
+ parts = store_dir.split(File::SEPARATOR)
389
+ idx = parts.rindex("projects")
390
+ return parts[idx + 1] if idx && parts[idx + 1] && parts[idx + 2] == "store"
391
+ nil
392
+ end
393
+
394
+ # Best-effort slug for the worktree dir-name from an intent dir/store path:
395
+ # the basename `{id}--{slug}` -> the `{slug}` portion (split on the first
396
+ # `--`). Used only for naming.
397
+ def slug_from_dir(dir)
398
+ return nil if blank?(dir)
399
+ base = File.basename(dir.to_s)
400
+ idx = base.index("--")
401
+ return nil unless idx
402
+ base[(idx + 2)..]
403
+ end
404
+
405
+ def default_tmp
406
+ t = ENV["PLASTIC_TMP"]
407
+ (t.nil? || t.strip.empty?) ? "/tmp" : t
408
+ end
409
+ end
@@ -211,6 +211,20 @@ During initial project creation, all decisions are non-destructive by definition
211
211
  ```
212
212
  Disarming also purges stale bridge files from the temp directory automatically (it keeps the
213
213
  current bridge and any live run), so no manual `/tmp` cleanup is needed.
214
+
215
+ **Worktree cleanup (mandatory, intent 73c3).** Disarming performs the worktree release:
216
+ `disarm_auto` calls `Worktree.release`, which removes both per-intent worktrees (the code
217
+ worktree under `<repo>/.claude/worktrees/{id}--{slug}` and the paired store worktree under
218
+ `<plastic_home>/.worktrees/{id}--{slug}`), prunes both repos, and clears the worktree block
219
+ from the bridge. This is the plain remove path: the disarm route does NOT merge, so use it
220
+ only when no release merges the branch (the branch survives and can be reclaimed).
221
+
222
+ When the work is being shipped through a release, do NOT rely on this plain remove. The
223
+ release path (step 4 above, via `plastic-releasing`) is responsible for merging the intent's
224
+ code branch (`plastic/{id}--{slug}`) back to the repo's default branch BEFORE the worktree is
225
+ removed, so the integrated work is not lost. It does this with `Worktree.finish(bridge_data,
226
+ merge: true)` (merge-then-remove). Never leave an orphaned worktree, and run `git worktree
227
+ prune` if you hit a stale reference.
214
228
  10. Notify user: "Intent [ID] — [name] delivered. [1-2 sentence summary]. See outcome.md for details."
215
229
 
216
230
  ## Error Handling
@@ -20,6 +20,7 @@ Project configuration drives the workflow - no hardcoded assumptions.
20
20
  - [ ] Run post-push actions (GitHub release, npm publish, etc.)
21
21
  - [ ] Verify release sync (npm dist-tag, GitHub "Latest", git tag all show the new version)
22
22
  - [ ] Complete active intent
23
+ - [ ] Clean up the intent's worktrees (merge-then-remove)
23
24
 
24
25
  ## Workflow
25
26
 
@@ -84,6 +85,15 @@ git merge <branch-name> --no-ff -m "feat: merge intent [ID] - [description]"
84
85
 
85
86
  Always `--no-ff` to preserve branch history in the merge commit.
86
87
 
88
+ **Worktree-isolated intents (intent 73c3).** When the intent was delivered in a Plastic
89
+ worktree (the bridge has a provisioned `worktree` block), its code lives on the branch
90
+ `plastic/{id}--{slug}` inside `<repo>/.claude/worktrees/{id}--{slug}`, not on a hand-made
91
+ feature branch. The merge-then-remove of that worktree is handled together with cleanup in
92
+ step 9, which merges `plastic/{id}--{slug}` into the default branch BEFORE removing the
93
+ worktree. If you already merged here by hand, step 9 is a clean no-op merge ("Already up to
94
+ date") and proceeds straight to removal. Do not delete the worktree before its branch is
95
+ merged, or the work is lost.
96
+
87
97
  ### 4. Bump Version
88
98
 
89
99
  Determine which files to update from project.yml:
@@ -207,6 +217,28 @@ A release IS a delivery. The active intent that drove this work must be complete
207
217
 
208
218
  **If no active intent exists for this release**, that itself is a problem - work happened outside the intent system. Log it and move on, but flag it.
209
219
 
220
+ ### 9. Clean Up the Intent's Worktrees (merge-then-remove)
221
+
222
+ A release is the merge-then-remove path for the intent's worktrees (intent 73c3). This is the
223
+ one place the merge-vs-remove policy lands on "merge": the intent's code branch
224
+ (`plastic/{id}--{slug}`) is merged back into the repo's default branch BEFORE the worktree is
225
+ removed, so the integrated work is never lost. (The disarm path in `plastic-auto`, by contrast,
226
+ is a plain remove because no release is merging the branch.)
227
+
228
+ Drive it through `Worktree.finish` with `merge: true`, which merges the code branch, then
229
+ removes both worktrees (code + paired store), prunes both repos, and clears the worktree block
230
+ from the bridge:
231
+
232
+ ```bash
233
+ ruby -r ~/.plastic/scripts/lib/worktree -r ~/.plastic/scripts/lib/bridge -e \
234
+ 'b = Bridge.read(ENV["CLAUDE_CODE_SESSION_ID"]); Worktree.finish(b, merge: true) if b'
235
+ ```
236
+
237
+ `finish` is fail-open and idempotent: a conflicting merge is aborted and logged (the worktree
238
+ is still removed rather than stranded), and a second call with the block already cleared is a
239
+ no-op. Honor the worktree-cleanup rule: never leave an orphaned worktree, and run `git worktree
240
+ prune` in the affected repo if you hit a stale reference.
241
+
210
242
  ## Conventions
211
243
 
212
244
  - **Annotated tags only** - `git tag -a`, never lightweight tags