@zalom/plastic 1.0.0-beta.11 → 1.0.0-beta.13
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 +71 -0
- package/hooks/hooks.json +20 -0
- package/hooks/retrieval-gate +10 -0
- package/hooks/savepoint-pre +10 -0
- package/package.json +1 -1
- package/scripts/hook-code-gate +9 -4
- package/scripts/hook-gate-check +13 -22
- package/scripts/hook-retrieval-gate +122 -0
- package/scripts/hook-savepoint-pre +32 -0
- package/scripts/lib/bridge.rb +281 -6
- package/scripts/lib/installer_core.rb +13 -0
- package/scripts/lib/qmd_sync.rb +15 -0
- package/scripts/lib/retrieval_gate.rb +238 -0
- package/scripts/lib/worktree.rb +409 -0
- package/scripts/new-intent +9 -1
- package/scripts/spawn-preamble +4 -2
- package/skills/auto/SKILL.md +34 -2
- package/skills/auto/references/agent-report-contract.md +8 -0
- package/skills/continuing/SKILL.md +21 -6
- package/skills/intent-curator/SKILL.md +4 -1
- package/skills/managing-index/SKILL.md +2 -0
- package/skills/releasing/SKILL.md +32 -0
|
@@ -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/scripts/new-intent
CHANGED
|
@@ -311,7 +311,15 @@ def main(argv)
|
|
|
311
311
|
File.write(File.join(intent_dir, name), "#{Bridge::PLACEHOLDER_SENTINEL}\n#{body}")
|
|
312
312
|
end
|
|
313
313
|
|
|
314
|
-
# 6.
|
|
314
|
+
# 6. Stamp the born savepoint line (intent 81). The first ledger line is the
|
|
315
|
+
# `What` bookend, written deterministically at creation rather than relying on
|
|
316
|
+
# a PostToolUse gate firing on the intent-file write (which is missed in some
|
|
317
|
+
# sessions / harnesses). The intent file is never a sentinel placeholder, so
|
|
318
|
+
# append_savepoint records `What {id}--{slug}.md`. Idempotent: a later gate
|
|
319
|
+
# fire adds nothing.
|
|
320
|
+
Bridge.append_savepoint(intent_dir, intent_file)
|
|
321
|
+
|
|
322
|
+
# 7. Self-validate (frontmatter + sanctioned sections).
|
|
315
323
|
result = IntentValidator.validate(intent_dir)
|
|
316
324
|
unless result[:ok]
|
|
317
325
|
warn "new-intent: scaffolded intent is NOT born complete:"
|
package/scripts/spawn-preamble
CHANGED
|
@@ -43,8 +43,10 @@ REPORT_CONTRACT =
|
|
|
43
43
|
"status (delivered or blocked), artifacts written, verification or tests run, " \
|
|
44
44
|
"checklist deltas, deviations from spec, and blockers or handoff notes; plus a " \
|
|
45
45
|
"role-specific payload that fulfils your place in the What, Why, How, Exec cycle " \
|
|
46
|
-
"(for example the planner explains the plan back to the orchestrator).
|
|
47
|
-
"
|
|
46
|
+
"(for example the planner explains the plan back to the orchestrator). Keep the " \
|
|
47
|
+
"report prose-stripped: the envelope and payload only, no greeting, no preamble, " \
|
|
48
|
+
"no end-recap, no restating of the task; reasoning stays in the thinking channel. " \
|
|
49
|
+
"See skills/auto/references/agent-report-contract.md for the per-role format."
|
|
48
50
|
|
|
49
51
|
def parse_args(argv)
|
|
50
52
|
role = nil
|
package/skills/auto/SKILL.md
CHANGED
|
@@ -90,7 +90,19 @@ Solo fallback: if the harness has no subagent dispatch, fall back to a single ag
|
|
|
90
90
|
|
|
91
91
|
## Stage-Aware Entry
|
|
92
92
|
|
|
93
|
-
Read the active intent's
|
|
93
|
+
Read the active intent's `savepoint.md` FIRST (intent 81): the last line classifies the stage,
|
|
94
|
+
and you then verify only that line's artifact before entering. Fall back to the filesystem probe
|
|
95
|
+
below only when the ledger is missing (then rebuild it with `Bridge.rebuild_savepoint`).
|
|
96
|
+
|
|
97
|
+
| Ledger last line | Enter |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `What {id}--{slug}.md` (born) or no spec | Start / complete Why (write spec.md) |
|
|
100
|
+
| `Why spec.md created` | Enter How |
|
|
101
|
+
| `How plan.md created` / `How checklist.md created` / `Exec started` | Enter Exec (verify plan + checklist) |
|
|
102
|
+
| `Exec outcome.md created` | Exec done; complete the intent |
|
|
103
|
+
| `Done delivered|abandoned` | Terminal; do not resume |
|
|
104
|
+
|
|
105
|
+
Filesystem fallback (ledger missing only):
|
|
94
106
|
|
|
95
107
|
| Check (in order) | Stage |
|
|
96
108
|
|---|---|
|
|
@@ -196,7 +208,13 @@ During initial project creation, all decisions are non-destructive by definition
|
|
|
196
208
|
5. Review `## Insights` for observations that should spawn future intents. If any:
|
|
197
209
|
- Create them (using `plastic-creating-intent` conventions)
|
|
198
210
|
- Update `chain` in the current intent's frontmatter
|
|
199
|
-
6. Move intent from `## Active` to `## Completed` in INDEX.md (with today's date)
|
|
211
|
+
6. Move intent from `## Active` to `## Completed` in INDEX.md (with today's date). As the
|
|
212
|
+
closing act of the transfer, stamp the terminal ledger bookend (intent 81) so the savepoint's
|
|
213
|
+
last line records delivery:
|
|
214
|
+
```bash
|
|
215
|
+
ruby -r ~/.plastic/scripts/lib/bridge -e 'Bridge.append_terminal_savepoint("<intent_dir>", "delivered")'
|
|
216
|
+
```
|
|
217
|
+
(Use `"abandoned"` instead when the intent is being moved to `## Abandoned`.) Idempotent.
|
|
200
218
|
7. Auto-commit: `cd <store-root> && git add . && git commit -m "feat: deliver intent <ID> — <name>"`
|
|
201
219
|
8. On completion, ALWAYS refresh the QMD search index for this store (no-op when QMD is absent).
|
|
202
220
|
It runs in the background so it never blocks the turn:
|
|
@@ -211,6 +229,20 @@ During initial project creation, all decisions are non-destructive by definition
|
|
|
211
229
|
```
|
|
212
230
|
Disarming also purges stale bridge files from the temp directory automatically (it keeps the
|
|
213
231
|
current bridge and any live run), so no manual `/tmp` cleanup is needed.
|
|
232
|
+
|
|
233
|
+
**Worktree cleanup (mandatory, intent 73c3).** Disarming performs the worktree release:
|
|
234
|
+
`disarm_auto` calls `Worktree.release`, which removes both per-intent worktrees (the code
|
|
235
|
+
worktree under `<repo>/.claude/worktrees/{id}--{slug}` and the paired store worktree under
|
|
236
|
+
`<plastic_home>/.worktrees/{id}--{slug}`), prunes both repos, and clears the worktree block
|
|
237
|
+
from the bridge. This is the plain remove path: the disarm route does NOT merge, so use it
|
|
238
|
+
only when no release merges the branch (the branch survives and can be reclaimed).
|
|
239
|
+
|
|
240
|
+
When the work is being shipped through a release, do NOT rely on this plain remove. The
|
|
241
|
+
release path (step 4 above, via `plastic-releasing`) is responsible for merging the intent's
|
|
242
|
+
code branch (`plastic/{id}--{slug}`) back to the repo's default branch BEFORE the worktree is
|
|
243
|
+
removed, so the integrated work is not lost. It does this with `Worktree.finish(bridge_data,
|
|
244
|
+
merge: true)` (merge-then-remove). Never leave an orphaned worktree, and run `git worktree
|
|
245
|
+
prune` if you hit a stale reference.
|
|
214
246
|
10. Notify user: "Intent [ID] — [name] delivered. [1-2 sentence summary]. See outcome.md for details."
|
|
215
247
|
|
|
216
248
|
## Error Handling
|
|
@@ -17,6 +17,14 @@ only. In-flight observations still go in `## Insights`; the report does not add
|
|
|
17
17
|
completed its handoff: the agent that did the work is the cheapest, most accurate source of the
|
|
18
18
|
account.
|
|
19
19
|
|
|
20
|
+
## Prose-stripped (intent 84)
|
|
21
|
+
|
|
22
|
+
The report is the envelope and the per-role payload, nothing else. Dispatched and background
|
|
23
|
+
subagents report and do their job; they do not narrate. Strip conversational prose: no
|
|
24
|
+
greeting, no preamble, no "Here is what I did" framing, no end-recap, no restating of the task.
|
|
25
|
+
Reasoning belongs in the thinking channel, not the report body. This tightens the FORM (the
|
|
26
|
+
fields stay exactly as below); it does not remove any required field.
|
|
27
|
+
|
|
20
28
|
## Common envelope
|
|
21
29
|
|
|
22
30
|
Every role report, whatever the stage, carries these fields:
|
|
@@ -63,13 +63,28 @@ command is a no-op when QMD is absent, so fall back to the existing INDEX.md / f
|
|
|
63
63
|
|
|
64
64
|
For that intent's directory:
|
|
65
65
|
|
|
66
|
-
1. **Read `savepoint.md
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
66
|
+
1. **Read `savepoint.md` FIRST (intent 81).** It is a deterministic, append-only ledger
|
|
67
|
+
(one line per event, newest at the bottom): `{utc-iso8601} {Stage} {milestone}`. Classify
|
|
68
|
+
the state from the **last line** alone, then verify ONLY that line's artifact. The bookends
|
|
69
|
+
are fixed: first line `What created`, last line either a cycle position or
|
|
70
|
+
`Done delivered|abandoned`.
|
|
71
|
+
|
|
72
|
+
| Last line | State | Verify only |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `What {id}--{slug}.md` | born / parked | intent file exists |
|
|
75
|
+
| `Why started` | Why entered, no spec yet | spec.md not yet real; continue Why |
|
|
76
|
+
| `Why spec.md created` | Why done | spec.md present; continue to How |
|
|
77
|
+
| `How started` / `How plan.md created` | How in progress | plan.md; continue How |
|
|
78
|
+
| `How checklist.md created` / `Exec started` | ready for / in Exec | plan.md + checklist.md present; continue Exec |
|
|
79
|
+
| `Exec outcome.md created` | Exec done | outcome.md present; ready to complete |
|
|
80
|
+
| `Done delivered` / `Done abandoned` | terminal | do NOT cycle-resume; INDEX is authoritative |
|
|
81
|
+
|
|
82
|
+
2. **Verify the stage file.** Confirm only the last line's artifact exists and is non-empty
|
|
83
|
+
(ledger `How plan.md created` → `plan.md` must be present and non-empty). Do not re-probe
|
|
84
|
+
every lifecycle file.
|
|
71
85
|
3. **Drift handling.** If the ledger's last line disagrees with files-on-disk, rebuild the
|
|
72
|
-
ledger from filesystem state and note the correction
|
|
86
|
+
ledger from filesystem state and note the correction. A rebuilt ledger is the file-landing
|
|
87
|
+
skeleton (no `started`/`Done` lines), which still pins cycle position:
|
|
73
88
|
```bash
|
|
74
89
|
ruby -r ~/.plastic/scripts/lib/bridge -e 'Bridge.rebuild_savepoint("<intent_dir>")'
|
|
75
90
|
```
|
|
@@ -38,6 +38,9 @@ The agent handles:
|
|
|
38
38
|
- Cluster management (create, merge, rename)
|
|
39
39
|
- Orphan detection
|
|
40
40
|
|
|
41
|
-
When an intent reaches a terminal state — moved to Completed OR Abandoned —
|
|
41
|
+
When an intent reaches a terminal state — moved to Completed OR Abandoned — do two things as the closing act of the transfer:
|
|
42
|
+
|
|
43
|
+
1. Stamp the terminal savepoint bookend (intent 81), so the ledger's last line records the disposition: `ruby -r ~/.plastic/scripts/lib/bridge -e 'Bridge.append_terminal_savepoint("<intent_dir>", "delivered")'` (use `"abandoned"` for an abandoned intent). Idempotent.
|
|
44
|
+
2. Refresh the QMD index for the affected store (no-op when QMD absent), running in the background so it never blocks: `ruby ~/.plastic/scripts/qmd-sync reindex --store <store-root> --async`.
|
|
42
45
|
|
|
43
46
|
After the agent completes, report what changed.
|
|
@@ -41,6 +41,8 @@ Topic-based groupings. Manually curated. Create a new cluster when 3+ intents sh
|
|
|
41
41
|
### Completed
|
|
42
42
|
All completed intents with dates. Links preserved, never deleted.
|
|
43
43
|
|
|
44
|
+
When you move an intent INTO Completed or Abandoned, stamp the terminal savepoint bookend as the closing act of the transfer (intent 81), so the ledger's last line records the disposition: `ruby -r ~/.plastic/scripts/lib/bridge -e 'Bridge.append_terminal_savepoint("<intent_dir>", "delivered")'` (use `"abandoned"` for an abandoned intent). Idempotent.
|
|
45
|
+
|
|
44
46
|
## Workflow
|
|
45
47
|
|
|
46
48
|
QMD-first (when available): when you need to locate a specific intent (to reclassify, flag, or
|
|
@@ -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
|