@zalom/plastic 1.0.0-beta.34 → 1.0.0-beta.36

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.
Files changed (38) hide show
  1. package/PLASTIC-reference.md +133 -0
  2. package/PLASTIC.md +29 -151
  3. package/agents/plastic-brainstorming.md +2 -6
  4. package/agents/plastic-enforcer.md +11 -6
  5. package/agents/plastic-executor.md +2 -6
  6. package/agents/plastic-future-intent-researcher.md +2 -7
  7. package/agents/plastic-intent-curator.md +2 -10
  8. package/agents/plastic-intent-discovery.md +8 -10
  9. package/agents/plastic-planner.md +2 -6
  10. package/agents/plastic-spec-specialist.md +2 -6
  11. package/bin/plastic.js +7 -3
  12. package/package.json +2 -1
  13. package/scripts/install.rb +42 -6
  14. package/scripts/lib/bridge.rb +84 -5
  15. package/scripts/lib/installer_core.rb +40 -6
  16. package/scripts/lib/power_tools.rb +18 -16
  17. package/scripts/lib/preflight.rb +79 -0
  18. package/skills/auto/SKILL.md +40 -38
  19. package/skills/auto/references/end-tail.md +56 -0
  20. package/skills/auto/references/human-report-contract.md +55 -0
  21. package/skills/brainstorming/SKILL.md +7 -34
  22. package/skills/brainstorming/references/design-principles.md +49 -0
  23. package/skills/creating-intent/SKILL.md +5 -26
  24. package/skills/creating-project/SKILL.md +11 -74
  25. package/skills/creating-project/references/project-scaffolding.md +97 -0
  26. package/skills/dashboard/SKILL.md +2 -17
  27. package/skills/dashboard/references/classification.md +22 -0
  28. package/skills/doctor/SKILL.md +6 -6
  29. package/skills/install/SKILL.md +75 -84
  30. package/skills/intent-discovery/SKILL.md +8 -7
  31. package/skills/intent-starting/SKILL.md +11 -8
  32. package/skills/releasing/SKILL.md +14 -46
  33. package/skills/releasing/references/promotion-and-tagging.md +60 -0
  34. package/skills/uninstall/SKILL.md +29 -11
  35. package/skills/update/SKILL.md +34 -23
  36. package/skills/versions/SKILL.md +27 -12
  37. package/skills/writing-plans/SKILL.md +10 -88
  38. package/skills/writing-plans/references/plan-format.md +102 -0
@@ -14,6 +14,7 @@
14
14
  # so install.rb is the single file-syncer; the ledger action is contextual.
15
15
 
16
16
  require_relative "lib/installer_core"
17
+ require_relative "lib/preflight"
17
18
 
18
19
  class Install < InstallerCore
19
20
  def cli(argv = ARGV)
@@ -22,6 +23,9 @@ class Install < InstallerCore
22
23
  return 0
23
24
  end
24
25
 
26
+ gate = preflight_gate
27
+ return gate unless gate.zero?
28
+
25
29
  force = argv.include?("--force")
26
30
  reinstall = argv.include?("--reinstall")
27
31
  ledger_action = flag_value(argv, "--ledger-action")
@@ -42,19 +46,19 @@ class Install < InstallerCore
42
46
  return 1
43
47
  end
44
48
 
45
- run(selected: selected, force: force, reinstall: reinstall, ledger_action: ledger_action)
49
+ run(selected: selected, force: force, reinstall: reinstall, ledger_action: ledger_action, argv: argv)
46
50
  0
47
51
  end
48
52
 
49
53
  # Hermetic entrypoint (no prompting / no exit). Returns the per-agent results array.
50
- def run(selected:, force: false, reinstall: false, ledger_action: nil)
54
+ def run(selected:, force: false, reinstall: false, ledger_action: nil, argv: ARGV, input: $stdin)
51
55
  fresh = !installed?
52
56
  mode = fresh ? :install : :update # :update here means "re-sync, skip bootstrap"
53
57
 
54
58
  distribute(mode)
55
59
  bootstrap if fresh
56
60
 
57
- results = selected.map { |key| install_for_agent(key, force) }
61
+ results = selected.map { |key| install_for_agent(key, force, argv: argv, input: input, reinstall: reinstall) }
58
62
 
59
63
  action = ledger_action || (fresh ? "install" : "reinstall")
60
64
  ledger_append(version, action)
@@ -72,8 +76,37 @@ class Install < InstallerCore
72
76
  File.exist?(path) ? File.read(path).strip : nil
73
77
  end
74
78
 
79
+ # Injectable pre-flight gate: real probes as default args, printing to an
80
+ # injectable `out:` IO so this is hermetically testable via StringIO. Returns
81
+ # 1 (stop the install) when Ruby is missing/too-old, else 0.
82
+ def preflight_gate(ruby_version: RUBY_VERSION, node_version: node_probe, git_present: git_probe,
83
+ mise_present: mise_probe, out: $stderr)
84
+ result = Preflight.check(ruby_version: ruby_version, node_version: node_version,
85
+ git_present: git_present, mise_present: mise_present)
86
+ result[:messages].each { |message| out.puts(message) }
87
+ result[:fatal] ? 1 : 0
88
+ end
89
+
75
90
  private
76
91
 
92
+ def node_probe
93
+ `node --version`.strip
94
+ rescue StandardError
95
+ ""
96
+ end
97
+
98
+ def git_probe
99
+ !`command -v git`.strip.empty?
100
+ rescue StandardError
101
+ false
102
+ end
103
+
104
+ def mise_probe
105
+ !`command -v mise`.strip.empty?
106
+ rescue StandardError
107
+ false
108
+ end
109
+
77
110
  def flag_value(argv, name)
78
111
  i = argv.index(name)
79
112
  return nil unless i && argv[i + 1]
@@ -119,9 +152,12 @@ class Install < InstallerCore
119
152
  --alpha Alpha channel
120
153
 
121
154
  Other options:
122
- --reinstall Re-sync core files for the installed version (repair). Store untouched.
123
- --force Overwrite existing files without prompting
124
- -h, --help Show this help
155
+ --reinstall Re-sync core files for the installed version (repair). Store untouched.
156
+ --force Overwrite existing files without prompting
157
+ --statusline VALUE keep or plastic. If an existing statusline is found, this
158
+ skips the interactive prompt. Interactive sessions ask by
159
+ default; non-interactive sessions default to keep.
160
+ -h, --help Show this help
125
161
 
126
162
  Notes:
127
163
  Install is one-shot. If Plastic is already installed, use `update` to upgrade or
@@ -921,6 +921,49 @@ module Bridge
921
921
  "(blocked edit: #{file_abs})"
922
922
  end
923
923
 
924
+ # --- Solo-mode detection (intent 128) ---------------------------------------
925
+ #
926
+ # Positive-only confirmation that exactly one session is delivering, from the
927
+ # durable delivery.lock files (never the /tmp bridge cache, D2). Used to relax
928
+ # the two ARBITRATION gates (lock_gate_decision, worktree_gate_decision) from
929
+ # a hard deny to an advisory allow when there is nothing to arbitrate.
930
+ #
931
+ # SOLO iff exactly ONE fresh delivery.lock exists across scan_roots, that
932
+ # lock's owner_session equals the resolved session, and its delegates array
933
+ # is empty. Any ambiguity (more than one fresh lock, including several under
934
+ # the SAME owner_session, which reads as parallel-in-play), a foreign owner,
935
+ # a non-empty delegates array, a blank/unresolvable session, or any error
936
+ # during the scan all return false (fail-closed direction preserved).
937
+ def self.solo_delivery?(scan_roots:, session:, ttl: Lock::TTL_SECONDS, now: Time.now)
938
+ return false if blank?(session)
939
+
940
+ lock_dirs = Array(scan_roots).compact.flat_map { |root|
941
+ Dir.glob(File.join(File.expand_path(root), "*", "delivery.lock"))
942
+ }.uniq.map { |lock_file| File.dirname(lock_file) }
943
+
944
+ fresh_dirs = lock_dirs.select { |dir| Lock.fresh?(dir, ttl: ttl, now: now) }
945
+ fresh_locks = fresh_dirs.map { |dir| Lock.read(dir) }
946
+
947
+ # A fresh-but-unreadable (corrupt) lock is real ambiguity, not an absence:
948
+ # dropping it via filter_map could leave exactly one READABLE lock and
949
+ # misconfirm solo while a second, unreadable-but-live lock is in play.
950
+ # Any unreadable fresh lock keeps this fail-closed (review finding 2).
951
+ return false if fresh_locks.any?(&:nil?)
952
+ return false unless fresh_locks.length == 1
953
+
954
+ lock = fresh_locks.first
955
+ lock["owner_session"].to_s == session.to_s && Array(lock["delegates"]).empty?
956
+ rescue StandardError
957
+ false
958
+ end
959
+
960
+ # One terse advisory line (no em-dashes), then ALLOW (nil). Shared by both
961
+ # arbitration gates so a relaxed deny always logs the same shape.
962
+ def self.solo_allow(id, reason)
963
+ $stderr.puts "plastic: solo delivery confirmed for intent #{id} (#{reason}); allowing"
964
+ nil
965
+ end
966
+
924
967
  # --- Fail-closed lock gate (intent 96) -------------------------------------
925
968
 
926
969
  # Returns a reason String to BLOCK, or nil to ALLOW. Decides from the
@@ -931,7 +974,7 @@ module Bridge
931
974
  # target's lock names as owner or delegate (even when stale: a stale lock is
932
975
  # still its owner's until an explicit takeover).
933
976
  def self.lock_gate_decision(bridge_data, file_path, session: nil,
934
- ttl: Lock::TTL_SECONDS, now: Time.now)
977
+ ttl: Lock::TTL_SECONDS, now: Time.now, home: Dir.home)
935
978
  return nil if blank?(file_path)
936
979
 
937
980
  target_dir = intent_dir_for(file_path)
@@ -943,23 +986,34 @@ module Bridge
943
986
  sess = session
944
987
  sess = bridge_data["session"] if blank?(sess) && bridge_data.is_a?(Hash)
945
988
 
989
+ # Solo-mode detection (intent 128): scan this intent's store plus the
990
+ # global store under `home` for fresh delivery locks. Computed once; used
991
+ # at every arbitration deny below to relax a hard deny to an advisory
992
+ # allow when solo delivery is positively confirmed.
993
+ scan_roots = [store, File.join(File.expand_path(home), ".plastic", "store")]
994
+ solo = solo_delivery?(scan_roots: scan_roots, session: sess, ttl: ttl, now: now)
995
+
946
996
  lock = Lock.read(target_dir)
947
997
  if lock
948
998
  return nil if Lock.authorized?(lock, sess)
949
999
  if Lock.fresh?(target_dir, ttl: ttl, now: now)
1000
+ return solo_allow(id, "fresh delivery lock") if solo
950
1001
  return "intent #{id} delivery lock is held by session " \
951
1002
  "#{lock['owner_session']}. Back off; if you are the owner's " \
952
1003
  "subagent, the owner must run: plastic-lock delegate " \
953
1004
  "--intent-dir #{target_dir} --session <your-session-id>. " \
954
1005
  "Inspect with /plastic-lock status"
955
1006
  end
1007
+ return solo_allow(id, "stale delivery lock") if solo
956
1008
  return "intent #{id} has a stale delivery lock (owner " \
957
1009
  "#{lock['owner_session']}); run /plastic-lock reclaim to take " \
958
1010
  "it over, or /plastic-lock fix"
959
1011
  end
960
1012
  if Lock.corrupt?(target_dir)
1013
+ return solo_allow(id, "unreadable delivery.lock") if solo
961
1014
  return "delivery.lock for intent #{id} is unreadable; run /plastic-lock fix"
962
1015
  end
1016
+ return solo_allow(id, "no delivery lock") if solo
963
1017
  "no delivery lock held for intent #{id}; run /plastic-intent-starting " \
964
1018
  "to lock and begin"
965
1019
  end
@@ -1012,6 +1066,19 @@ module Bridge
1012
1066
  under_own_intent = intent_dir_abs &&
1013
1067
  (file_abs == intent_dir_abs || file_abs.start_with?("#{intent_dir_abs}/"))
1014
1068
 
1069
+ # Solo-mode detection (intent 128): current session first, else the
1070
+ # bridge's own session; scan roots are this intent's store, the global
1071
+ # store under `home`, AND the EDIT TARGET's own store (when the target
1072
+ # lives inside a store dir), so a live foreign lock on the intent being
1073
+ # edited is never invisible to the scan just because it belongs to a
1074
+ # different project than the acting bridge's own store (review finding 1;
1075
+ # duplicate roots are harmless, solo_delivery? dedupes). Computed once;
1076
+ # used by both rules below.
1077
+ sess = blank?(current_session) ? bridge_data["session"] : current_session
1078
+ target_store = parse_store_target(file_abs, plastic_home)&.fetch(:store, nil)
1079
+ scan_roots = [store, File.join(plastic_home, "store"), target_store]
1080
+ solo = solo_delivery?(scan_roots: scan_roots, session: sess)
1081
+
1015
1082
  # Rule 1 (fixed in intent 108, D7): confinement applies ONLY to paths
1016
1083
  # inside the project repo. The repo root is derived from the provisioned
1017
1084
  # code worktree path, which is <repo>/.claude/worktrees/{id}--{slug} by
@@ -1029,6 +1096,7 @@ module Bridge
1029
1096
  inside_code = file_abs == code_abs || file_abs.start_with?("#{code_abs}/")
1030
1097
  if inside_repo && !inside_code
1031
1098
  id = intent_info["id"]
1099
+ return solo_allow(id, "worktree confinement") if solo
1032
1100
  return "intent #{id} is isolated to its worktree - edit project code " \
1033
1101
  "inside #{code_abs}, not the shared checkout. (blocked edit: #{file_abs})"
1034
1102
  end
@@ -1040,7 +1108,10 @@ module Bridge
1040
1108
  reason = non_owner_store_edit_reason(file_abs, plastic_home, intent_dir_abs,
1041
1109
  home: home, current_session: current_session,
1042
1110
  own_session: bridge_data["session"])
1043
- return reason if reason
1111
+ if reason
1112
+ return solo_allow(intent_info["id"], "non-owner store edit") if solo
1113
+ return reason
1114
+ end
1044
1115
  end
1045
1116
 
1046
1117
  nil
@@ -1173,7 +1244,11 @@ module Bridge
1173
1244
  c = line[i]
1174
1245
  case state
1175
1246
  when :single
1176
- out << " "
1247
+ if c == "'" || c == "<" || c == ">"
1248
+ out << " "
1249
+ else
1250
+ out << c
1251
+ end
1177
1252
  state = :normal if c == "'"
1178
1253
  i += 1
1179
1254
  when :double
@@ -1181,7 +1256,11 @@ module Bridge
1181
1256
  out << " "
1182
1257
  i += 2
1183
1258
  else
1184
- out << " "
1259
+ if c == '"' || c == "<" || c == ">"
1260
+ out << " "
1261
+ else
1262
+ out << c
1263
+ end
1185
1264
  state = :normal if c == '"'
1186
1265
  i += 1
1187
1266
  end
@@ -1191,7 +1270,7 @@ module Bridge
1191
1270
  elsif c == '"'
1192
1271
  out << " "; state = :double; i += 1
1193
1272
  elsif c == "<" && line[i + 1] == "<"
1194
- m = line[i..].match(/\A<<(-?)\s*("|')?([A-Za-z_][A-Za-z0-9_]*)\2?/)
1273
+ m = line[i..].match(/\A<<(-?)\s*("|')?([A-Za-z0-9_][A-Za-z0-9_]*)\2?/)
1195
1274
  if m
1196
1275
  openers << { word: m[3], dash: m[1] == "-" }
1197
1276
  out << (" " * m[0].length)
@@ -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)
@@ -197,6 +228,7 @@ class InstallerCore
197
228
  def core_files
198
229
  {
199
230
  "PLASTIC.md" => "PLASTIC.md",
231
+ "PLASTIC-reference.md" => "PLASTIC-reference.md",
200
232
  "deprecations.yml" => "deprecations.yml",
201
233
  "scripts/folgezettel-id" => "scripts/folgezettel-id",
202
234
  "scripts/read-config" => "scripts/read-config",
@@ -249,6 +281,7 @@ class InstallerCore
249
281
  "scripts/lib/store_provisioning.rb" => "scripts/lib/store_provisioning.rb",
250
282
  "scripts/provision-project-store" => "scripts/provision-project-store",
251
283
  "scripts/lib/installer_core.rb" => "scripts/lib/installer_core.rb",
284
+ "scripts/lib/preflight.rb" => "scripts/lib/preflight.rb",
252
285
  "scripts/install.rb" => "scripts/install.rb",
253
286
  "scripts/update.rb" => "scripts/update.rb",
254
287
  "scripts/uninstall.rb" => "scripts/uninstall.rb",
@@ -321,7 +354,7 @@ class InstallerCore
321
354
  (data["files"] || {}).keys
322
355
  end
323
356
 
324
- def install_for_agent(key, force)
357
+ def install_for_agent(key, force, argv: [], input: $stdin, reinstall: false)
325
358
  config = agent_config(key)
326
359
  return { agent: config[:name], success: false, reason: "Unknown agent" } unless config
327
360
 
@@ -334,7 +367,7 @@ class InstallerCore
334
367
  old_files = manifest_files(manifest_path_for(key, config))
335
368
 
336
369
  result = case key
337
- when "claude" then install_claude(config, force)
370
+ when "claude" then install_claude(config, force, argv: argv, input: input, reinstall: reinstall)
338
371
  when "codex" then install_codex(config, force)
339
372
  when "hermes" then install_hermes(config, force)
340
373
  end
@@ -363,7 +396,7 @@ class InstallerCore
363
396
  removed
364
397
  end
365
398
 
366
- def install_claude(config, force)
399
+ def install_claude(config, force, argv: [], input: $stdin, reinstall: false)
367
400
  hooks_dir = File.join(config[:dir], "hooks")
368
401
  skills_root = File.join(config[:dir], "skills")
369
402
  plastic_dir = File.join(config[:dir], "plastic")
@@ -406,7 +439,8 @@ class InstallerCore
406
439
 
407
440
  # Merge hooks + statusline into settings.json (no plugin registration)
408
441
  settings_path = File.join(config[:dir], "settings.json")
409
- merge_claude_hooks(settings_path)
442
+ choice = statusline_choice(settings_path, argv: argv, input: input, reinstall: reinstall)
443
+ merge_claude_hooks(settings_path, choice: choice)
410
444
 
411
445
  # Write manifest
412
446
  manifest_path = File.join(plastic_dir, "manifest.json")
@@ -572,7 +606,7 @@ class InstallerCore
572
606
 
573
607
  # --- settings.json merge (read-modify-write, never clobber) ---
574
608
 
575
- def merge_claude_hooks(settings_path)
609
+ def merge_claude_hooks(settings_path, choice: :plastic)
576
610
  settings = read_json_safe(settings_path) || {}
577
611
  return if settings.nil?
578
612
 
@@ -614,7 +648,7 @@ class InstallerCore
614
648
  File.write(File.join(cache_dir, "original-statusline.json"), JSON.pretty_generate(existing_status))
615
649
  end
616
650
 
617
- settings["statusLine"] = { "type" => "command", "command" => "#{hook_dir}/plastic-statusline" }
651
+ settings["statusLine"] = { "type" => "command", "command" => "#{hook_dir}/plastic-statusline" } if choice == :plastic
618
652
 
619
653
  # No plugin/marketplace registration: skills are flat personal skills
620
654
  # (plastic-<name>/) discovered directly from ~/.claude/skills.
@@ -52,24 +52,26 @@ module PowerTools
52
52
  false
53
53
  end
54
54
 
55
- # Recommendation text for whichever tools are present, joined by newlines, or
56
- # nil when none are. One recommendation line per present tool.
57
- def mandate(cwd:, qmd_detector: QmdSync.method(:detect), serena_detector: nil)
58
- lines = []
59
-
60
- if qmd?(detector: qmd_detector)
61
- lines << "QMD is available: prefer `qmd search` / `qmd query` over the " \
62
- "`plastic-*` collections to check for existing or related intents " \
63
- "before treating work as new."
64
- end
55
+ QMD_OBLIGATION = "prefer `qmd search` / `qmd query` over the `plastic-*` " \
56
+ "collections to check for existing or related intents before " \
57
+ "treating work as new"
58
+ SERENA_OBLIGATION = "prefer its symbolic tools (find_symbol / get_symbols_overview / " \
59
+ "find_referencing_symbols) for code navigation"
65
60
 
61
+ # Recommendation text for whichever tools are present, or nil when none are.
62
+ # Both present collapse to ONE combined line naming both obligations (no
63
+ # embedded newline); one present returns that tool's own line; neither
64
+ # returns nil.
65
+ def mandate(cwd:, qmd_detector: QmdSync.method(:detect), serena_detector: nil)
66
+ qmd_present = qmd?(detector: qmd_detector)
66
67
  serena_present = serena_detector ? !!serena_detector.call : serena?(cwd: cwd)
67
- if serena_present
68
- lines << "Serena is available: prefer its symbolic tools (find_symbol / " \
69
- "get_symbols_overview / find_referencing_symbols) for code navigation."
70
- end
71
68
 
72
- return nil if lines.empty?
73
- lines.join("\n")
69
+ if qmd_present && serena_present
70
+ "QMD and Serena are available: #{QMD_OBLIGATION}, and #{SERENA_OBLIGATION}."
71
+ elsif qmd_present
72
+ "QMD is available: #{QMD_OBLIGATION}."
73
+ elsif serena_present
74
+ "Serena is available: #{SERENA_OBLIGATION}."
75
+ end
74
76
  end
75
77
  end
@@ -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
@@ -76,17 +76,10 @@ ruby -r ~/.plastic/scripts/lib/bridge -e \
76
76
  Replace `<ID>`, `<STORE>` (e.g. `~/.plastic/projects/<slug>/store` or `~/.plastic/store`),
77
77
  `<dir>` (the `ID--slug` directory), and `<name>`. The first argument is the session id you
78
78
  want the bridge keyed by: pass the hook stdin `session_id` when you have it, otherwise
79
- `ENV["CLAUDE_CODE_SESSION_ID"]`, otherwise `nil`. `arm_auto` calls `resolve_session`, which
80
- picks the first non-empty of: the explicit id you pass -> `CLAUDE_CODE_SESSION_ID` -> a
81
- deterministic derived key (a hash of the store and intent id).
82
- It never returns nil, so the gate engages even when every session env var is empty; the call
83
- never needs a non-empty session env var to function. Arming prints a one-line notice to
84
- stderr when it falls through to the derived key.
85
-
86
- Arming acquires the durable `delivery.lock` in the intent dir, keyed by that resolved
87
- session. Ownership is session-keyed, not process-keyed, so the arm one-liner exiting
88
- immediately is fine by construction: the lock stays yours for every later tool call in this
89
- session. A failed arm raises with a message naming the resolving `plastic-lock` verb.
79
+ `ENV["CLAUDE_CODE_SESSION_ID"]`, otherwise `nil`. Arming always succeeds and acquires the
80
+ durable `delivery.lock` in the intent dir. For the `resolve_session` fallback chain
81
+ (why arming never needs a non-empty session env var, and what the lock ownership model
82
+ implies for later tool calls) read `references/end-tail.md`.
90
83
 
91
84
  **Hard rule for the rest of this run:** do NOT edit project code (anything outside the
92
85
  intent directory / `~/.plastic/`) until `plan.md` AND `checklist.md` exist for the intent.
@@ -165,6 +158,10 @@ Filesystem fallback (ledger missing only):
165
158
 
166
159
  Announce which stage you're entering and why.
167
160
 
161
+ Notify user (What briefing): brief per `references/human-report-contract.md`
162
+ (State: the work picked up and why it matters now; Risk: scope uncertainty; Call: confirm
163
+ this is worth doing, or proceed).
164
+
168
165
  ## Why Completion (Autonomous)
169
166
 
170
167
  When entering at Why stage:
@@ -179,6 +176,9 @@ When entering at Why stage:
179
176
  5. Make decisions — pick best option, document in `## Context > ### Decisions` with rationale
180
177
  6. Log all autonomous decisions in `## Insights` with `(autonomous)` marker: "Decision: chose X because Y (autonomous)"
181
178
  7. Write `spec.md` — consolidated specification
179
+ 8. Notify user (Why briefing): brief per `references/human-report-contract.md`
180
+ (State: the approach chosen, one line; Risk: the main trade-off; Call: the one decision
181
+ needed, approve or pick an option).
182
182
 
183
183
  Then proceed to How.
184
184
 
@@ -193,6 +193,9 @@ only (S/M leave the directory empty).
193
193
  2. Otherwise, write `plan.md` directly — implementation plan with numbered tasks
194
194
  3. Write `ACTION_N.md` files into the existing `actions/` directory (one per task, self-contained) — L only
195
195
  4. Write `checklist.md` — execution registry with checkboxes covering all actions
196
+ 5. Notify user (How briefing): brief per `references/human-report-contract.md`
197
+ (State: the plan shape, task count and what it builds; Risk: the riskiest task or
198
+ dependency; Call: approve the plan to build).
196
199
 
197
200
  Then proceed to Exec.
198
201
 
@@ -216,6 +219,9 @@ If the plan calls for creating a new project (the intent is an implementation in
216
219
  4. Check off items in `checklist.md` as completed
217
220
  5. Append observations to `## Insights` with `(autonomous)` marker
218
221
  6. Sub-agents can be spawned for parallel actions (one agent per action)
222
+ 7. Notify user (Exec briefing): brief per `references/human-report-contract.md`
223
+ (State: what got built and the test result; Risk: residual failures or deviations;
224
+ Call: go to review, or done).
219
225
 
220
226
  ## Permission Model — Safe-by-Default
221
227
 
@@ -281,36 +287,27 @@ During initial project creation, all decisions are non-destructive by definition
281
287
  ```bash
282
288
  ruby -r ~/.plastic/scripts/lib/bridge -e 'Bridge.disarm_auto(ENV["CLAUDE_CODE_SESSION_ID"], intent_id: "<ID>")'
283
289
  ```
284
- Disarm runs the ordered End tail: it releases the worktrees first, then clears the
285
- intent's `delivery.lock` (and the bridge's lock cache), and only then is the bridge
286
- purge-eligible. Disarming also purges stale bridge files from the temp directory
287
- automatically (it keeps the current bridge, any live run, and any bridge whose intent
288
- still holds a delivery lock), so no manual `/tmp` cleanup is needed.
289
-
290
- **Worktree cleanup (mandatory, intent 73c3).** Disarming performs the worktree release:
291
- `disarm_auto` calls `Worktree.release`, which removes both per-intent worktrees (the code
292
- worktree under `<repo>/.claude/worktrees/{id}--{slug}` and the paired store worktree under
293
- `<plastic_home>/.worktrees/{id}--{slug}`), prunes both repos, and clears the worktree block
294
- from the bridge. This is the plain remove path: the disarm route does NOT merge, so use it
295
- only when no release merges the branch (the branch survives and can be reclaimed).
296
-
297
- When the work is being shipped through a release, do NOT rely on this plain remove. The
298
- release path (step 4 above, via `plastic-releasing`) is responsible for merging the intent's
299
- code branch (`plastic/{id}--{slug}`) back to the repo's default branch BEFORE the worktree is
300
- removed, so the integrated work is not lost. It does this with `Worktree.finish(bridge_data,
301
- merge: true)` (merge-then-remove). Never leave an orphaned worktree, and run `git worktree
302
- prune` if you hit a stale reference.
303
- 9. QMD reindex LAST (canonical End tail). AFTER disarm has released the worktrees, cleared the
304
- `delivery.lock`, and purged the bridge, refresh the QMD search index for this store (no-op when
305
- QMD is absent). It runs in the background so it never blocks the turn:
290
+ Disarm runs the ordered End tail (release worktrees, then clear the `delivery.lock`,
291
+ then the bridge becomes purge-eligible) and performs the mandatory worktree cleanup
292
+ (intent 73c3): both per-intent worktrees are removed and both repos pruned. This is
293
+ the plain remove path (no merge); when the work ships through a release, the release
294
+ path merges the branch BEFORE the worktree is removed instead of relying on this step.
295
+ Never leave an orphaned worktree, and run `git worktree prune` if you hit a stale
296
+ reference. For the full ordering rationale and the release-vs-plain-disarm
297
+ distinction, read `references/end-tail.md`.
298
+ 9. QMD reindex LAST (canonical End tail), run only after disarm has released the
299
+ worktrees, cleared the `delivery.lock`, and purged the bridge. It runs in the
300
+ background so it never blocks the turn:
306
301
  ```bash
307
302
  ruby ~/.plastic/scripts/qmd-sync reindex --store <store-root> --async
308
303
  ```
309
- Completion is the lifecycle event that keeps the search index fresh. `<store-root>` is the store
310
- that holds this intent (the global store or the project store). The reindex is the LAST End-tail
311
- step, run after purge, so the index never references a bridge or lock that is about to disappear
312
- (see PLASTIC.md `## Delivery Isolation and the Single-Owner Lock`).
313
- 10. Notify user: "Intent [ID] [name] delivered. [1-2 sentence summary]. See outcome.md for details."
304
+ `<store-root>` is the store that holds this intent (the global store or the project
305
+ store); the command is a no-op when QMD is absent. For why the reindex must be last
306
+ (so the index never references a bridge or lock about to disappear), read
307
+ `references/end-tail.md`.
308
+ 10. Notify user (Done briefing): brief per `references/human-report-contract.md`
309
+ (State: the delivered impact; Risk: residual risk; Call: the decision left to you, merge,
310
+ release, or accept). See `outcome.md` for details.
314
311
 
315
312
  ## Error Handling
316
313
 
@@ -324,3 +321,8 @@ If the agent gets stuck (can't resolve a gap, dependency is missing, tests fail
324
321
 
325
322
  - Read `references/agent-architecture.md` for the full team model (the 5-role enforcer-led team, per-stage handoffs, gate ownership, headless note, solo fallback) and the orchestrator hierarchy (Main Orchestrator, Project Orchestrators, coordination loop) when spinning up the team or understanding autonomous delivery scope
326
323
  - Read `references/tiers.md` for the extended per-tier walkthrough (S/M/L worked examples, the collapsed one-thinker flow, the QMD-skip case for S) and rationale
324
+ - Read `references/human-report-contract.md` for the human-facing per-stage briefing (the
325
+ State/Risk/Call skeleton used at each "Notify user" step above, and how it differs from the
326
+ internal `agent-report-contract.md`)
327
+ - Read `references/end-tail.md` for the `resolve_session` fallback chain and the disarm
328
+ ordering / worktree cleanup / QMD reindex rationale referenced above