@zalom/plastic 1.0.0-alpha.17 → 1.0.0-alpha.19
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/README.md +48 -6
- package/bin/plastic.js +57 -0
- package/bin/test +28 -0
- package/hooks/bash-gate +3 -0
- package/hooks/hooks.json +10 -0
- package/package.json +2 -2
- package/scripts/doctor.rb +741 -729
- package/scripts/hook-bash-gate +53 -0
- package/scripts/hook-continue +15 -114
- package/scripts/hook-session-start +42 -34
- package/scripts/install.rb +90 -695
- package/scripts/lib/bridge.rb +103 -0
- package/scripts/lib/installer_core.rb +760 -0
- package/scripts/uninstall.rb +53 -0
- package/scripts/update.rb +142 -0
- package/scripts/versions.rb +141 -0
- package/skills/brainstorming/SKILL.md +1 -1
- package/skills/executing-plan/SKILL.md +2 -2
- package/skills/install/SKILL.md +5 -5
- package/skills/uninstall/SKILL.md +2 -2
- package/skills/update/SKILL.md +35 -91
- package/skills/versions/SKILL.md +65 -0
- package/templates/outcome.md +13 -0
- package/templates/spec.md +25 -0
- package/bin/install.js +0 -29
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# PreToolUse gate for Bash (intent 27a): close the Bash-edit bypass. When auto
|
|
6
|
+
# mode is armed and the active intent has not reached How (plan.md + checklist.md),
|
|
7
|
+
# block Bash commands that WRITE project code outside the store.
|
|
8
|
+
#
|
|
9
|
+
# Reads the Claude Code PreToolUse payload as JSON on STDIN:
|
|
10
|
+
# { "tool_input": { "command": "..." }, "cwd": "..." }
|
|
11
|
+
# Empty / unparseable / no command => exit 0 (allow). Conservatism is the prime
|
|
12
|
+
# directive: when a command's write intent is ambiguous, ALLOW.
|
|
13
|
+
#
|
|
14
|
+
# Exit 0 = allow. Exit 2 = block (reason on stderr, shown to the agent).
|
|
15
|
+
|
|
16
|
+
require "json"
|
|
17
|
+
require_relative "lib/bridge"
|
|
18
|
+
|
|
19
|
+
raw = $stdin.read rescue nil
|
|
20
|
+
exit 0 if raw.nil? || raw.strip.empty?
|
|
21
|
+
|
|
22
|
+
payload = JSON.parse(raw) rescue nil
|
|
23
|
+
exit 0 unless payload.is_a?(Hash)
|
|
24
|
+
|
|
25
|
+
command = payload.dig("tool_input", "command") || payload.dig("tool_params", "command")
|
|
26
|
+
exit 0 if command.nil? || command.to_s.strip.empty?
|
|
27
|
+
|
|
28
|
+
cwd = payload["cwd"]
|
|
29
|
+
cwd = Dir.pwd if cwd.nil? || cwd.to_s.empty?
|
|
30
|
+
|
|
31
|
+
# --- Load bridge (mirror hook-code-gate resolution) ---
|
|
32
|
+
session = ENV["CLAUDE_SESSION_ID"]
|
|
33
|
+
bridge_data = nil
|
|
34
|
+
|
|
35
|
+
if session && !session.empty?
|
|
36
|
+
bridge_data = Bridge.read(session) if File.exist?(Bridge.path(session))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
unless bridge_data
|
|
40
|
+
candidates = Dir.glob("/tmp/plastic-*.json").reject { |f| f.end_with?(".tmp") }
|
|
41
|
+
if candidates.any?
|
|
42
|
+
newest = candidates.max_by { |f| File.mtime(f) }
|
|
43
|
+
bridge_data = JSON.parse(File.read(newest)) rescue nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
exit 0 unless bridge_data
|
|
48
|
+
|
|
49
|
+
reason = Bridge.bash_gate_decision(bridge_data, command, cwd: cwd)
|
|
50
|
+
exit 0 unless reason
|
|
51
|
+
|
|
52
|
+
$stderr.puts "PLASTIC GATE — #{reason}"
|
|
53
|
+
exit 2
|
package/scripts/hook-continue
CHANGED
|
@@ -1,130 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# encoding: UTF-8
|
|
3
3
|
# Usage: hook-continue <index_path> <store_root> <mode>
|
|
4
|
-
#
|
|
4
|
+
# Renders the dashboard `continue` cockpit as UserPromptSubmit additionalContext.
|
|
5
|
+
# Exits silently (no output) if the dashboard produces nothing — the bash caller
|
|
6
|
+
# then falls back to a minimal notice.
|
|
5
7
|
|
|
6
8
|
require "json"
|
|
7
|
-
require "
|
|
8
|
-
require "yaml"
|
|
9
|
+
require "open3"
|
|
9
10
|
|
|
10
|
-
index_path,
|
|
11
|
-
exit 0 unless index_path &&
|
|
11
|
+
index_path, _store_root, _mode = ARGV
|
|
12
|
+
exit 0 unless index_path && File.exist?(index_path)
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
dashboard = File.expand_path("dashboard.rb", __dir__)
|
|
15
|
+
exit 0 unless File.exist?(dashboard)
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
future = []
|
|
18
|
-
section = nil
|
|
17
|
+
cockpit, _err, status = Open3.capture3("ruby", dashboard, "continue")
|
|
18
|
+
exit 0 unless status.success? && !cockpit.strip.empty?
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
elsif line.start_with?("## Future")
|
|
25
|
-
section = :future
|
|
26
|
-
next
|
|
27
|
-
elsif line.start_with?("## ")
|
|
28
|
-
section = nil
|
|
29
|
-
next
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
next unless section && line.strip.start_with?("- [")
|
|
33
|
-
|
|
34
|
-
if section == :active
|
|
35
|
-
active << line.strip
|
|
36
|
-
elsif section == :future
|
|
37
|
-
future << line.strip
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
# --- Detect stale future intents ---
|
|
42
|
-
|
|
43
|
-
stale = []
|
|
44
|
-
stale_days = 3
|
|
45
|
-
config_path = "#{store_root}/config.yml"
|
|
46
|
-
if File.exist?(config_path)
|
|
47
|
-
config = YAML.safe_load(File.read(config_path)) rescue {}
|
|
48
|
-
stale_days = config["stale_threshold_days"] || 3
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
future.each do |f|
|
|
52
|
-
if f =~ /store\/([\w-]+)\//
|
|
53
|
-
dir_name = $1
|
|
54
|
-
intent_file = "#{store_root}/store/#{dir_name}/#{dir_name}.md"
|
|
55
|
-
next unless File.exist?(intent_file)
|
|
56
|
-
|
|
57
|
-
content = File.read(intent_file)
|
|
58
|
-
if content =~ /^created:\s*['"]?(\d{4}-\d{2}-\d{2})/
|
|
59
|
-
age = (Date.today - Date.parse($1)).to_i
|
|
60
|
-
if age >= stale_days
|
|
61
|
-
name = f[/\[([^\]]+)\]/, 1] || "unknown"
|
|
62
|
-
stale << { name: name, age: age, entry: f }
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
# --- Detect current project ---
|
|
69
|
-
|
|
70
|
-
current_project = nil
|
|
71
|
-
if mode == "global"
|
|
72
|
-
projects_path = "#{store_root}/projects.yml"
|
|
73
|
-
if File.exist?(projects_path)
|
|
74
|
-
projects = YAML.safe_load(File.read(projects_path)) rescue {}
|
|
75
|
-
cwd = Dir.pwd
|
|
76
|
-
(projects["projects"] || {}).each do |slug, info|
|
|
77
|
-
project_path = File.expand_path(info["path"])
|
|
78
|
-
if cwd.start_with?(project_path)
|
|
79
|
-
current_project = { "slug" => slug, "parent" => info["parent"], "path" => project_path }
|
|
80
|
-
break
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
# --- Build context ---
|
|
87
|
-
|
|
88
|
-
parts = []
|
|
89
|
-
parts << "PLASTIC CONTINUE — The user wants to resume work.\n"
|
|
90
|
-
|
|
91
|
-
if mode == "global" && current_project
|
|
92
|
-
parts << "Project: #{current_project["slug"]} (#{current_project["path"]})\n"
|
|
93
|
-
parts << "Governing intent: #{current_project["parent"]}\n" if current_project["parent"]
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
if active.any?
|
|
97
|
-
parts << "## Active Intents (work on these first)\n"
|
|
98
|
-
active.each { |a| parts << a }
|
|
99
|
-
parts << ""
|
|
100
|
-
else
|
|
101
|
-
parts << "## No Active Intents\n"
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
if future.any?
|
|
105
|
-
parts << "## Future Intents (offer as next work)\n"
|
|
106
|
-
future.each { |f| parts << f }
|
|
107
|
-
parts << ""
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
if stale.any?
|
|
111
|
-
parts << "## Stale Future Intents\n"
|
|
112
|
-
parts << "These future intents have not been actioned. Ask the user what to do with each:\n"
|
|
113
|
-
stale.each do |s|
|
|
114
|
-
parts << "- #{s[:name]} (#{s[:age]} days old) — options: activate, abandon, or defer to agent (implement / research / ideate)"
|
|
115
|
-
end
|
|
116
|
-
parts << ""
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
parts << "Follow the plastic-continuing skill workflow.\n"
|
|
120
|
-
parts << "1. If active intents exist: read their state and resume\n"
|
|
121
|
-
parts << "2. If no active intents: present future intents as options\n"
|
|
122
|
-
parts << "3. If stale intents exist: surface them and ask user to decide"
|
|
20
|
+
context = cockpit.rstrip +
|
|
21
|
+
"\n\nFollow the plastic-continuing skill workflow to resume: " \
|
|
22
|
+
"if active intents exist, read their state and resume; otherwise offer the " \
|
|
23
|
+
"Value×Effort matrix items above, surfacing any stale (Nd) / ⚑ triage intents."
|
|
123
24
|
|
|
124
25
|
payload = {
|
|
125
26
|
"hookSpecificOutput" => {
|
|
126
27
|
"hookEventName" => "UserPromptSubmit",
|
|
127
|
-
"additionalContext" =>
|
|
28
|
+
"additionalContext" => context
|
|
128
29
|
}
|
|
129
30
|
}
|
|
130
31
|
puts JSON.generate(payload)
|
|
@@ -172,53 +172,57 @@ if File.exist?(cache_file)
|
|
|
172
172
|
end
|
|
173
173
|
end
|
|
174
174
|
|
|
175
|
-
# ---
|
|
175
|
+
# --- Assemble session context ---
|
|
176
176
|
|
|
177
177
|
all_active = active + project_active
|
|
178
178
|
all_future = future + project_future
|
|
179
|
-
exit 0 unless plastic_md
|
|
180
179
|
|
|
181
180
|
# --- Build context ---
|
|
181
|
+
# PLASTIC.md conventions and intent context render only when conventions are
|
|
182
|
+
# installed. Deprecation warnings and update notices render regardless, so a
|
|
183
|
+
# partial install (or a missing PLASTIC.md) still surfaces critical warnings.
|
|
182
184
|
|
|
183
185
|
parts = []
|
|
184
186
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
parts <<
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
187
|
+
if plastic_md
|
|
188
|
+
# Conventions always loaded first
|
|
189
|
+
parts << plastic_md
|
|
190
|
+
parts << "\n---\n"
|
|
191
|
+
|
|
192
|
+
if current_project
|
|
193
|
+
slug = current_project["slug"]
|
|
194
|
+
banner = "Project: #{slug} | Store: ~/.plastic/projects/#{slug}/store/"
|
|
195
|
+
if project_active.any? && project_active.first =~ /\[([^\]]+)\].*store\/([\w-]+)\//
|
|
196
|
+
intent_name, dir_name = $1, $2
|
|
197
|
+
intent_id = dir_name.split("--").first
|
|
198
|
+
banner += "\nActive: [#{intent_id} — #{intent_name}] | Artifacts → store/#{dir_name}/"
|
|
199
|
+
end
|
|
200
|
+
parts << banner + "\n"
|
|
201
|
+
else
|
|
202
|
+
parts << "PLASTIC — Global store loaded from ~/.plastic/\n"
|
|
196
203
|
end
|
|
197
|
-
parts << banner + "\n"
|
|
198
|
-
else
|
|
199
|
-
parts << "PLASTIC — Global store loaded from ~/.plastic/\n"
|
|
200
|
-
end
|
|
201
204
|
|
|
202
|
-
if all_active.any?
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
205
|
+
if all_active.any?
|
|
206
|
+
parts << "Active intents:\n"
|
|
207
|
+
all_active.each { |a| parts << a }
|
|
208
|
+
if bridge_data
|
|
209
|
+
stage = bridge_data["build"]["stage"]
|
|
210
|
+
missing = bridge_data["build"]["missing"]
|
|
211
|
+
missing_str = missing.empty? ? "none" : missing.join(", ")
|
|
212
|
+
parts << "Stage: #{stage} | Next: #{missing_str}"
|
|
213
|
+
end
|
|
214
|
+
parts << ""
|
|
215
|
+
else
|
|
216
|
+
parts << "No active intents. #{future.length} future intents available.\n"
|
|
210
217
|
end
|
|
211
|
-
parts << ""
|
|
212
|
-
else
|
|
213
|
-
parts << "No active intents. #{future.length} future intents available.\n"
|
|
214
|
-
end
|
|
215
218
|
|
|
216
|
-
if stale.any?
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
219
|
+
if stale.any?
|
|
220
|
+
parts << "\nStale future intents (untouched for days):\n"
|
|
221
|
+
stale.each do |s|
|
|
222
|
+
parts << "- #{s[:name]} (#{s[:age]} days) — consider: activate, abandon, or defer to agent"
|
|
223
|
+
end
|
|
224
|
+
parts << "\nWhen appropriate, ask the user what to do with stale intents."
|
|
220
225
|
end
|
|
221
|
-
parts << "\nWhen appropriate, ask the user what to do with stale intents."
|
|
222
226
|
end
|
|
223
227
|
|
|
224
228
|
if active_deprecations.any?
|
|
@@ -252,6 +256,10 @@ if update_notice
|
|
|
252
256
|
parts.unshift("! #{update_notice}\n")
|
|
253
257
|
end
|
|
254
258
|
|
|
259
|
+
# Emit nothing when there is genuinely nothing to surface (no conventions,
|
|
260
|
+
# no deprecations, no update notice).
|
|
261
|
+
exit 0 if parts.join.strip.empty?
|
|
262
|
+
|
|
255
263
|
payload = {
|
|
256
264
|
"hookSpecificOutput" => {
|
|
257
265
|
"hookEventName" => "SessionStart",
|