@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.
- package/PLASTIC-reference.md +133 -0
- package/PLASTIC.md +29 -151
- package/agents/plastic-brainstorming.md +2 -6
- package/agents/plastic-enforcer.md +11 -6
- package/agents/plastic-executor.md +2 -6
- package/agents/plastic-future-intent-researcher.md +2 -7
- package/agents/plastic-intent-curator.md +2 -10
- package/agents/plastic-intent-discovery.md +8 -10
- package/agents/plastic-planner.md +2 -6
- package/agents/plastic-spec-specialist.md +2 -6
- package/bin/plastic.js +7 -3
- package/package.json +2 -1
- package/scripts/install.rb +42 -6
- package/scripts/lib/bridge.rb +84 -5
- package/scripts/lib/installer_core.rb +40 -6
- package/scripts/lib/power_tools.rb +18 -16
- package/scripts/lib/preflight.rb +79 -0
- package/skills/auto/SKILL.md +40 -38
- package/skills/auto/references/end-tail.md +56 -0
- package/skills/auto/references/human-report-contract.md +55 -0
- package/skills/brainstorming/SKILL.md +7 -34
- package/skills/brainstorming/references/design-principles.md +49 -0
- package/skills/creating-intent/SKILL.md +5 -26
- package/skills/creating-project/SKILL.md +11 -74
- package/skills/creating-project/references/project-scaffolding.md +97 -0
- package/skills/dashboard/SKILL.md +2 -17
- package/skills/dashboard/references/classification.md +22 -0
- 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 +14 -46
- package/skills/releasing/references/promotion-and-tagging.md +60 -0
- package/skills/uninstall/SKILL.md +29 -11
- package/skills/update/SKILL.md +34 -23
- package/skills/versions/SKILL.md +27 -12
- package/skills/writing-plans/SKILL.md +10 -88
- package/skills/writing-plans/references/plan-format.md +102 -0
package/scripts/install.rb
CHANGED
|
@@ -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
|
|
123
|
-
--force
|
|
124
|
-
|
|
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
|
package/scripts/lib/bridge.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
package/skills/auto/SKILL.md
CHANGED
|
@@ -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`.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
10. Notify user
|
|
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
|