@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.
@@ -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
@@ -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
- # Outputs hook JSON for the continue hook. Exits silently if nothing to show.
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 "date"
8
- require "yaml"
9
+ require "open3"
9
10
 
10
- index_path, store_root, mode = ARGV
11
- exit 0 unless index_path && store_root && mode
11
+ index_path, _store_root, _mode = ARGV
12
+ exit 0 unless index_path && File.exist?(index_path)
12
13
 
13
- # --- Parse INDEX.md ---
14
+ dashboard = File.expand_path("dashboard.rb", __dir__)
15
+ exit 0 unless File.exist?(dashboard)
14
16
 
15
- lines = File.readlines(index_path)
16
- active = []
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
- lines.each do |line|
21
- if line.start_with?("## Active")
22
- section = :active
23
- next
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" => parts.join("\n")
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
- # --- Early exit only if no PLASTIC.md ---
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
- # Conventions always loaded first
186
- parts << plastic_md
187
- parts << "\n---\n"
188
-
189
- if current_project
190
- slug = current_project["slug"]
191
- banner = "Project: #{slug} | Store: ~/.plastic/projects/#{slug}/store/"
192
- if project_active.any? && project_active.first =~ /\[([^\]]+)\].*store\/([\w-]+)\//
193
- intent_name, dir_name = $1, $2
194
- intent_id = dir_name.split("--").first
195
- banner += "\nActive: [#{intent_id} — #{intent_name}] | Artifacts → store/#{dir_name}/"
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
- parts << "Active intents:\n"
204
- all_active.each { |a| parts << a }
205
- if bridge_data
206
- stage = bridge_data["build"]["stage"]
207
- missing = bridge_data["build"]["missing"]
208
- missing_str = missing.empty? ? "none" : missing.join(", ")
209
- parts << "Stage: #{stage} | Next: #{missing_str}"
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
- parts << "\nStale future intents (untouched for days):\n"
218
- stale.each do |s|
219
- parts << "- #{s[:name]} (#{s[:age]} days) — consider: activate, abandon, or defer to agent"
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",