@zalom/plastic 1.0.0-beta.10 → 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 +20 -0
- package/package.json +1 -1
- package/scripts/hook-code-gate +9 -4
- package/scripts/lib/bridge.rb +156 -0
- package/scripts/lib/installer_core.rb +1 -0
- package/scripts/lib/worktree.rb +409 -0
- package/skills/auto/SKILL.md +25 -6
- package/skills/auto/references/agent-architecture.md +7 -4
- package/skills/releasing/SKILL.md +32 -0
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
package/scripts/hook-code-gate
CHANGED
|
@@ -3,11 +3,15 @@
|
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
# Usage: hook-code-gate <file_path>
|
|
6
|
-
# PreToolUse gate
|
|
7
|
-
#
|
|
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
|
|
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}"
|
package/scripts/lib/bridge.rb
CHANGED
|
@@ -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
|
package/skills/auto/SKILL.md
CHANGED
|
@@ -43,13 +43,18 @@ edited before the plan exists (the gate applies to YOU, the orchestrator):
|
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
45
|
ruby -r ~/.plastic/scripts/lib/bridge -e \
|
|
46
|
-
'Bridge.arm_auto(ENV["
|
|
46
|
+
'Bridge.arm_auto(ENV["CLAUDE_CODE_SESSION_ID"], intent_id: "<ID>", intent_dir: "<STORE>/<dir>", store: "<STORE>", name: "<name>")'
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
Replace `<ID>`, `<STORE>` (e.g. `~/.plastic/projects/<slug>/store` or `~/.plastic/store`),
|
|
50
|
-
`<dir>` (the `ID--slug` directory), and `<name>`.
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
`<dir>` (the `ID--slug` directory), and `<name>`. The first argument is the session id you
|
|
51
|
+
want the bridge keyed by: pass the hook stdin `session_id` when you have it, otherwise
|
|
52
|
+
`ENV["CLAUDE_CODE_SESSION_ID"]`, otherwise `nil`. `arm_auto` calls `resolve_session`, which
|
|
53
|
+
picks the first non-empty of: the explicit id you pass -> `CLAUDE_SESSION_ID` ->
|
|
54
|
+
`CLAUDE_CODE_SESSION_ID` -> a deterministic derived key (a hash of the store and intent id).
|
|
55
|
+
It never returns nil, so the gate engages even when every session env var is empty; the call
|
|
56
|
+
never needs a non-empty `CLAUDE_SESSION_ID` to function. Arming prints a one-line notice to
|
|
57
|
+
stderr when it falls through to the derived key.
|
|
53
58
|
|
|
54
59
|
**Hard rule for the rest of this run:** do NOT edit project code (anything outside the
|
|
55
60
|
intent directory / `~/.plastic/`) until `plan.md` AND `checklist.md` exist for the intent.
|
|
@@ -79,7 +84,7 @@ Completion report (require-then-synthesize): every dispatched specialist MUST en
|
|
|
79
84
|
|
|
80
85
|
Final-gate review: dispatch an independent reviewer subagent at the final gate only, not as a standing role.
|
|
81
86
|
|
|
82
|
-
Headless manual gate: when running headless or in the background, enforce gates manually
|
|
87
|
+
Headless manual gate: when running headless or in the background, still enforce gates manually rather than relying on hooks alone. The PostToolUse gate hook reads `session_id` from hook stdin, and the savepoint ledger write is decoupled from the bridge (derived from the file path, so it fires even with no session id) - these do NOT no-op. What can degrade is the bridge-keyed stage enforcement: if no session id reaches the bridge and no matching bridge is discovered, the stage-gate enforcement step exits without acting, so verify state yourself. The bridge still resolves arming via `CLAUDE_CODE_SESSION_ID` or the derived-key fallback (see the arm-gate note above).
|
|
83
88
|
|
|
84
89
|
Solo fallback: if the harness has no subagent dispatch, fall back to a single agent walking the full What, Why, How, Exec cycle yourself. This preserves current behavior.
|
|
85
90
|
|
|
@@ -202,10 +207,24 @@ During initial project creation, all decisions are non-destructive by definition
|
|
|
202
207
|
store that holds this intent (the global store or the project store).
|
|
203
208
|
9. Disarm the lifecycle gate (auto delivery is finished):
|
|
204
209
|
```bash
|
|
205
|
-
ruby -r ~/.plastic/scripts/lib/bridge -e 'Bridge.disarm_auto(ENV["
|
|
210
|
+
ruby -r ~/.plastic/scripts/lib/bridge -e 'Bridge.disarm_auto(ENV["CLAUDE_CODE_SESSION_ID"])'
|
|
206
211
|
```
|
|
207
212
|
Disarming also purges stale bridge files from the temp directory automatically (it keeps the
|
|
208
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.
|
|
209
228
|
10. Notify user: "Intent [ID] — [name] delivered. [1-2 sentence summary]. See outcome.md for details."
|
|
210
229
|
|
|
211
230
|
## Error Handling
|
|
@@ -90,10 +90,13 @@ permanent sixth role, it exists only for the final review.
|
|
|
90
90
|
|
|
91
91
|
### Headless Manual Gate
|
|
92
92
|
|
|
93
|
-
When running headless or in the background, the enforcer enforces gates manually
|
|
94
|
-
|
|
95
|
-
gate
|
|
96
|
-
|
|
93
|
+
When running headless or in the background, the enforcer enforces gates manually rather
|
|
94
|
+
than relying on hooks alone. The savepoint ledger and PostToolUse gate hook still fire
|
|
95
|
+
(the gate hook reads `session_id` from stdin; the savepoint write is path-derived and
|
|
96
|
+
bridge-independent), so they do not blanket no-op. Only the bridge-keyed stage-enforcement
|
|
97
|
+
step degrades when no session id reaches the bridge and no bridge is discovered. The
|
|
98
|
+
enforcer arms via `CLAUDE_CODE_SESSION_ID` or the bridge's derived-key fallback and
|
|
99
|
+
verifies state itself.
|
|
97
100
|
|
|
98
101
|
### Delegation
|
|
99
102
|
|
|
@@ -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
|