@zalom/plastic 1.0.0-beta.33 → 1.0.0-beta.35
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 +8 -6
- package/agents/plastic-intent-discovery.md +7 -4
- package/bin/plastic.js +7 -3
- package/hooks/statusline +48 -6
- package/package.json +1 -1
- package/scripts/dashboard.rb +86 -21
- package/scripts/hook-continue +17 -0
- package/scripts/install.rb +42 -6
- package/scripts/lib/bridge.rb +134 -35
- package/scripts/lib/dashboard_banner.rb +42 -0
- package/scripts/lib/installer_core.rb +40 -6
- package/scripts/lib/preflight.rb +79 -0
- package/scripts/plastic-lock +2 -2
- package/skills/auto/SKILL.md +4 -2
- package/skills/continuing/SKILL.md +5 -1
- package/skills/dashboard/SKILL.md +25 -8
- package/skills/doctor/SKILL.md +6 -6
- package/skills/install/SKILL.md +75 -84
- package/skills/intent-discovery/SKILL.md +8 -7
- package/skills/intent-starting/SKILL.md +11 -8
- package/skills/releasing/SKILL.md +4 -1
- package/skills/uninstall/SKILL.md +29 -11
- package/skills/update/SKILL.md +34 -23
- package/skills/versions/SKILL.md +27 -12
package/scripts/lib/bridge.rb
CHANGED
|
@@ -45,8 +45,18 @@ module Bridge
|
|
|
45
45
|
(t.nil? || t.strip.empty?) ? "/tmp" : t
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
# Per-intent bridge key (intent 131): `plastic-<session>--<intent_id>.json`
|
|
49
|
+
# when intent_id is present, else the legacy single-key
|
|
50
|
+
# `plastic-<session>.json`. The per-intent key is what lets two concurrent
|
|
51
|
+
# deliveries under ONE session id keep separate bridge files instead of
|
|
52
|
+
# clobbering a shared one; the legacy form is still produced (and read) when
|
|
53
|
+
# no intent_id is given, so old single-key files stay valid.
|
|
54
|
+
def self.path(session, intent_id: nil, tmp: tmp_dir)
|
|
55
|
+
if blank?(intent_id)
|
|
56
|
+
"#{tmp}/plastic-#{session}.json"
|
|
57
|
+
else
|
|
58
|
+
"#{tmp}/plastic-#{session}--#{intent_id}.json"
|
|
59
|
+
end
|
|
50
60
|
end
|
|
51
61
|
|
|
52
62
|
# --- Session resolution (intent 52) ----------------------------------------
|
|
@@ -123,15 +133,46 @@ module Bridge
|
|
|
123
133
|
data.is_a?(Hash) && !blank?(data["session"]) && data["intent"].is_a?(Hash)
|
|
124
134
|
end
|
|
125
135
|
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
136
|
+
# Tiered cwd discriminator for one bridge candidate (intent 131). A session
|
|
137
|
+
# now owns SEVERAL bridges (one per concurrent intent), so the discriminator
|
|
138
|
+
# that used to be "cwd overlaps intent.store" is too coarse: every sibling
|
|
139
|
+
# under the same store shares it. worktree.code is the only field that
|
|
140
|
+
# differs between siblings, so it is the strongest signal; the intent dir is
|
|
141
|
+
# next; the shared store is a last-resort coarse tie.
|
|
142
|
+
# 2 - cwd is the intent's provisioned code worktree (or under it)
|
|
143
|
+
# 1 - cwd is the intent's own dir (or under it)
|
|
144
|
+
# 0 - cwd merely overlaps the intent's store (shared by every sibling)
|
|
145
|
+
# -1 - no signal at all
|
|
146
|
+
def self.bridge_cwd_tier(data, cwd_abs)
|
|
147
|
+
worktree_code = data.dig("worktree", "code")
|
|
148
|
+
if !blank?(worktree_code)
|
|
149
|
+
wc_abs = File.expand_path(worktree_code)
|
|
150
|
+
return 2 if cwd_abs == wc_abs || cwd_abs.start_with?("#{wc_abs}/")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
dir_abs = bridge_intent_dir(data)
|
|
154
|
+
if dir_abs
|
|
155
|
+
return 1 if cwd_abs == dir_abs || cwd_abs.start_with?("#{dir_abs}/")
|
|
133
156
|
end
|
|
134
157
|
|
|
158
|
+
store = data.dig("intent", "store").to_s
|
|
159
|
+
unless store.empty?
|
|
160
|
+
store_abs = File.expand_path(store)
|
|
161
|
+
return 0 if cwd_abs == store_abs || cwd_abs.start_with?("#{store_abs}/") ||
|
|
162
|
+
store_abs.start_with?("#{cwd_abs}/")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
-1
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Resolve the active bridge: scan tmp for plastic-*.json (both per-intent and
|
|
169
|
+
# legacy-keyed files), keep only valid bridges, filter to the caller's own
|
|
170
|
+
# session when it has one, prefer auto-armed, then disambiguate by cwd tier
|
|
171
|
+
# (see bridge_cwd_tier), tie-break by newest mtime. No exact-session fast
|
|
172
|
+
# path: a session now legitimately owns several bridges (one per concurrent
|
|
173
|
+
# intent), so filename lookup alone cannot pick the right one; cwd must
|
|
174
|
+
# decide (intent 131).
|
|
175
|
+
def self.discover_bridge(session:, cwd: Dir.pwd, tmp: tmp_dir)
|
|
135
176
|
candidates = Dir.glob(File.join(tmp, "plastic-*.json")).reject { |f| f.end_with?(".tmp") }
|
|
136
177
|
parsed = candidates.filter_map do |f|
|
|
137
178
|
data = (JSON.parse(File.read(f)) rescue nil)
|
|
@@ -156,26 +197,34 @@ module Bridge
|
|
|
156
197
|
return nil if parsed.empty?
|
|
157
198
|
end
|
|
158
199
|
|
|
200
|
+
# Auto-preference pool: a build-armed bridge is preferred over a merely
|
|
201
|
+
# derived one, but ONLY as a fallback when cwd cannot decide (below). cwd
|
|
202
|
+
# must win over auto-preference, so this pool is not applied before the
|
|
203
|
+
# cwd tiering (intent 131: a guided sibling in the caller's own worktree
|
|
204
|
+
# must beat an auto sibling in another worktree).
|
|
159
205
|
auto = parsed.select { |c| c[:data].dig("build", "auto") == true }
|
|
160
|
-
|
|
206
|
+
auto_pool = auto.empty? ? parsed : auto
|
|
161
207
|
|
|
162
208
|
unless blank?(cwd)
|
|
163
209
|
cwd_abs = File.expand_path(cwd)
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
210
|
+
# Tier the FULL session pool by cwd BEFORE the auto-preference filter.
|
|
211
|
+
# When cwd overlaps ANY candidate (tier >= 0) it decides outright, even
|
|
212
|
+
# against a newer or auto-armed sibling: worktree.code (tier 2) and the
|
|
213
|
+
# intent dir (tier 1) disambiguate same-store siblings (intent 131), and
|
|
214
|
+
# a store overlap (tier 0) still selects the overlapping bridge over an
|
|
215
|
+
# off-cwd one in another store (the intent 90/52 store filter, preserved).
|
|
216
|
+
# Only when NO candidate overlaps cwd (max tier -1) do we fall through to
|
|
217
|
+
# the auto-preference pool and newest mtime, so a lone armed bridge
|
|
218
|
+
# off-cwd still resolves (intent 52 headless).
|
|
219
|
+
tiered = parsed.map { |c| [bridge_cwd_tier(c[:data], cwd_abs), c] }
|
|
220
|
+
max_tier = tiered.map(&:first).max
|
|
221
|
+
if max_tier && max_tier >= 0
|
|
222
|
+
winners = tiered.select { |tier, _| tier == max_tier }.map { |_, c| c }
|
|
223
|
+
return winners.max_by { |c| c[:mtime] }&.fetch(:data)
|
|
171
224
|
end
|
|
172
|
-
# Hard cwd filter when the caller has a session (intent 90): a non-matching store
|
|
173
|
-
# excludes the candidate outright. Without a session, keep the best-effort revert
|
|
174
|
-
# (intent 52) so a lone armed bridge is still found when cwd does not overlap its store.
|
|
175
|
-
pool = has_session ? matching : (matching.empty? ? pool : matching)
|
|
176
225
|
end
|
|
177
226
|
|
|
178
|
-
|
|
227
|
+
auto_pool.max_by { |c| c[:mtime] }&.fetch(:data)
|
|
179
228
|
end
|
|
180
229
|
|
|
181
230
|
# --- Terminal-state bridge purge (intent 80) -------------------------------
|
|
@@ -221,10 +270,16 @@ module Bridge
|
|
|
221
270
|
# arm_auto and disarm_auto so both manual and auto delivery keep the temp dir
|
|
222
271
|
# clean at deterministic work boundaries.
|
|
223
272
|
def self.purge_done_bridges(session:, tmp: tmp_dir)
|
|
224
|
-
|
|
273
|
+
# Own-bridge predicate (intent 131): a session now legitimately owns
|
|
274
|
+
# SEVERAL bridges (one per concurrent intent), so "current" is no longer
|
|
275
|
+
# one filename. Skip the legacy single-key file for this session AND every
|
|
276
|
+
# per-intent-keyed file for this session; none of the session's own live
|
|
277
|
+
# bridges may be reaped mid-run.
|
|
278
|
+
own_legacy_name = File.basename(path(session, tmp: tmp))
|
|
279
|
+
own_prefix = "plastic-#{session}--"
|
|
225
280
|
removed = []
|
|
226
281
|
Dir.glob(File.join(tmp, "plastic-*.json")).each do |f|
|
|
227
|
-
next if f ==
|
|
282
|
+
next if File.basename(f) == own_legacy_name || File.basename(f).start_with?(own_prefix)
|
|
228
283
|
begin
|
|
229
284
|
data = JSON.parse(File.read(f)) rescue nil
|
|
230
285
|
keep = false
|
|
@@ -256,17 +311,35 @@ module Bridge
|
|
|
256
311
|
removed || []
|
|
257
312
|
end
|
|
258
313
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
314
|
+
# Try the per-intent path first; when it is absent and an intent_id was
|
|
315
|
+
# given, fall back to the legacy single-key path (migration + legacy
|
|
316
|
+
# tolerance, intent 131): a live `plastic-<session>.json` from before this
|
|
317
|
+
# intent keeps resolving during the transition. The legacy fallback is
|
|
318
|
+
# honored for a specific intent_id ONLY when the legacy file actually carries
|
|
319
|
+
# that intent (or carries none), so a caller asking for intent A never acts
|
|
320
|
+
# on a legacy file that still holds sibling B.
|
|
321
|
+
def self.read(session, intent_id: nil, tmp: tmp_dir)
|
|
322
|
+
p = path(session, intent_id: intent_id, tmp: tmp)
|
|
323
|
+
return JSON.parse(File.read(p)) if File.exist?(p)
|
|
324
|
+
return nil if blank?(intent_id)
|
|
325
|
+
legacy = path(session, tmp: tmp)
|
|
326
|
+
return nil unless File.exist?(legacy)
|
|
327
|
+
data = JSON.parse(File.read(legacy))
|
|
328
|
+
id = data.is_a?(Hash) ? data.dig("intent", "id") : nil
|
|
329
|
+
(blank?(id) || id.to_s == intent_id.to_s) ? data : nil
|
|
263
330
|
rescue JSON::ParserError
|
|
264
331
|
nil
|
|
265
332
|
end
|
|
266
333
|
|
|
334
|
+
# Self-keying (intent 131): the file `write` targets is derived from
|
|
335
|
+
# `data.dig("intent", "id")`, not a caller-supplied intent_id, so every
|
|
336
|
+
# existing `write(session, data)` call site keys itself correctly for free
|
|
337
|
+
# as long as `data["intent"]["id"]` is set (arm/derive/disarm_auto/
|
|
338
|
+
# repair_lock/hook-gate-check/plastic-lock all carry it).
|
|
267
339
|
def self.write(session, data, tmp: tmp_dir)
|
|
268
340
|
raise ArgumentError, "bridge session must be present" if blank?(session)
|
|
269
|
-
|
|
341
|
+
intent_id = data.is_a?(Hash) ? data.dig("intent", "id") : nil
|
|
342
|
+
p = path(session, intent_id: intent_id, tmp: tmp)
|
|
270
343
|
# Atomic write: tmp file + rename to prevent partial reads
|
|
271
344
|
tmp_file = "#{p}.tmp.#{Process.pid}"
|
|
272
345
|
File.write(tmp_file, JSON.pretty_generate(data.merge("updated_at" => Time.now.utc.iso8601)))
|
|
@@ -709,13 +782,31 @@ module Bridge
|
|
|
709
782
|
arm(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name, auto: false)
|
|
710
783
|
end
|
|
711
784
|
|
|
785
|
+
# Degrade path for disarm_auto when no intent_id is given (intent 131): the
|
|
786
|
+
# session's sole per-intent bridge when there is exactly one, else the
|
|
787
|
+
# legacy single-key file. Keeps the common single-intent auto path working
|
|
788
|
+
# without every caller having to name the intent id explicitly.
|
|
789
|
+
def self.sole_bridge_data(session, tmp: tmp_dir)
|
|
790
|
+
matches = Dir.glob(File.join(tmp, "plastic-#{session}--*.json")).reject { |f| f.end_with?(".tmp") }
|
|
791
|
+
if matches.length == 1
|
|
792
|
+
data = (JSON.parse(File.read(matches.first)) rescue nil)
|
|
793
|
+
return data if data
|
|
794
|
+
end
|
|
795
|
+
read(session, tmp: tmp)
|
|
796
|
+
end
|
|
797
|
+
|
|
712
798
|
# Disarm. No-op if no bridge exists for the session. End-tail order (D6):
|
|
713
799
|
# worktrees are merged/removed FIRST (the verify step is the caller's,
|
|
714
800
|
# before disarm), then the delivery lock is cleared, and only then does the
|
|
715
801
|
# bridge become purge-eligible. purge_done_bridges enforces the same order
|
|
716
802
|
# defensively by skipping any bridge whose intent still holds a lock.
|
|
717
|
-
|
|
718
|
-
|
|
803
|
+
#
|
|
804
|
+
# Now takes intent_id (intent 131): a session can own SEVERAL live bridges
|
|
805
|
+
# (one per concurrent intent), so disarm must target ONE of them. When
|
|
806
|
+
# intent_id is nil, degrades to the session's sole bridge (see
|
|
807
|
+
# sole_bridge_data) so the common single-intent path keeps working.
|
|
808
|
+
def self.disarm_auto(session, intent_id: nil)
|
|
809
|
+
data = blank?(intent_id) ? sole_bridge_data(session) : read(session, intent_id: intent_id)
|
|
719
810
|
return nil unless data
|
|
720
811
|
data["build"] ||= {}
|
|
721
812
|
data["build"]["auto"] = false
|
|
@@ -783,7 +874,7 @@ module Bridge
|
|
|
783
874
|
actions << "lock #{status}"
|
|
784
875
|
end
|
|
785
876
|
|
|
786
|
-
previous = read(key, tmp: tmp)
|
|
877
|
+
previous = read(key, intent_id: intent_id, tmp: tmp)
|
|
787
878
|
auto = !!(previous && previous.dig("build", "auto"))
|
|
788
879
|
data = derive(key, intent_id: intent_id, intent_dir: dir, store: store,
|
|
789
880
|
name: name, tmp: tmp)
|
|
@@ -1082,7 +1173,11 @@ module Bridge
|
|
|
1082
1173
|
c = line[i]
|
|
1083
1174
|
case state
|
|
1084
1175
|
when :single
|
|
1085
|
-
|
|
1176
|
+
if c == "'" || c == "<" || c == ">"
|
|
1177
|
+
out << " "
|
|
1178
|
+
else
|
|
1179
|
+
out << c
|
|
1180
|
+
end
|
|
1086
1181
|
state = :normal if c == "'"
|
|
1087
1182
|
i += 1
|
|
1088
1183
|
when :double
|
|
@@ -1090,7 +1185,11 @@ module Bridge
|
|
|
1090
1185
|
out << " "
|
|
1091
1186
|
i += 2
|
|
1092
1187
|
else
|
|
1093
|
-
|
|
1188
|
+
if c == '"' || c == "<" || c == ">"
|
|
1189
|
+
out << " "
|
|
1190
|
+
else
|
|
1191
|
+
out << c
|
|
1192
|
+
end
|
|
1094
1193
|
state = :normal if c == '"'
|
|
1095
1194
|
i += 1
|
|
1096
1195
|
end
|
|
@@ -1100,7 +1199,7 @@ module Bridge
|
|
|
1100
1199
|
elsif c == '"'
|
|
1101
1200
|
out << " "; state = :double; i += 1
|
|
1102
1201
|
elsif c == "<" && line[i + 1] == "<"
|
|
1103
|
-
m = line[i..].match(/\A<<(-?)\s*("|')?([A-Za-
|
|
1202
|
+
m = line[i..].match(/\A<<(-?)\s*("|')?([A-Za-z0-9_][A-Za-z0-9_]*)\2?/)
|
|
1104
1203
|
if m
|
|
1105
1204
|
openers << { word: m[3], dash: m[1] == "-" }
|
|
1106
1205
|
out << (" " * m[0].length)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Renders a one-line dashboard summary for the hook-owned systemMessage floor
|
|
5
|
+
# (intent 125, Task 6). Pure and dependency-injected, mirroring BootBanner: it
|
|
6
|
+
# takes the already-parsed `dashboard.rb continue --data` JSON payload and
|
|
7
|
+
# returns a single line, with no file I/O and no subprocess calls in here, so
|
|
8
|
+
# it is unit-testable in isolation while the hook feeds it real data.
|
|
9
|
+
module DashboardBanner
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# payload: the Hash from JSON.parse(`dashboard.rb continue --data`), or nil
|
|
13
|
+
# when the subprocess call failed or produced unusable JSON.
|
|
14
|
+
#
|
|
15
|
+
# Returns a single summary line, or nil when the payload has nothing usable
|
|
16
|
+
# (the caller degrades silently in that case, omitting systemMessage).
|
|
17
|
+
def render(payload)
|
|
18
|
+
return nil unless payload.is_a?(Hash)
|
|
19
|
+
counts = payload["counts"]
|
|
20
|
+
return nil unless counts.is_a?(Hash)
|
|
21
|
+
active = counts["active"].to_i
|
|
22
|
+
future = counts["future"].to_i
|
|
23
|
+
line = "Plastic: #{active} active · #{future} next · run /plastic-dashboard to see the board"
|
|
24
|
+
nbt = next_big_thing_id(payload)
|
|
25
|
+
line += " · next big thing: #{nbt}" if nbt
|
|
26
|
+
line
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The id of the top-ranked next_big candidate, when the payload's matrix carries
|
|
30
|
+
# exactly the shape dashboard.rb emits (a "next_big" list of {id, ...} hashes,
|
|
31
|
+
# already rank-sorted). Returns nil for any other shape rather than raising.
|
|
32
|
+
def next_big_thing_id(payload)
|
|
33
|
+
matrix = payload["matrix"]
|
|
34
|
+
return nil unless matrix.is_a?(Hash)
|
|
35
|
+
list = matrix["next_big"]
|
|
36
|
+
return nil unless list.is_a?(Array) && !list.empty?
|
|
37
|
+
top = list.first
|
|
38
|
+
return nil unless top.is_a?(Hash)
|
|
39
|
+
id = top["id"].to_s
|
|
40
|
+
id.empty? ? nil : id
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -161,6 +161,37 @@ class InstallerCore
|
|
|
161
161
|
nums.select { |n| n >= 1 && n <= agents.size }.map { |n| agents[n - 1][:key] }
|
|
162
162
|
end
|
|
163
163
|
|
|
164
|
+
# Resolve whether install should keep the user's existing statusline or switch it
|
|
165
|
+
# to Plastic's. Pure function of (settings file, argv, input, reinstall): no writes,
|
|
166
|
+
# so it stays fully unit-testable apart from merge_claude_hooks.
|
|
167
|
+
def statusline_choice(settings_path, argv: [], input: $stdin, reinstall: false)
|
|
168
|
+
existing_command = read_json_safe(settings_path)&.dig("statusLine", "command").to_s
|
|
169
|
+
return :plastic if existing_command.empty?
|
|
170
|
+
return :plastic if existing_command.include?("plastic-")
|
|
171
|
+
|
|
172
|
+
idx = argv.index("--statusline")
|
|
173
|
+
flag = idx && argv[idx + 1]
|
|
174
|
+
return flag.to_sym if %w[keep plastic].include?(flag)
|
|
175
|
+
|
|
176
|
+
return :keep if reinstall
|
|
177
|
+
return prompt_statusline(input: input) if input.tty?
|
|
178
|
+
|
|
179
|
+
:keep
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def prompt_statusline(input: $stdin)
|
|
183
|
+
return :keep unless input.tty?
|
|
184
|
+
|
|
185
|
+
puts "An existing statusline was found in your settings.\n\n"
|
|
186
|
+
puts " 1. Keep my statusline (Plastic will not change it)"
|
|
187
|
+
puts " 2. Switch to Plastic's statusline"
|
|
188
|
+
puts
|
|
189
|
+
print "Select (1 or 2, Enter to keep): "
|
|
190
|
+
answer = input.gets&.strip
|
|
191
|
+
|
|
192
|
+
answer == "2" ? :plastic : :keep
|
|
193
|
+
end
|
|
194
|
+
|
|
164
195
|
# --- Distribution phase ---
|
|
165
196
|
|
|
166
197
|
def distribute(mode)
|
|
@@ -224,6 +255,7 @@ class InstallerCore
|
|
|
224
255
|
"scripts/lib/insights.rb" => "scripts/lib/insights.rb",
|
|
225
256
|
"scripts/lib/worktree.rb" => "scripts/lib/worktree.rb",
|
|
226
257
|
"scripts/lib/boot_banner.rb" => "scripts/lib/boot_banner.rb",
|
|
258
|
+
"scripts/lib/dashboard_banner.rb" => "scripts/lib/dashboard_banner.rb",
|
|
227
259
|
"scripts/lib/qmd_sync.rb" => "scripts/lib/qmd_sync.rb",
|
|
228
260
|
"scripts/qmd-sync" => "scripts/qmd-sync",
|
|
229
261
|
"scripts/lib/intent_validator.rb" => "scripts/lib/intent_validator.rb",
|
|
@@ -248,6 +280,7 @@ class InstallerCore
|
|
|
248
280
|
"scripts/lib/store_provisioning.rb" => "scripts/lib/store_provisioning.rb",
|
|
249
281
|
"scripts/provision-project-store" => "scripts/provision-project-store",
|
|
250
282
|
"scripts/lib/installer_core.rb" => "scripts/lib/installer_core.rb",
|
|
283
|
+
"scripts/lib/preflight.rb" => "scripts/lib/preflight.rb",
|
|
251
284
|
"scripts/install.rb" => "scripts/install.rb",
|
|
252
285
|
"scripts/update.rb" => "scripts/update.rb",
|
|
253
286
|
"scripts/uninstall.rb" => "scripts/uninstall.rb",
|
|
@@ -320,7 +353,7 @@ class InstallerCore
|
|
|
320
353
|
(data["files"] || {}).keys
|
|
321
354
|
end
|
|
322
355
|
|
|
323
|
-
def install_for_agent(key, force)
|
|
356
|
+
def install_for_agent(key, force, argv: [], input: $stdin, reinstall: false)
|
|
324
357
|
config = agent_config(key)
|
|
325
358
|
return { agent: config[:name], success: false, reason: "Unknown agent" } unless config
|
|
326
359
|
|
|
@@ -333,7 +366,7 @@ class InstallerCore
|
|
|
333
366
|
old_files = manifest_files(manifest_path_for(key, config))
|
|
334
367
|
|
|
335
368
|
result = case key
|
|
336
|
-
when "claude" then install_claude(config, force)
|
|
369
|
+
when "claude" then install_claude(config, force, argv: argv, input: input, reinstall: reinstall)
|
|
337
370
|
when "codex" then install_codex(config, force)
|
|
338
371
|
when "hermes" then install_hermes(config, force)
|
|
339
372
|
end
|
|
@@ -362,7 +395,7 @@ class InstallerCore
|
|
|
362
395
|
removed
|
|
363
396
|
end
|
|
364
397
|
|
|
365
|
-
def install_claude(config, force)
|
|
398
|
+
def install_claude(config, force, argv: [], input: $stdin, reinstall: false)
|
|
366
399
|
hooks_dir = File.join(config[:dir], "hooks")
|
|
367
400
|
skills_root = File.join(config[:dir], "skills")
|
|
368
401
|
plastic_dir = File.join(config[:dir], "plastic")
|
|
@@ -405,7 +438,8 @@ class InstallerCore
|
|
|
405
438
|
|
|
406
439
|
# Merge hooks + statusline into settings.json (no plugin registration)
|
|
407
440
|
settings_path = File.join(config[:dir], "settings.json")
|
|
408
|
-
|
|
441
|
+
choice = statusline_choice(settings_path, argv: argv, input: input, reinstall: reinstall)
|
|
442
|
+
merge_claude_hooks(settings_path, choice: choice)
|
|
409
443
|
|
|
410
444
|
# Write manifest
|
|
411
445
|
manifest_path = File.join(plastic_dir, "manifest.json")
|
|
@@ -571,7 +605,7 @@ class InstallerCore
|
|
|
571
605
|
|
|
572
606
|
# --- settings.json merge (read-modify-write, never clobber) ---
|
|
573
607
|
|
|
574
|
-
def merge_claude_hooks(settings_path)
|
|
608
|
+
def merge_claude_hooks(settings_path, choice: :plastic)
|
|
575
609
|
settings = read_json_safe(settings_path) || {}
|
|
576
610
|
return if settings.nil?
|
|
577
611
|
|
|
@@ -613,7 +647,7 @@ class InstallerCore
|
|
|
613
647
|
File.write(File.join(cache_dir, "original-statusline.json"), JSON.pretty_generate(existing_status))
|
|
614
648
|
end
|
|
615
649
|
|
|
616
|
-
settings["statusLine"] = { "type" => "command", "command" => "#{hook_dir}/plastic-statusline" }
|
|
650
|
+
settings["statusLine"] = { "type" => "command", "command" => "#{hook_dir}/plastic-statusline" } if choice == :plastic
|
|
617
651
|
|
|
618
652
|
# No plugin/marketplace registration: skills are flat personal skills
|
|
619
653
|
# (plastic-<name>/) discovered directly from ~/.claude/skills.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "rubygems"
|
|
5
|
+
|
|
6
|
+
# Pure, dependency-injected pre-flight checks for Plastic's runtime dependencies
|
|
7
|
+
# (intent 38). Takes injected probes (ruby version, node version, git presence,
|
|
8
|
+
# mise presence) and returns a plain decision: ok / fatal plus branded messages.
|
|
9
|
+
#
|
|
10
|
+
# No I/O, no shelling out, no ENV reads here. Callers (scripts/install.rb,
|
|
11
|
+
# bin/plastic.js) own the impure probing and the printing, so this module stays
|
|
12
|
+
# hermetically testable. Voice matches boot_banner.rb (understated, "Plastic ..."
|
|
13
|
+
# prefix); no em-dash, no en-dash in any message.
|
|
14
|
+
module Preflight
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
RUBY_FLOOR = "3.0.0"
|
|
18
|
+
NODE_FLOOR = 18
|
|
19
|
+
RUBY_PIN = "3.3"
|
|
20
|
+
|
|
21
|
+
def check(ruby_version:, node_version:, git_present:, mise_present:)
|
|
22
|
+
messages = []
|
|
23
|
+
|
|
24
|
+
ruby_message = ruby_issue(ruby_version, mise_present)
|
|
25
|
+
fatal = !ruby_message.nil?
|
|
26
|
+
messages << ruby_message if ruby_message
|
|
27
|
+
|
|
28
|
+
node_message = node_issue(node_version)
|
|
29
|
+
messages << node_message if node_message
|
|
30
|
+
|
|
31
|
+
git_message = git_issue(git_present)
|
|
32
|
+
messages << git_message if git_message
|
|
33
|
+
|
|
34
|
+
{ ok: messages.empty?, fatal: fatal, messages: messages }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def ruby_issue(ruby_version, mise_present)
|
|
38
|
+
parsed = safe_version(ruby_version)
|
|
39
|
+
return nil if parsed && parsed >= safe_version(RUBY_FLOOR)
|
|
40
|
+
|
|
41
|
+
lines = []
|
|
42
|
+
lines << "Plastic needs Ruby #{RUBY_FLOOR} or newer to run its scripts (found #{found(ruby_version)})."
|
|
43
|
+
lines << "Install a pinned Ruby with mise:"
|
|
44
|
+
lines << " curl https://mise.run | sh # only if mise is not installed yet" unless mise_present
|
|
45
|
+
lines << " mise use --global ruby@#{RUBY_PIN}"
|
|
46
|
+
lines << "Then re-run the Plastic installer."
|
|
47
|
+
lines.join("\n")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def node_issue(node_version)
|
|
51
|
+
parsed = safe_version(strip_leading_v(node_version))
|
|
52
|
+
return nil if parsed && parsed >= safe_version(NODE_FLOOR.to_s)
|
|
53
|
+
|
|
54
|
+
"Plastic works best on Node #{NODE_FLOOR} or newer (found #{found(node_version)}). " \
|
|
55
|
+
"Pin it with mise: mise use --global node@25"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def git_issue(git_present)
|
|
59
|
+
return nil if git_present
|
|
60
|
+
|
|
61
|
+
"Plastic uses git for its store and worktrees (git was not found). " \
|
|
62
|
+
"Install git, e.g. macOS: xcode-select --install"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def safe_version(str)
|
|
66
|
+
Gem::Version.new(str.to_s)
|
|
67
|
+
rescue ArgumentError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def strip_leading_v(str)
|
|
72
|
+
str.to_s.strip.sub(/\Av/, "")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def found(value)
|
|
76
|
+
text = value.to_s.strip
|
|
77
|
+
text.empty? ? "not found" : text
|
|
78
|
+
end
|
|
79
|
+
end
|
package/scripts/plastic-lock
CHANGED
|
@@ -72,7 +72,7 @@ key = Bridge.resolve_session(session, intent_id: intent_id, store: store)
|
|
|
72
72
|
case verb
|
|
73
73
|
when "status"
|
|
74
74
|
lock = Lock.read(dir)
|
|
75
|
-
bridge = Bridge.read(key)
|
|
75
|
+
bridge = Bridge.read(key, intent_id: intent_id)
|
|
76
76
|
report = {
|
|
77
77
|
"intent_dir" => dir,
|
|
78
78
|
"session" => key,
|
|
@@ -100,7 +100,7 @@ when "release"
|
|
|
100
100
|
warn "plastic-lock: not the owner; run plastic-lock status"
|
|
101
101
|
exit 1
|
|
102
102
|
end
|
|
103
|
-
data = Bridge.read(key)
|
|
103
|
+
data = Bridge.read(key, intent_id: intent_id)
|
|
104
104
|
if data
|
|
105
105
|
data["lock"] = { "owner_session" => nil, "acquired_at" => nil,
|
|
106
106
|
"host" => nil, "type" => nil, "delegates" => [] }
|
package/skills/auto/SKILL.md
CHANGED
|
@@ -275,9 +275,11 @@ During initial project creation, all decisions are non-destructive by definition
|
|
|
275
275
|
```
|
|
276
276
|
(Use `"abandoned"` instead when the intent is being moved to `## Abandoned`.) Idempotent.
|
|
277
277
|
7. Auto-commit: `cd <store-root> && git add . && git commit -m "feat: deliver intent <ID> — <name>"`
|
|
278
|
-
8. Disarm the lifecycle gate (auto delivery is finished)
|
|
278
|
+
8. Disarm the lifecycle gate (auto delivery is finished). Substitute the intent's own id for
|
|
279
|
+
`<ID>` (a session can be delivering more than one intent at once, intent 131, so disarm must
|
|
280
|
+
name which of the session's bridges to clear):
|
|
279
281
|
```bash
|
|
280
|
-
ruby -r ~/.plastic/scripts/lib/bridge -e 'Bridge.disarm_auto(ENV["CLAUDE_CODE_SESSION_ID"])'
|
|
282
|
+
ruby -r ~/.plastic/scripts/lib/bridge -e 'Bridge.disarm_auto(ENV["CLAUDE_CODE_SESSION_ID"], intent_id: "<ID>")'
|
|
281
283
|
```
|
|
282
284
|
Disarm runs the ordered End tail: it releases the worktrees first, then clears the
|
|
283
285
|
intent's `delivery.lock` (and the bridge's lock cache), and only then is the bridge
|
|
@@ -38,7 +38,11 @@ here — run the data payload and fill + present the matching template:
|
|
|
38
38
|
- Otherwise → `ruby ~/.plastic/scripts/dashboard.rb continue --data`
|
|
39
39
|
|
|
40
40
|
Fill the matching template from this skill's `templates/` and **present the filled Markdown
|
|
41
|
-
in your reply** (every time).
|
|
41
|
+
in your reply** (every time, non-optional). If the reply does not contain the filled Markdown,
|
|
42
|
+
the user sees nothing — tool-call stdout and hook `additionalContext` are both invisible to
|
|
43
|
+
them. `hook-continue` also emits a one-line `systemMessage` summary as a hook-owned fallback;
|
|
44
|
+
treat it as a floor only, never as a substitute for presenting the full board here. See
|
|
45
|
+
`plastic-dashboard` for the fill rules and entry flow.
|
|
42
46
|
|
|
43
47
|
The board load runs the scoped store check on every load (`doctor --store <scope>`): the
|
|
44
48
|
global board runs `--store global` and a project board runs `--store <slug>`. The result
|
|
@@ -61,9 +61,17 @@ Fill mechanically — no rewriting, no re-sorting:
|
|
|
61
61
|
|
|
62
62
|
### Step 3 — Present it (mandatory, every invocation)
|
|
63
63
|
|
|
64
|
-
**Paste the filled Markdown into your reply.** This is non-optional: the
|
|
65
|
-
the
|
|
66
|
-
instead of showing
|
|
64
|
+
**Paste the filled Markdown into your reply.** This is non-optional: if the reply does not
|
|
65
|
+
contain the filled Markdown, the user sees nothing — tool-call stdout and hook
|
|
66
|
+
`additionalContext` are both invisible to them. Never describe the board instead of showing
|
|
67
|
+
it, and never assume a hook already showed it for you.
|
|
68
|
+
|
|
69
|
+
`hook-continue` also emits a one-line `systemMessage` summary (counts, and the next big thing
|
|
70
|
+
when there is one) as a hook-owned fallback, independent of the agent's reply. Treat that line
|
|
71
|
+
as a floor only, not a substitute for this step: it carries no matrix, no recently-worked
|
|
72
|
+
section, and no entry-flow prompt. Presenting the full board here remains mandatory regardless
|
|
73
|
+
of whether the summary line fired. This stays a soft, agent-followed mechanism — there is no
|
|
74
|
+
stronger enforcement for a full multi-section Markdown document in this harness today.
|
|
67
75
|
|
|
68
76
|
### Step 4 — Entry flow (the board is the menu)
|
|
69
77
|
|
|
@@ -103,13 +111,22 @@ a raw terminal. The Markdown board (`--data` + template) is the surface for the
|
|
|
103
111
|
## How classification works (deterministic)
|
|
104
112
|
|
|
105
113
|
- **Effort** — small for `research`/`exploration`/`bugfix`, for already-scoped intents
|
|
106
|
-
(plan/checklist exists), or
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
`
|
|
114
|
+
(plan/checklist exists), or a **branch id** (folgezettel depth ≥ 2, e.g. `4a`, `12b3`); big
|
|
115
|
+
otherwise. A root id (a bare number) is always depth 1, so it is never demoted by this rule.
|
|
116
|
+
- **Value → high** when any of: explicit `value: high`; a human-authored **root** intent; or
|
|
117
|
+
an intent that is a `source` of ≥1 other intent (it has spawned follow-on work). A purely
|
|
118
|
+
relational `chain` entry alone is **not** a value signal (intent 68) — else low.
|
|
119
|
+
- **Flags** — `unblocked` only when a **future** intent has **all** its `sources` done AND at
|
|
120
|
+
least one source's completion date is strictly later than the intent's own `created` date (a
|
|
121
|
+
genuine wait, not a birth-time default); `in-progress` only when the savepoint ledger shows
|
|
122
|
+
real post-birth activity, not just the creation stamp; `stale` only on future intents past
|
|
123
|
+
the staleness threshold. All three kept low-noise by design.
|
|
111
124
|
- **Override** — a `value: high|low` frontmatter field always wins (pre-stamped data, never
|
|
112
125
|
model judgment at render time).
|
|
126
|
+
- **Caps** — quadrant lists and the project board's `active`/`future` lists are capped at 8
|
|
127
|
+
entries plus a trailing "+N more" line; each entry's text is truncated to 120 characters
|
|
128
|
+
with a trailing ellipsis. Applies to the Markdown board only (the ASCII renderer has its own
|
|
129
|
+
separate `CELL_CAP`).
|
|
113
130
|
|
|
114
131
|
## Eval
|
|
115
132
|
|
package/skills/doctor/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: plastic-doctor
|
|
|
3
3
|
description: Use when diagnosing Plastic installation health, after updates, or when something seems broken. Runs checks and reports findings with fix options.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Doctor
|
|
6
|
+
# Doctor: Plastic Health Check
|
|
7
7
|
|
|
8
8
|
## Scopes
|
|
9
9
|
|
|
@@ -67,9 +67,9 @@ Parse the JSON output from stdout. The script is read-only and never modifies
|
|
|
67
67
|
files. Errors go to stderr.
|
|
68
68
|
|
|
69
69
|
Exit codes indicate check results, not script failure:
|
|
70
|
-
- `0
|
|
71
|
-
- `1
|
|
72
|
-
- `2
|
|
70
|
+
- `0`: all checks passed
|
|
71
|
+
- `1`: warnings found
|
|
72
|
+
- `2`: failures found
|
|
73
73
|
|
|
74
74
|
All three exit codes mean the script ran successfully. Do not treat non-zero
|
|
75
75
|
as an error.
|
|
@@ -118,7 +118,7 @@ Use the `fix_hint` value to determine the correct action:
|
|
|
118
118
|
| "Remove stale references from INDEX.md" | Edit INDEX.md to remove ghost references |
|
|
119
119
|
| "Inject the missing required frontmatter field(s)" | Edit the intent's `{ID}--{slug}.md` frontmatter to add the missing key (e.g. `chain: []`) without touching other keys |
|
|
120
120
|
| "Run: provision-project-store {slug}" | Run `provision-project-store <slug>` (or invoke the `plastic-add-project-store` skill) to create the missing store |
|
|
121
|
-
| "Re-run installer" | Run `npx @zalom/plastic
|
|
121
|
+
| "Re-run installer" | Run `npx -y @zalom/plastic@<channel> install --agent <agent>` (channel: -alpha->@alpha, -beta->@beta, else @latest) |
|
|
122
122
|
| "Dispatch plastic-intent-curator ... revisions.md ..." | Invoke the `plastic-intent-curator` (or the agent) to relocate the flagged section or ref into the intent's `revisions.md` via move-and-record (one dated, `[rule: <tag>]`-tagged entry per item), per PLASTIC.md > Structural maintenance and revisions.md. For a missing required section, restore or reproject it instead. |
|
|
123
123
|
|
|
124
124
|
For fixes the agent cannot handle automatically, explain what the user needs
|
|
@@ -144,7 +144,7 @@ Show the updated results.
|
|
|
144
144
|
When invoked from `plastic-update` (not directly by the user):
|
|
145
145
|
|
|
146
146
|
1. Run the diagnostic script as in Step 1.
|
|
147
|
-
2. If all checks pass
|
|
147
|
+
2. If all checks pass, show a single line: **"Health check: all clear."**
|
|
148
148
|
3. If issues are found: show the full report (Steps 3-6).
|
|
149
149
|
|
|
150
150
|
This keeps the update flow clean when nothing is wrong.
|