@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,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