@zalom/plastic 1.0.0-alpha.1
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/LICENSE +21 -0
- package/PLASTIC.md +534 -0
- package/README.md +88 -0
- package/agents/future-intent-researcher.md +38 -0
- package/agents/intent-curator.md +40 -0
- package/bin/install.js +29 -0
- package/deprecations.yml +23 -0
- package/hooks/check-update +42 -0
- package/hooks/continue +31 -0
- package/hooks/future-intent-check +25 -0
- package/hooks/gate-check +10 -0
- package/hooks/hooks.json +78 -0
- package/hooks/run-hook +7 -0
- package/hooks/savepoint +6 -0
- package/hooks/session-start +9 -0
- package/hooks/statusline +16 -0
- package/package.json +43 -0
- package/scripts/folgezettel-id +40 -0
- package/scripts/hash-intent +29 -0
- package/scripts/hook-continue +130 -0
- package/scripts/hook-future-intent-check +90 -0
- package/scripts/hook-gate-check +136 -0
- package/scripts/hook-session-start +224 -0
- package/scripts/install.rb +474 -0
- package/scripts/lib/bridge.rb +139 -0
- package/scripts/migrate-folgezettel +535 -0
- package/scripts/migrate-to-global +96 -0
- package/scripts/read-config +129 -0
- package/skills/auto/SKILL.md +127 -0
- package/skills/brainstorming-grill-me/SKILL.md +105 -0
- package/skills/continuing/SKILL.md +104 -0
- package/skills/creating-intent/SKILL.md +122 -0
- package/skills/creating-project/SKILL.md +166 -0
- package/skills/executing-plan/SKILL.md +120 -0
- package/skills/executing-plan/code-quality-reviewer-prompt.md +32 -0
- package/skills/executing-plan/implementer-prompt.md +42 -0
- package/skills/executing-plan/spec-reviewer-prompt.md +27 -0
- package/skills/install/SKILL.md +134 -0
- package/skills/intent-curator/SKILL.md +41 -0
- package/skills/linking-intents/SKILL.md +72 -0
- package/skills/managing-index/SKILL.md +66 -0
- package/skills/managing-index/references/zettelkasten-linking.md +27 -0
- package/skills/releasing/SKILL.md +124 -0
- package/skills/savepoint/SKILL.md +57 -0
- package/skills/uninstall/SKILL.md +48 -0
- package/skills/update/SKILL.md +69 -0
- package/templates/agents.md +46 -0
- package/templates/checklist.md +11 -0
- package/templates/config.yml +13 -0
- package/templates/index.md +13 -0
- package/templates/intent.md +24 -0
- package/templates/plan.md +11 -0
- package/templates/projects.yml +3 -0
- package/templates/savepoint.md +13 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# Usage: hook-gate-check <file_path>
|
|
4
|
+
# PostToolUse gate enforcement for Plastic intent lifecycle.
|
|
5
|
+
# Checks if a written file triggers a stage-transition gate.
|
|
6
|
+
# Exit 0 = allow (optionally with transition context)
|
|
7
|
+
# Exit 2 = block (gate violation)
|
|
8
|
+
|
|
9
|
+
require "json"
|
|
10
|
+
require_relative "lib/bridge"
|
|
11
|
+
|
|
12
|
+
file_path = ARGV[0]
|
|
13
|
+
exit 0 unless file_path && !file_path.empty?
|
|
14
|
+
|
|
15
|
+
# --- Find bridge file ---
|
|
16
|
+
|
|
17
|
+
session = ENV["CLAUDE_SESSION_ID"]
|
|
18
|
+
bridge_path = nil
|
|
19
|
+
bridge_data = nil
|
|
20
|
+
|
|
21
|
+
if session && !session.empty?
|
|
22
|
+
bridge_path = Bridge.path(session)
|
|
23
|
+
bridge_data = Bridge.read(session) if File.exist?(bridge_path)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Fallback: scan /tmp for most recent plastic bridge file
|
|
27
|
+
unless bridge_data
|
|
28
|
+
candidates = Dir.glob("/tmp/plastic-*.json").reject { |f| f.end_with?(".tmp") }
|
|
29
|
+
if candidates.any?
|
|
30
|
+
bridge_path = candidates.max_by { |f| File.mtime(f) }
|
|
31
|
+
raw = JSON.parse(File.read(bridge_path)) rescue nil
|
|
32
|
+
if raw
|
|
33
|
+
session = raw["session"]
|
|
34
|
+
bridge_data = raw
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# No bridge = no active intent = no enforcement
|
|
40
|
+
exit 0 unless bridge_data && session
|
|
41
|
+
|
|
42
|
+
# --- Resolve intent directory ---
|
|
43
|
+
|
|
44
|
+
intent_info = bridge_data["intent"]
|
|
45
|
+
exit 0 unless intent_info
|
|
46
|
+
|
|
47
|
+
store = intent_info["store"]
|
|
48
|
+
dir = intent_info["dir"]
|
|
49
|
+
intent_dir = "#{store}/#{dir}"
|
|
50
|
+
|
|
51
|
+
# Normalize both paths for comparison
|
|
52
|
+
intent_dir_abs = File.expand_path(intent_dir)
|
|
53
|
+
file_path_abs = File.expand_path(file_path)
|
|
54
|
+
|
|
55
|
+
# Not inside intent dir = not our business
|
|
56
|
+
exit 0 unless file_path_abs.start_with?("#{intent_dir_abs}/")
|
|
57
|
+
|
|
58
|
+
# --- Determine if this is a stage-transition file ---
|
|
59
|
+
|
|
60
|
+
relative = file_path_abs.sub("#{intent_dir_abs}/", "")
|
|
61
|
+
basename = File.basename(file_path)
|
|
62
|
+
|
|
63
|
+
stage_files = %w[spec.md plan.md checklist.md outcome.md]
|
|
64
|
+
is_stage_file = stage_files.include?(basename)
|
|
65
|
+
is_action_file = relative.start_with?("actions/")
|
|
66
|
+
|
|
67
|
+
if is_stage_file || is_action_file
|
|
68
|
+
# Run gate check
|
|
69
|
+
error = Bridge.check_gate(intent_dir_abs, file_path_abs)
|
|
70
|
+
|
|
71
|
+
if error
|
|
72
|
+
# Gate violation — block the write
|
|
73
|
+
bridge_data["build"]["gate_failures"] = (bridge_data["build"]["gate_failures"] || 0) + 1
|
|
74
|
+
Bridge.write(session, bridge_data)
|
|
75
|
+
|
|
76
|
+
payload = {
|
|
77
|
+
"decision" => "block",
|
|
78
|
+
"reason" => "PLASTIC GATE — #{error}"
|
|
79
|
+
}
|
|
80
|
+
puts JSON.generate(payload)
|
|
81
|
+
exit 2
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Gate passed — update bridge with new stage
|
|
85
|
+
old_stage = bridge_data["build"]["stage"]
|
|
86
|
+
new_stage = Bridge.derive_stage(intent_dir_abs)
|
|
87
|
+
new_missing = Bridge.missing_for_stage(new_stage) - Bridge.has_files(intent_dir_abs)
|
|
88
|
+
|
|
89
|
+
bridge_data["build"]["stage"] = new_stage
|
|
90
|
+
bridge_data["build"]["has"] = Bridge.has_files(intent_dir_abs)
|
|
91
|
+
bridge_data["build"]["missing"] = new_missing
|
|
92
|
+
bridge_data["build"]["gate_failures"] = 0
|
|
93
|
+
bridge_data["build"]["last_activity"] = Time.now.utc.iso8601
|
|
94
|
+
|
|
95
|
+
if old_stage != new_stage
|
|
96
|
+
bridge_data["observe"]["last_transition"] = "#{old_stage} → #{new_stage}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
Bridge.write(session, bridge_data)
|
|
100
|
+
|
|
101
|
+
# Build transition context
|
|
102
|
+
stage_labels = { "what" => "What", "why" => "Why", "how" => "How", "exec" => "Exec", "done" => "Done" }
|
|
103
|
+
next_hints = {
|
|
104
|
+
"why" => "write spec.md",
|
|
105
|
+
"how" => "Why complete. Invoke plastic:auto to deliver autonomously, or write plan.md manually.",
|
|
106
|
+
"exec" => "How complete. Invoke plastic:auto or plastic:executing-plan to execute, or work through the checklist manually.",
|
|
107
|
+
"done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic:auto or do it manually."
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
context_parts = ["PLASTIC"]
|
|
111
|
+
if old_stage != new_stage
|
|
112
|
+
context_parts[0] = "PLASTIC — Stage transition: #{stage_labels[old_stage] || old_stage} → #{stage_labels[new_stage] || new_stage}."
|
|
113
|
+
else
|
|
114
|
+
context_parts[0] = "PLASTIC — #{basename} written (stage: #{stage_labels[new_stage] || new_stage})."
|
|
115
|
+
end
|
|
116
|
+
context_parts << "#{basename} written." if old_stage != new_stage
|
|
117
|
+
if new_missing.any?
|
|
118
|
+
context_parts << "Next: #{new_missing.join(", ")}"
|
|
119
|
+
elsif next_hints[new_stage]
|
|
120
|
+
context_parts << "Next: #{next_hints[new_stage]}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
payload = {
|
|
124
|
+
"hookSpecificOutput" => {
|
|
125
|
+
"hookEventName" => "PostToolUse",
|
|
126
|
+
"additionalContext" => context_parts.join(" ")
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
puts JSON.generate(payload)
|
|
130
|
+
exit 0
|
|
131
|
+
else
|
|
132
|
+
# Regular file in intent dir — just update last_activity
|
|
133
|
+
bridge_data["build"]["last_activity"] = Time.now.utc.iso8601
|
|
134
|
+
Bridge.write(session, bridge_data)
|
|
135
|
+
exit 0
|
|
136
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# Usage: hook-session-start <index_path> <store_root> <mode> [plugin_root]
|
|
4
|
+
# Outputs hook JSON for the session-start hook. Exits silently if nothing to show.
|
|
5
|
+
|
|
6
|
+
require "json"
|
|
7
|
+
require "date"
|
|
8
|
+
require "yaml"
|
|
9
|
+
require_relative "lib/bridge"
|
|
10
|
+
|
|
11
|
+
index_path, store_root, mode, plugin_root = ARGV
|
|
12
|
+
exit 0 unless index_path && store_root && mode
|
|
13
|
+
|
|
14
|
+
# --- Parse INDEX.md ---
|
|
15
|
+
|
|
16
|
+
lines = File.readlines(index_path)
|
|
17
|
+
active = []
|
|
18
|
+
future = []
|
|
19
|
+
section = nil
|
|
20
|
+
|
|
21
|
+
lines.each do |line|
|
|
22
|
+
if line.start_with?("## Active")
|
|
23
|
+
section = :active
|
|
24
|
+
next
|
|
25
|
+
elsif line.start_with?("## Future")
|
|
26
|
+
section = :future
|
|
27
|
+
next
|
|
28
|
+
elsif line.start_with?("## ")
|
|
29
|
+
section = nil
|
|
30
|
+
next
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
next unless section && line.strip.start_with?("- [")
|
|
34
|
+
|
|
35
|
+
if section == :active
|
|
36
|
+
active << line.strip
|
|
37
|
+
elsif section == :future
|
|
38
|
+
future << line.strip
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# --- Derive bridge for active intent ---
|
|
43
|
+
|
|
44
|
+
bridge_data = nil
|
|
45
|
+
if active.length == 1 && active.first =~ /store\/([\w-]+)\//
|
|
46
|
+
dir_name = $1
|
|
47
|
+
intent_dir = "#{store_root}/store/#{dir_name}"
|
|
48
|
+
session = ENV["CLAUDE_SESSION_ID"] || Process.pid.to_s
|
|
49
|
+
intent_id = dir_name.split("--").first
|
|
50
|
+
intent_name = active.first[/\[([^\]]+)\]/, 1] || "unknown"
|
|
51
|
+
bridge_data = Bridge.derive(session, intent_id: intent_id, intent_dir: intent_dir, store: store_root, name: intent_name)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# --- Detect stale future intents ---
|
|
55
|
+
|
|
56
|
+
stale = []
|
|
57
|
+
read_config = if plugin_root && !plugin_root.empty?
|
|
58
|
+
"#{plugin_root}/scripts/read-config"
|
|
59
|
+
else
|
|
60
|
+
File.expand_path("~/.plastic/scripts/read-config")
|
|
61
|
+
end
|
|
62
|
+
stale_days = `"#{read_config}" stale_threshold_days`.strip.to_i
|
|
63
|
+
stale_days = 3 if stale_days == 0
|
|
64
|
+
|
|
65
|
+
future.each do |f|
|
|
66
|
+
if f =~ /store\/([\w-]+)\//
|
|
67
|
+
dir_name = $1
|
|
68
|
+
intent_file = "#{store_root}/store/#{dir_name}/#{dir_name}.md"
|
|
69
|
+
next unless File.exist?(intent_file)
|
|
70
|
+
|
|
71
|
+
content = File.read(intent_file)
|
|
72
|
+
if content =~ /^created:\s*['"]?(\d{4}-\d{2}-\d{2})/
|
|
73
|
+
age = (Date.today - Date.parse($1)).to_i
|
|
74
|
+
if age >= stale_days
|
|
75
|
+
name = f[/\[([^\]]+)\]/, 1] || "unknown"
|
|
76
|
+
stale << { name: name, age: age, entry: f }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# --- Detect current project ---
|
|
83
|
+
|
|
84
|
+
current_project = nil
|
|
85
|
+
project_active = []
|
|
86
|
+
project_future = []
|
|
87
|
+
|
|
88
|
+
projects_path = "#{store_root}/projects.yml"
|
|
89
|
+
if File.exist?(projects_path)
|
|
90
|
+
projects = YAML.safe_load(File.read(projects_path)) rescue {}
|
|
91
|
+
cwd = Dir.pwd
|
|
92
|
+
(projects["projects"] || {}).each do |slug, info|
|
|
93
|
+
project_path = File.expand_path(info["path"])
|
|
94
|
+
if cwd.start_with?(project_path)
|
|
95
|
+
current_project = { "slug" => slug, "parent" => info["parent"], "path" => project_path }
|
|
96
|
+
project_index = "#{store_root}/projects/#{slug}/INDEX.md"
|
|
97
|
+
if File.exist?(project_index)
|
|
98
|
+
p_section = nil
|
|
99
|
+
File.readlines(project_index).each do |pline|
|
|
100
|
+
if pline.start_with?("## Active")
|
|
101
|
+
p_section = :active
|
|
102
|
+
next
|
|
103
|
+
elsif pline.start_with?("## Future")
|
|
104
|
+
p_section = :future
|
|
105
|
+
next
|
|
106
|
+
elsif pline.start_with?("## ")
|
|
107
|
+
p_section = nil
|
|
108
|
+
next
|
|
109
|
+
end
|
|
110
|
+
next unless p_section && pline.strip.start_with?("- [")
|
|
111
|
+
project_active << pline.strip if p_section == :active
|
|
112
|
+
project_future << pline.strip if p_section == :future
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
break
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# --- Load deprecations ---
|
|
121
|
+
|
|
122
|
+
deprecations = []
|
|
123
|
+
if plugin_root && !plugin_root.empty?
|
|
124
|
+
dep_file = "#{plugin_root}/deprecations.yml"
|
|
125
|
+
if File.exist?(dep_file)
|
|
126
|
+
dep_data = YAML.safe_load(File.read(dep_file)) rescue {}
|
|
127
|
+
deprecations = dep_data["deprecations"] || []
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
dismissed_json = `"#{read_config}" deprecations_dismissed`.strip
|
|
132
|
+
dismissed = begin
|
|
133
|
+
JSON.parse(dismissed_json)
|
|
134
|
+
rescue
|
|
135
|
+
[]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
current_version = nil
|
|
139
|
+
if plugin_root && !plugin_root.empty?
|
|
140
|
+
plugin_json_path = "#{plugin_root}/.claude-plugin/plugin.json"
|
|
141
|
+
if File.exist?(plugin_json_path)
|
|
142
|
+
pj = JSON.parse(File.read(plugin_json_path)) rescue {}
|
|
143
|
+
current_version = pj["version"]
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
active_deprecations = deprecations.select do |dep|
|
|
148
|
+
next true if dep["severity"] == "critical"
|
|
149
|
+
next true if current_version && dep["removal"] == current_version
|
|
150
|
+
!dismissed.include?(dep["id"])
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# --- Early exit if nothing to show ---
|
|
154
|
+
|
|
155
|
+
all_active = active + project_active
|
|
156
|
+
all_future = future + project_future
|
|
157
|
+
exit 0 if all_active.empty? && stale.empty? && all_future.empty? && active_deprecations.empty?
|
|
158
|
+
|
|
159
|
+
# --- Build context ---
|
|
160
|
+
|
|
161
|
+
parts = []
|
|
162
|
+
|
|
163
|
+
if current_project
|
|
164
|
+
parts << "PLASTIC — Global store | Project: #{current_project["slug"]} (#{current_project["path"]})\n"
|
|
165
|
+
else
|
|
166
|
+
parts << "PLASTIC — Global store loaded from ~/.plastic/\n"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
if all_active.any?
|
|
170
|
+
parts << "Active intents:\n"
|
|
171
|
+
all_active.each { |a| parts << a }
|
|
172
|
+
if bridge_data
|
|
173
|
+
stage = bridge_data["build"]["stage"]
|
|
174
|
+
missing = bridge_data["build"]["missing"]
|
|
175
|
+
missing_str = missing.empty? ? "none" : missing.join(", ")
|
|
176
|
+
parts << "Stage: #{stage} | Next: #{missing_str}"
|
|
177
|
+
end
|
|
178
|
+
parts << ""
|
|
179
|
+
else
|
|
180
|
+
parts << "No active intents. #{future.length} future intents available.\n"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
if stale.any?
|
|
184
|
+
parts << "\nStale future intents (untouched for days):\n"
|
|
185
|
+
stale.each do |s|
|
|
186
|
+
parts << "- #{s[:name]} (#{s[:age]} days) — consider: activate, abandon, or defer to agent"
|
|
187
|
+
end
|
|
188
|
+
parts << "\nWhen appropriate, ask the user what to do with stale intents."
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
if active_deprecations.any?
|
|
192
|
+
parts << ""
|
|
193
|
+
active_deprecations.each do |dep|
|
|
194
|
+
severity = dep["severity"] || "info"
|
|
195
|
+
summary = dep["summary"] || dep["id"]
|
|
196
|
+
removal = dep["removal"]
|
|
197
|
+
link = dep["link"]
|
|
198
|
+
steps = dep["migration_steps"] || []
|
|
199
|
+
|
|
200
|
+
if severity == "info"
|
|
201
|
+
line = "i Deprecation: #{summary}. Removed in: #{removal}."
|
|
202
|
+
line += " See: #{link}" if link
|
|
203
|
+
parts << line
|
|
204
|
+
else
|
|
205
|
+
marker = severity == "critical" ? "!! DEPRECATION (critical)" : "! DEPRECATION (warning)"
|
|
206
|
+
parts << "#{marker}: #{summary}"
|
|
207
|
+
if steps.any?
|
|
208
|
+
parts << " Migration steps:"
|
|
209
|
+
steps.each_with_index { |s, i| parts << " #{i + 1}. #{s}" }
|
|
210
|
+
end
|
|
211
|
+
trail = " Removed in: #{removal}"
|
|
212
|
+
trail += " | Details: #{link}" if link
|
|
213
|
+
parts << trail
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
payload = {
|
|
219
|
+
"hookSpecificOutput" => {
|
|
220
|
+
"hookEventName" => "SessionStart",
|
|
221
|
+
"additionalContext" => parts.join("\n")
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
puts JSON.generate(payload)
|