@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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/PLASTIC.md +534 -0
  3. package/README.md +88 -0
  4. package/agents/future-intent-researcher.md +38 -0
  5. package/agents/intent-curator.md +40 -0
  6. package/bin/install.js +29 -0
  7. package/deprecations.yml +23 -0
  8. package/hooks/check-update +42 -0
  9. package/hooks/continue +31 -0
  10. package/hooks/future-intent-check +25 -0
  11. package/hooks/gate-check +10 -0
  12. package/hooks/hooks.json +78 -0
  13. package/hooks/run-hook +7 -0
  14. package/hooks/savepoint +6 -0
  15. package/hooks/session-start +9 -0
  16. package/hooks/statusline +16 -0
  17. package/package.json +43 -0
  18. package/scripts/folgezettel-id +40 -0
  19. package/scripts/hash-intent +29 -0
  20. package/scripts/hook-continue +130 -0
  21. package/scripts/hook-future-intent-check +90 -0
  22. package/scripts/hook-gate-check +136 -0
  23. package/scripts/hook-session-start +224 -0
  24. package/scripts/install.rb +474 -0
  25. package/scripts/lib/bridge.rb +139 -0
  26. package/scripts/migrate-folgezettel +535 -0
  27. package/scripts/migrate-to-global +96 -0
  28. package/scripts/read-config +129 -0
  29. package/skills/auto/SKILL.md +127 -0
  30. package/skills/brainstorming-grill-me/SKILL.md +105 -0
  31. package/skills/continuing/SKILL.md +104 -0
  32. package/skills/creating-intent/SKILL.md +122 -0
  33. package/skills/creating-project/SKILL.md +166 -0
  34. package/skills/executing-plan/SKILL.md +120 -0
  35. package/skills/executing-plan/code-quality-reviewer-prompt.md +32 -0
  36. package/skills/executing-plan/implementer-prompt.md +42 -0
  37. package/skills/executing-plan/spec-reviewer-prompt.md +27 -0
  38. package/skills/install/SKILL.md +134 -0
  39. package/skills/intent-curator/SKILL.md +41 -0
  40. package/skills/linking-intents/SKILL.md +72 -0
  41. package/skills/managing-index/SKILL.md +66 -0
  42. package/skills/managing-index/references/zettelkasten-linking.md +27 -0
  43. package/skills/releasing/SKILL.md +124 -0
  44. package/skills/savepoint/SKILL.md +57 -0
  45. package/skills/uninstall/SKILL.md +48 -0
  46. package/skills/update/SKILL.md +69 -0
  47. package/templates/agents.md +46 -0
  48. package/templates/checklist.md +11 -0
  49. package/templates/config.yml +13 -0
  50. package/templates/index.md +13 -0
  51. package/templates/intent.md +24 -0
  52. package/templates/plan.md +11 -0
  53. package/templates/projects.yml +3 -0
  54. 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)