@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,474 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# Plastic installer — runs via npx shim or directly.
|
|
6
|
+
# Usage: ruby scripts/install.rb [--claude] [--codex] [--hermes] [--all] [--uninstall] [--force] [--help]
|
|
7
|
+
|
|
8
|
+
require "json"
|
|
9
|
+
require "yaml"
|
|
10
|
+
require "fileutils"
|
|
11
|
+
require "digest"
|
|
12
|
+
|
|
13
|
+
PACKAGE_ROOT = ENV["PLASTIC_PACKAGE_ROOT"] || File.expand_path("..", __dir__)
|
|
14
|
+
PLASTIC_HOME = File.join(Dir.home, ".plastic")
|
|
15
|
+
VERSION = File.read(File.join(PACKAGE_ROOT, "package.json")).then { |s| JSON.parse(s)["version"] }
|
|
16
|
+
|
|
17
|
+
AGENTS = [
|
|
18
|
+
{ key: "claude", name: "Claude Code", dir: File.join(Dir.home, ".claude"), flag: "--claude" },
|
|
19
|
+
{ key: "codex", name: "Codex CLI", dir: File.join(Dir.home, ".agents"), flag: "--codex" },
|
|
20
|
+
{ key: "hermes", name: "Hermes", dir: File.join(Dir.home, ".hermes"), flag: "--hermes" },
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
def main
|
|
24
|
+
flags = parse_flags(ARGV)
|
|
25
|
+
|
|
26
|
+
if flags[:help]
|
|
27
|
+
show_help
|
|
28
|
+
return
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
puts "\n\u{1f9e0} Plastic v#{VERSION}\n\n"
|
|
32
|
+
|
|
33
|
+
if flags[:uninstall]
|
|
34
|
+
handle_uninstall(flags[:agents].empty? ? ["claude"] : flags[:agents])
|
|
35
|
+
return
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
agents = flags[:agents].empty? ? prompt_agents : flags[:agents]
|
|
39
|
+
|
|
40
|
+
if agents.empty?
|
|
41
|
+
puts "No agents selected. Nothing to do."
|
|
42
|
+
return
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
mode = File.exist?(File.join(PLASTIC_HOME, "INDEX.md")) ? :update : :install
|
|
46
|
+
puts "Mode: #{mode}"
|
|
47
|
+
puts "Agents: #{agents.map { |k| agent_config(k)[:name] }.join(", ")}\n\n"
|
|
48
|
+
|
|
49
|
+
distribute(mode)
|
|
50
|
+
bootstrap if mode == :install
|
|
51
|
+
|
|
52
|
+
results = agents.map { |key| install_for_agent(key, flags[:force]) }
|
|
53
|
+
|
|
54
|
+
puts "\n\u{2014} Results \u{2014}\n\n"
|
|
55
|
+
results.each do |r|
|
|
56
|
+
if r[:success]
|
|
57
|
+
puts " \u{2705} #{r[:agent]}: #{r[:files]} files installed"
|
|
58
|
+
else
|
|
59
|
+
puts " \u{26a0}\u{fe0f} #{r[:agent]}: #{r[:reason]}"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
installed = results.select { |r| r[:success] }
|
|
64
|
+
if installed.any?
|
|
65
|
+
puts "\n\u{2705} Plastic v#{VERSION} #{mode == :update ? "updated" : "installed"}."
|
|
66
|
+
puts " Registered for: #{installed.map { |r| r[:agent] }.join(", ")}"
|
|
67
|
+
puts " Run /clear (or restart your agent) to pick up new conventions.\n\n"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# --- Flag parsing ---
|
|
72
|
+
|
|
73
|
+
def parse_flags(argv)
|
|
74
|
+
flags = { agents: [], force: false, uninstall: false, help: false }
|
|
75
|
+
|
|
76
|
+
argv.each do |arg|
|
|
77
|
+
case arg
|
|
78
|
+
when "--all" then flags[:agents] = AGENTS.map { |a| a[:key] }
|
|
79
|
+
when "--force" then flags[:force] = true
|
|
80
|
+
when "--uninstall" then flags[:uninstall] = true
|
|
81
|
+
when "--help", "-h" then flags[:help] = true
|
|
82
|
+
else
|
|
83
|
+
agent = AGENTS.find { |a| a[:flag] == arg }
|
|
84
|
+
flags[:agents] << agent[:key] if agent
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
flags
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def prompt_agents
|
|
92
|
+
unless $stdin.tty?
|
|
93
|
+
return ["claude"]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
puts "Which agents should Plastic register for?\n\n"
|
|
97
|
+
AGENTS.each_with_index { |a, i| puts " #{i + 1}. #{a[:name]} (#{a[:dir]})" }
|
|
98
|
+
puts " #{AGENTS.size + 1}. All"
|
|
99
|
+
puts
|
|
100
|
+
|
|
101
|
+
print "Select (comma-separated numbers, or Enter for Claude Code): "
|
|
102
|
+
answer = $stdin.gets&.strip || ""
|
|
103
|
+
|
|
104
|
+
return ["claude"] if answer.empty?
|
|
105
|
+
|
|
106
|
+
nums = answer.split(",").map { |n| n.strip.to_i }
|
|
107
|
+
return AGENTS.map { |a| a[:key] } if nums.include?(AGENTS.size + 1)
|
|
108
|
+
|
|
109
|
+
nums.select { |n| n >= 1 && n <= AGENTS.size }.map { |n| AGENTS[n - 1][:key] }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def show_help
|
|
113
|
+
puts <<~HELP
|
|
114
|
+
|
|
115
|
+
plastic - Intent-driven idea development system
|
|
116
|
+
|
|
117
|
+
Usage:
|
|
118
|
+
npx @zalom/plastic@latest [options]
|
|
119
|
+
|
|
120
|
+
Options:
|
|
121
|
+
--claude Install for Claude Code
|
|
122
|
+
--codex Install for Codex CLI
|
|
123
|
+
--hermes Install for Hermes
|
|
124
|
+
--all Install for all supported agents
|
|
125
|
+
--force Overwrite existing files without prompting
|
|
126
|
+
--uninstall Remove Plastic from agent directories
|
|
127
|
+
-h, --help Show this help
|
|
128
|
+
|
|
129
|
+
Examples:
|
|
130
|
+
npx @zalom/plastic@latest Interactive agent selection
|
|
131
|
+
npx @zalom/plastic@latest --claude Install for Claude Code only
|
|
132
|
+
npx @zalom/plastic@latest --all Install for all agents
|
|
133
|
+
npx @zalom/plastic@latest --uninstall Remove from agent directories
|
|
134
|
+
|
|
135
|
+
HELP
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# --- Distribution phase ---
|
|
139
|
+
|
|
140
|
+
def distribute(mode)
|
|
141
|
+
puts " \u{1f4e6} #{mode == :update ? "Updating" : "Installing"} core files to #{PLASTIC_HOME}"
|
|
142
|
+
|
|
143
|
+
FileUtils.mkdir_p(PLASTIC_HOME)
|
|
144
|
+
FileUtils.mkdir_p(File.join(PLASTIC_HOME, "scripts"))
|
|
145
|
+
|
|
146
|
+
core_files = {
|
|
147
|
+
"PLASTIC.md" => "PLASTIC.md",
|
|
148
|
+
"deprecations.yml" => "deprecations.yml",
|
|
149
|
+
"scripts/folgezettel-id" => "scripts/folgezettel-id",
|
|
150
|
+
"scripts/read-config" => "scripts/read-config",
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
core_files.each do |src, dest|
|
|
154
|
+
src_path = File.join(PACKAGE_ROOT, src)
|
|
155
|
+
dest_path = File.join(PLASTIC_HOME, dest)
|
|
156
|
+
FileUtils.cp(src_path, dest_path) if File.exist?(src_path)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
File.write(File.join(PLASTIC_HOME, "VERSION"), "#{VERSION}\n")
|
|
160
|
+
|
|
161
|
+
Dir.glob(File.join(PLASTIC_HOME, "scripts", "*")).each { |f| FileUtils.chmod(0o755, f) }
|
|
162
|
+
|
|
163
|
+
puts " \u{2705} Core files synced (v#{VERSION})"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def bootstrap
|
|
167
|
+
puts " \u{1f331} First install \u{2014} bootstrapping store..."
|
|
168
|
+
|
|
169
|
+
FileUtils.mkdir_p(File.join(PLASTIC_HOME, "store"))
|
|
170
|
+
FileUtils.mkdir_p(File.join(PLASTIC_HOME, "projects"))
|
|
171
|
+
|
|
172
|
+
write_if_missing(File.join(PLASTIC_HOME, "config.yml"), <<~YAML)
|
|
173
|
+
version: 3
|
|
174
|
+
execution_mode: subagent-driven
|
|
175
|
+
stale_threshold_days: 3
|
|
176
|
+
hash_length: 6
|
|
177
|
+
hash_algorithm: sha256-base36
|
|
178
|
+
max_slug_words: 5
|
|
179
|
+
agent:
|
|
180
|
+
type: claude-code
|
|
181
|
+
parallel_mode: agent-teams
|
|
182
|
+
YAML
|
|
183
|
+
|
|
184
|
+
write_if_missing(File.join(PLASTIC_HOME, "projects.yml"), "---\nprojects: {}\n")
|
|
185
|
+
|
|
186
|
+
write_if_missing(File.join(PLASTIC_HOME, "INDEX.md"), <<~MD)
|
|
187
|
+
# Index
|
|
188
|
+
|
|
189
|
+
## Active
|
|
190
|
+
|
|
191
|
+
## Future
|
|
192
|
+
|
|
193
|
+
## Clusters
|
|
194
|
+
|
|
195
|
+
## Abandoned
|
|
196
|
+
|
|
197
|
+
## Completed
|
|
198
|
+
MD
|
|
199
|
+
|
|
200
|
+
write_if_missing(File.join(PLASTIC_HOME, "AGENTS.md"), <<~MD)
|
|
201
|
+
# Plastic \u{2014} Agent Instructions
|
|
202
|
+
|
|
203
|
+
Read `PLASTIC.md` in this directory. It contains all Plastic conventions.
|
|
204
|
+
Follow it exactly. Never modify it \u{2014} it is overwritten on plugin updates.
|
|
205
|
+
|
|
206
|
+
This file (`AGENTS.md`) is where project-specific rules live.
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
MD
|
|
210
|
+
|
|
211
|
+
puts " \u{2705} Store bootstrapped"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# --- Agent adapters ---
|
|
215
|
+
|
|
216
|
+
def install_for_agent(key, force)
|
|
217
|
+
config = agent_config(key)
|
|
218
|
+
return { agent: config[:name], success: false, reason: "Unknown agent" } unless config
|
|
219
|
+
|
|
220
|
+
unless File.directory?(config[:dir])
|
|
221
|
+
return { agent: config[:name], success: false, reason: "#{config[:dir]} not found \u{2014} #{config[:name]} not installed?" }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
case key
|
|
225
|
+
when "claude" then install_claude(config, force)
|
|
226
|
+
when "codex" then install_codex(config, force)
|
|
227
|
+
when "hermes" then install_hermes(config, force)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def install_claude(config, force)
|
|
232
|
+
hooks_dir = File.join(config[:dir], "hooks")
|
|
233
|
+
skills_dir = File.join(config[:dir], "skills", "plastic")
|
|
234
|
+
plastic_dir = File.join(config[:dir], "plastic")
|
|
235
|
+
|
|
236
|
+
FileUtils.mkdir_p(hooks_dir)
|
|
237
|
+
FileUtils.mkdir_p(skills_dir)
|
|
238
|
+
FileUtils.mkdir_p(plastic_dir)
|
|
239
|
+
|
|
240
|
+
installed = []
|
|
241
|
+
|
|
242
|
+
# Copy hooks
|
|
243
|
+
hook_source = File.join(PACKAGE_ROOT, "hooks")
|
|
244
|
+
Dir.glob(File.join(hook_source, "*")).each do |f|
|
|
245
|
+
next unless File.file?(f)
|
|
246
|
+
basename = File.basename(f)
|
|
247
|
+
next if %w[hooks.json run-hook].include?(basename)
|
|
248
|
+
dest_name = basename.start_with?("plastic-") ? basename : "plastic-#{basename}"
|
|
249
|
+
dest = File.join(hooks_dir, dest_name)
|
|
250
|
+
FileUtils.cp(f, dest)
|
|
251
|
+
FileUtils.chmod(0o755, dest)
|
|
252
|
+
installed << dest
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Copy skills recursively
|
|
256
|
+
skills_source = File.join(PACKAGE_ROOT, "skills")
|
|
257
|
+
installed += copy_dir_recursive(skills_source, skills_dir) if File.directory?(skills_source)
|
|
258
|
+
|
|
259
|
+
# Write VERSION
|
|
260
|
+
version_file = File.join(plastic_dir, "VERSION")
|
|
261
|
+
File.write(version_file, "#{VERSION}\n")
|
|
262
|
+
installed << version_file
|
|
263
|
+
|
|
264
|
+
# Merge hooks into settings.json
|
|
265
|
+
settings_path = File.join(config[:dir], "settings.json")
|
|
266
|
+
merge_claude_hooks(settings_path)
|
|
267
|
+
|
|
268
|
+
# Write manifest
|
|
269
|
+
manifest_path = File.join(plastic_dir, "manifest.json")
|
|
270
|
+
write_manifest(installed, manifest_path)
|
|
271
|
+
|
|
272
|
+
{ agent: config[:name], success: true, files: installed.size }
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def install_codex(config, force)
|
|
276
|
+
skills_dir = File.join(config[:dir], "skills", "plastic")
|
|
277
|
+
FileUtils.mkdir_p(skills_dir)
|
|
278
|
+
|
|
279
|
+
installed = []
|
|
280
|
+
skills_source = File.join(PACKAGE_ROOT, "skills")
|
|
281
|
+
installed += copy_dir_recursive(skills_source, skills_dir) if File.directory?(skills_source)
|
|
282
|
+
|
|
283
|
+
manifest_path = File.join(config[:dir], "plastic-manifest.json")
|
|
284
|
+
write_manifest(installed, manifest_path)
|
|
285
|
+
|
|
286
|
+
{ agent: config[:name], success: true, files: installed.size }
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def install_hermes(config, force)
|
|
290
|
+
skills_dir = File.join(config[:dir], "skills", "plastic")
|
|
291
|
+
FileUtils.mkdir_p(skills_dir)
|
|
292
|
+
|
|
293
|
+
installed = []
|
|
294
|
+
skills_source = File.join(PACKAGE_ROOT, "skills")
|
|
295
|
+
installed += copy_dir_recursive(skills_source, skills_dir) if File.directory?(skills_source)
|
|
296
|
+
|
|
297
|
+
manifest_path = File.join(config[:dir], "plastic-manifest.json")
|
|
298
|
+
write_manifest(installed, manifest_path)
|
|
299
|
+
|
|
300
|
+
{ agent: config[:name], success: true, files: installed.size }
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# --- settings.json merge (read-modify-write, never clobber) ---
|
|
304
|
+
|
|
305
|
+
def merge_claude_hooks(settings_path)
|
|
306
|
+
settings = read_json_safe(settings_path) || {}
|
|
307
|
+
return if settings.nil? # unparseable — refuse to modify
|
|
308
|
+
|
|
309
|
+
hooks = settings["hooks"] ||= {}
|
|
310
|
+
hook_dir = File.join(Dir.home, ".claude", "hooks")
|
|
311
|
+
|
|
312
|
+
plastic_hooks = {
|
|
313
|
+
"SessionStart" => [
|
|
314
|
+
{ "type" => "command", "command" => "ruby #{hook_dir}/plastic-session-start", "statusMessage" => "Loading Plastic context..." },
|
|
315
|
+
{ "type" => "command", "command" => "#{hook_dir}/plastic-check-update", "statusMessage" => "" },
|
|
316
|
+
],
|
|
317
|
+
"PreCompact" => [
|
|
318
|
+
{ "type" => "command", "command" => "ruby #{hook_dir}/plastic-savepoint", "statusMessage" => "Saving Plastic intent state..." },
|
|
319
|
+
],
|
|
320
|
+
"PostToolUse" => [
|
|
321
|
+
{ "matcher" => "Write|Edit", "type" => "command", "command" => "#{hook_dir}/plastic-gate-check", "statusMessage" => "Checking lifecycle gates..." },
|
|
322
|
+
],
|
|
323
|
+
"UserPromptSubmit" => [
|
|
324
|
+
{ "type" => "command", "command" => "#{hook_dir}/plastic-continue", "statusMessage" => "Checking for continue..." },
|
|
325
|
+
{ "type" => "command", "command" => "#{hook_dir}/plastic-future-intent-check", "statusMessage" => "Checking future intents..." },
|
|
326
|
+
],
|
|
327
|
+
"statusLine" => [
|
|
328
|
+
{ "type" => "command", "command" => "#{hook_dir}/plastic-statusline" },
|
|
329
|
+
],
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
plastic_hooks.each do |event, entries|
|
|
333
|
+
hooks[event] ||= []
|
|
334
|
+
entries.each do |entry|
|
|
335
|
+
already = hooks[event].any? { |h| h["command"] == entry["command"] }
|
|
336
|
+
hooks[event] << entry unless already
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
write_json_atomic(settings_path, settings)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# --- Uninstall ---
|
|
344
|
+
|
|
345
|
+
def handle_uninstall(agents)
|
|
346
|
+
agents.each do |key|
|
|
347
|
+
config = agent_config(key)
|
|
348
|
+
next unless config
|
|
349
|
+
|
|
350
|
+
result = uninstall_agent(key, config)
|
|
351
|
+
if result[:success]
|
|
352
|
+
puts " \u{2705} #{config[:name]}: uninstalled (#{result[:files]} files removed)"
|
|
353
|
+
else
|
|
354
|
+
puts " \u{26a0}\u{fe0f} #{config[:name]}: #{result[:reason]}"
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
puts "\n Note: ~/.plastic/ (your intent store) is preserved.\n\n"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def uninstall_agent(key, config)
|
|
362
|
+
unless File.directory?(config[:dir])
|
|
363
|
+
return { success: false, reason: "#{config[:dir]} not found" }
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
manifest_path = case key
|
|
367
|
+
when "claude" then File.join(config[:dir], "plastic", "manifest.json")
|
|
368
|
+
else File.join(config[:dir], "plastic-manifest.json")
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
files_removed = 0
|
|
372
|
+
|
|
373
|
+
if File.exist?(manifest_path)
|
|
374
|
+
manifest = JSON.parse(File.read(manifest_path)) rescue {}
|
|
375
|
+
(manifest["files"] || {}).each_key do |f|
|
|
376
|
+
if File.exist?(f)
|
|
377
|
+
File.delete(f)
|
|
378
|
+
files_removed += 1
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
File.delete(manifest_path)
|
|
382
|
+
files_removed += 1
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Clean known directories
|
|
386
|
+
dirs_to_clean = case key
|
|
387
|
+
when "claude" then [File.join(config[:dir], "plastic"), File.join(config[:dir], "skills", "plastic")]
|
|
388
|
+
else [File.join(config[:dir], "skills", "plastic")]
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
dirs_to_clean.each { |d| FileUtils.rm_rf(d) if File.directory?(d) }
|
|
392
|
+
|
|
393
|
+
# Clean hooks from settings.json (Claude Code only)
|
|
394
|
+
if key == "claude"
|
|
395
|
+
settings_path = File.join(config[:dir], "settings.json")
|
|
396
|
+
remove_claude_hooks(settings_path) if File.exist?(settings_path)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
{ success: true, files: files_removed }
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def remove_claude_hooks(settings_path)
|
|
403
|
+
settings = read_json_safe(settings_path)
|
|
404
|
+
return unless settings && settings["hooks"]
|
|
405
|
+
|
|
406
|
+
settings["hooks"].each do |event, entries|
|
|
407
|
+
settings["hooks"][event] = entries.reject { |h| (h["command"] || "").include?("plastic-") }
|
|
408
|
+
end
|
|
409
|
+
settings["hooks"].delete_if { |_, v| v.empty? }
|
|
410
|
+
settings.delete("hooks") if settings["hooks"]&.empty?
|
|
411
|
+
|
|
412
|
+
write_json_atomic(settings_path, settings)
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# --- Utilities ---
|
|
416
|
+
|
|
417
|
+
def agent_config(key)
|
|
418
|
+
AGENTS.find { |a| a[:key] == key }
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def copy_dir_recursive(src, dest)
|
|
422
|
+
files = []
|
|
423
|
+
FileUtils.mkdir_p(dest)
|
|
424
|
+
Dir.entries(src).reject { |e| e.start_with?(".") }.each do |entry|
|
|
425
|
+
src_path = File.join(src, entry)
|
|
426
|
+
dest_path = File.join(dest, entry)
|
|
427
|
+
if File.directory?(src_path)
|
|
428
|
+
files += copy_dir_recursive(src_path, dest_path)
|
|
429
|
+
elsif File.file?(src_path)
|
|
430
|
+
FileUtils.cp(src_path, dest_path)
|
|
431
|
+
files << dest_path
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
files
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def read_json_safe(path)
|
|
438
|
+
return nil unless File.exist?(path)
|
|
439
|
+
JSON.parse(File.read(path))
|
|
440
|
+
rescue JSON::ParserError
|
|
441
|
+
# Try JSONC stripping (remove // comments and trailing commas)
|
|
442
|
+
content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
|
|
443
|
+
JSON.parse(content)
|
|
444
|
+
rescue
|
|
445
|
+
nil
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def write_json_atomic(path, data)
|
|
449
|
+
content = JSON.pretty_generate(data) + "\n"
|
|
450
|
+
tmp = "#{path}.plastic-tmp.#{Process.pid}"
|
|
451
|
+
File.write(tmp, content)
|
|
452
|
+
File.rename(tmp, path)
|
|
453
|
+
rescue => e
|
|
454
|
+
File.delete(tmp) if tmp && File.exist?(tmp)
|
|
455
|
+
raise e
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def write_manifest(files, manifest_path)
|
|
459
|
+
entries = {}
|
|
460
|
+
files.each do |f|
|
|
461
|
+
entries[f] = Digest::SHA256.file(f).hexdigest if File.exist?(f)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
data = { "version" => "1", "created" => Time.now.utc.iso8601, "files" => entries }
|
|
465
|
+
File.write(manifest_path, JSON.pretty_generate(data) + "\n")
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def write_if_missing(path, content)
|
|
469
|
+
File.write(path, content) unless File.exist?(path)
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# --- Run ---
|
|
473
|
+
|
|
474
|
+
main
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "tempfile"
|
|
7
|
+
|
|
8
|
+
module Bridge
|
|
9
|
+
STAGES = %w[what why how exec done].freeze
|
|
10
|
+
|
|
11
|
+
def self.intent_file(intent_dir)
|
|
12
|
+
dir_name = File.basename(intent_dir)
|
|
13
|
+
"#{intent_dir}/#{dir_name}.md"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.path(session)
|
|
17
|
+
"/tmp/plastic-#{session}.json"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.read(session)
|
|
21
|
+
p = path(session)
|
|
22
|
+
return nil unless File.exist?(p)
|
|
23
|
+
JSON.parse(File.read(p))
|
|
24
|
+
rescue JSON::ParserError
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.write(session, data)
|
|
29
|
+
p = path(session)
|
|
30
|
+
# Atomic write: tmp file + rename to prevent partial reads
|
|
31
|
+
tmp = "#{p}.tmp.#{Process.pid}"
|
|
32
|
+
File.write(tmp, JSON.pretty_generate(data.merge("updated_at" => Time.now.utc.iso8601)))
|
|
33
|
+
File.rename(tmp, p)
|
|
34
|
+
rescue => e
|
|
35
|
+
File.delete(tmp) if tmp && File.exist?(tmp)
|
|
36
|
+
raise e
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.derive_stage(intent_dir)
|
|
40
|
+
return "done" if File.exist?("#{intent_dir}/outcome.md")
|
|
41
|
+
if File.exist?("#{intent_dir}/plan.md") &&
|
|
42
|
+
File.directory?("#{intent_dir}/actions") &&
|
|
43
|
+
File.exist?("#{intent_dir}/checklist.md")
|
|
44
|
+
return "exec"
|
|
45
|
+
end
|
|
46
|
+
return "how" if File.exist?("#{intent_dir}/spec.md")
|
|
47
|
+
return "why" if File.exist?(intent_file(intent_dir))
|
|
48
|
+
"what"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.has_files(intent_dir)
|
|
52
|
+
files = []
|
|
53
|
+
ifile = File.basename(intent_file(intent_dir))
|
|
54
|
+
[ifile, "spec.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
|
|
55
|
+
files << f if File.exist?("#{intent_dir}/#{f}")
|
|
56
|
+
end
|
|
57
|
+
files << "actions/" if File.directory?("#{intent_dir}/actions")
|
|
58
|
+
files
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.missing_for_stage(stage, intent_dir = nil)
|
|
62
|
+
ifile = intent_dir ? File.basename(intent_file(intent_dir)) : "intent.md"
|
|
63
|
+
case stage
|
|
64
|
+
when "what" then [ifile]
|
|
65
|
+
when "why" then ["spec.md"]
|
|
66
|
+
when "how" then ["plan.md", "actions/", "checklist.md"]
|
|
67
|
+
when "exec" then ["outcome.md"]
|
|
68
|
+
else []
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.derive(session, intent_id:, intent_dir:, store:, name:)
|
|
73
|
+
stage = derive_stage(intent_dir)
|
|
74
|
+
has = has_files(intent_dir)
|
|
75
|
+
missing = missing_for_stage(stage, intent_dir) - has
|
|
76
|
+
|
|
77
|
+
data = {
|
|
78
|
+
"session" => session,
|
|
79
|
+
"intent" => {
|
|
80
|
+
"id" => intent_id,
|
|
81
|
+
"dir" => intent_dir.sub("#{store}/", ""),
|
|
82
|
+
"store" => store,
|
|
83
|
+
"name" => name
|
|
84
|
+
},
|
|
85
|
+
"build" => {
|
|
86
|
+
"stage" => stage,
|
|
87
|
+
"has" => has,
|
|
88
|
+
"missing" => missing,
|
|
89
|
+
"gate_failures" => 0,
|
|
90
|
+
"last_activity" => Time.now.utc.iso8601
|
|
91
|
+
},
|
|
92
|
+
"observe" => {
|
|
93
|
+
"last_transition" => nil,
|
|
94
|
+
"insights_count" => 0,
|
|
95
|
+
"chain_spawned" => []
|
|
96
|
+
},
|
|
97
|
+
"tokens" => {
|
|
98
|
+
"context_pct" => 0,
|
|
99
|
+
"warning_at" => 80,
|
|
100
|
+
"critical_at" => 90
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
write(session, data)
|
|
105
|
+
data
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Gate check: returns nil if allowed, or an error message string if blocked
|
|
109
|
+
def self.check_gate(intent_dir, file_being_written)
|
|
110
|
+
basename = File.basename(file_being_written)
|
|
111
|
+
|
|
112
|
+
case basename
|
|
113
|
+
when "spec.md"
|
|
114
|
+
ifile = intent_file(intent_dir)
|
|
115
|
+
unless File.exist?(ifile) && File.read(ifile).include?("## Intent")
|
|
116
|
+
return "Cannot start Why — What is incomplete (#{File.basename(ifile)} missing or no ## Intent)"
|
|
117
|
+
end
|
|
118
|
+
when "plan.md"
|
|
119
|
+
unless File.exist?("#{intent_dir}/spec.md")
|
|
120
|
+
return "Cannot start How — Why is incomplete (spec.md missing)"
|
|
121
|
+
end
|
|
122
|
+
when "checklist.md"
|
|
123
|
+
unless File.exist?("#{intent_dir}/plan.md") && File.directory?("#{intent_dir}/actions")
|
|
124
|
+
return "Cannot complete How — plan.md or actions/ missing"
|
|
125
|
+
end
|
|
126
|
+
when "outcome.md"
|
|
127
|
+
checklist = "#{intent_dir}/checklist.md"
|
|
128
|
+
if File.exist?(checklist)
|
|
129
|
+
content = File.read(checklist)
|
|
130
|
+
unchecked = content.scan(/^- \[ \]/).length
|
|
131
|
+
if unchecked > 0
|
|
132
|
+
return "Cannot complete Exec — #{unchecked} unchecked items in checklist.md"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
nil # no gate violation
|
|
138
|
+
end
|
|
139
|
+
end
|