@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.
@@ -45,8 +45,18 @@ module Bridge
45
45
  (t.nil? || t.strip.empty?) ? "/tmp" : t
46
46
  end
47
47
 
48
- def self.path(session, tmp: tmp_dir)
49
- "#{tmp}/plastic-#{session}.json"
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
- # Resolve the active bridge. Exact-session lookup first; otherwise scan tmp:
127
- # for plastic-*.json, keep only valid bridges, prefer auto-armed, then prefer
128
- # the one whose intent.store matches cwd, tie-break by newest mtime.
129
- def self.discover_bridge(session:, cwd: Dir.pwd, tmp: tmp_dir)
130
- if !blank?(session) && File.exist?(path(session, tmp: tmp))
131
- exact = read(session, tmp: tmp)
132
- return exact if bridge_valid?(exact)
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
- pool = auto.empty? ? parsed : auto
206
+ auto_pool = auto.empty? ? parsed : auto
161
207
 
162
208
  unless blank?(cwd)
163
209
  cwd_abs = File.expand_path(cwd)
164
- matching = pool.select do |c|
165
- store = c[:data].dig("intent", "store").to_s
166
- next false if store.empty?
167
- store_abs = File.expand_path(store)
168
- cwd_abs == store_abs ||
169
- cwd_abs.start_with?("#{store_abs}/") ||
170
- store_abs.start_with?("#{cwd_abs}/")
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
- pool.max_by { |c| c[:mtime] }&.fetch(:data)
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
- current = path(session, tmp: tmp)
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 == current
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
- def self.read(session, tmp: tmp_dir)
260
- p = path(session, tmp: tmp)
261
- return nil unless File.exist?(p)
262
- JSON.parse(File.read(p))
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
- p = path(session, tmp: tmp)
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
- def self.disarm_auto(session)
718
- data = read(session)
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
- out << " "
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
- out << " "
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-z_][A-Za-z0-9_]*)\2?/)
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
- merge_claude_hooks(settings_path)
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
@@ -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" => [] }
@@ -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). See `plastic-dashboard` for the fill rules and entry flow.
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 board only reaches
65
- the user when it is in the chat reply, not in tool-call stdout. Never describe the board
66
- instead of showing it.
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 deep refinement branches; big otherwise.
107
- - **Value → high** when any of: explicit `value: high`; a human-authored **root** intent; an
108
- intent with a non-empty `chain`; or an intent that is a `source` of ≥1 other intent. Else low.
109
- - **Flags** — `unblocked` only when a **future** intent has **all** its `sources` done;
110
- `stale` only on future intents past the staleness threshold. Both kept low-noise by design.
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
 
@@ -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 — Plastic Health Check
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` — all checks passed
71
- - `1` — warnings found
72
- - `2` — failures found
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@latest --agent` |
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: show a single line — **"Health check: all clear."**
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.