@zalom/plastic 1.0.0-alpha.1 → 1.0.0-alpha.11

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 (41) hide show
  1. package/PLASTIC.md +106 -475
  2. package/hooks/hooks.json +0 -11
  3. package/hooks/statusline +50 -10
  4. package/package.json +1 -1
  5. package/scripts/doctor.rb +931 -0
  6. package/scripts/hook-session-start +47 -10
  7. package/scripts/install.rb +218 -30
  8. package/scripts/lib/bridge.rb +34 -0
  9. package/skills/_active-intent-gate.md +26 -0
  10. package/skills/auto/SKILL.md +22 -4
  11. package/skills/auto/references/agent-architecture.md +60 -0
  12. package/skills/brainstorming/SKILL.md +143 -0
  13. package/skills/continuing/SKILL.md +4 -0
  14. package/skills/continuing/references/context-management.md +32 -0
  15. package/skills/creating-intent/SKILL.md +5 -0
  16. package/skills/creating-intent/references/lifecycle.md +74 -0
  17. package/skills/creating-intent/references/wikilinks.md +8 -0
  18. package/skills/creating-project/SKILL.md +4 -0
  19. package/skills/creating-project/references/hubs-projects.md +55 -0
  20. package/skills/doctor/SKILL.md +116 -0
  21. package/skills/doctor/references/gates-stuck-detection.md +38 -0
  22. package/skills/doctor/report.md +96 -0
  23. package/skills/evaluating-skills/SKILL.md +140 -0
  24. package/skills/evaluating-skills/assets/eval-template.json +12 -0
  25. package/skills/evaluating-skills/evals/evals.json +75 -0
  26. package/skills/evaluating-skills/references/convention-checks.md +76 -0
  27. package/skills/evaluating-skills/references/eval-methodology.md +154 -0
  28. package/skills/linking-intents/SKILL.md +4 -0
  29. package/skills/linking-intents/references/zettelkasten.md +33 -0
  30. package/skills/managing-index/SKILL.md +4 -0
  31. package/skills/releasing/SKILL.md +92 -17
  32. package/skills/releasing/references/deprecations.md +44 -0
  33. package/skills/research/SKILL.md +114 -0
  34. package/skills/savepoint/SKILL.md +4 -0
  35. package/skills/savepoint/references/context-management.md +32 -0
  36. package/skills/update/SKILL.md +10 -2
  37. package/skills/writing-instructions/SKILL.md +159 -0
  38. package/skills/writing-instructions/references/agentskills-spec.md +135 -0
  39. package/skills/writing-plans/SKILL.md +183 -0
  40. package/templates/agents.md +16 -0
  41. package/templates/project.yml +5 -0
@@ -117,15 +117,23 @@ if File.exist?(projects_path)
117
117
  end
118
118
  end
119
119
 
120
+ # --- Load PLASTIC.md conventions ---
121
+
122
+ plastic_md_path = "#{store_root}/PLASTIC.md"
123
+ plastic_md = File.exist?(plastic_md_path) ? File.read(plastic_md_path).strip : nil
124
+
120
125
  # --- Load deprecations ---
121
126
 
127
+ dep_file = if plugin_root && !plugin_root.empty?
128
+ "#{plugin_root}/deprecations.yml"
129
+ else
130
+ "#{store_root}/deprecations.yml"
131
+ end
132
+
122
133
  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
134
+ if File.exist?(dep_file)
135
+ dep_data = YAML.safe_load(File.read(dep_file)) rescue {}
136
+ deprecations = dep_data["deprecations"] || []
129
137
  end
130
138
 
131
139
  dismissed_json = `"#{read_config}" deprecations_dismissed`.strip
@@ -136,7 +144,10 @@ rescue
136
144
  end
137
145
 
138
146
  current_version = nil
139
- if plugin_root && !plugin_root.empty?
147
+ version_file = "#{store_root}/VERSION"
148
+ if File.exist?(version_file)
149
+ current_version = File.read(version_file).strip
150
+ elsif plugin_root && !plugin_root.empty?
140
151
  plugin_json_path = "#{plugin_root}/.claude-plugin/plugin.json"
141
152
  if File.exist?(plugin_json_path)
142
153
  pj = JSON.parse(File.read(plugin_json_path)) rescue {}
@@ -150,18 +161,40 @@ active_deprecations = deprecations.select do |dep|
150
161
  !dismissed.include?(dep["id"])
151
162
  end
152
163
 
153
- # --- Early exit if nothing to show ---
164
+ # --- Check for available updates (from previous session's check) ---
165
+
166
+ update_notice = nil
167
+ cache_file = "#{store_root}/.cache/update-check.json"
168
+ if File.exist?(cache_file)
169
+ cache = JSON.parse(File.read(cache_file)) rescue {}
170
+ if cache["updateAvailable"]
171
+ update_notice = "Plastic update available: #{cache["current"]} -> #{cache["latest"]} — run /plastic:update"
172
+ end
173
+ end
174
+
175
+ # --- Early exit only if no PLASTIC.md ---
154
176
 
155
177
  all_active = active + project_active
156
178
  all_future = future + project_future
157
- exit 0 if all_active.empty? && stale.empty? && all_future.empty? && active_deprecations.empty?
179
+ exit 0 unless plastic_md
158
180
 
159
181
  # --- Build context ---
160
182
 
161
183
  parts = []
162
184
 
185
+ # Conventions always loaded first
186
+ parts << plastic_md
187
+ parts << "\n---\n"
188
+
163
189
  if current_project
164
- parts << "PLASTIC — Global store | Project: #{current_project["slug"]} (#{current_project["path"]})\n"
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}/"
196
+ end
197
+ parts << banner + "\n"
165
198
  else
166
199
  parts << "PLASTIC — Global store loaded from ~/.plastic/\n"
167
200
  end
@@ -215,6 +248,10 @@ if active_deprecations.any?
215
248
  end
216
249
  end
217
250
 
251
+ if update_notice
252
+ parts.unshift("! #{update_notice}\n")
253
+ end
254
+
218
255
  payload = {
219
256
  "hookSpecificOutput" => {
220
257
  "hookEventName" => "SessionStart",
@@ -141,13 +141,19 @@ def distribute(mode)
141
141
  puts " \u{1f4e6} #{mode == :update ? "Updating" : "Installing"} core files to #{PLASTIC_HOME}"
142
142
 
143
143
  FileUtils.mkdir_p(PLASTIC_HOME)
144
- FileUtils.mkdir_p(File.join(PLASTIC_HOME, "scripts"))
144
+ FileUtils.mkdir_p(File.join(PLASTIC_HOME, "scripts", "lib"))
145
145
 
146
146
  core_files = {
147
147
  "PLASTIC.md" => "PLASTIC.md",
148
148
  "deprecations.yml" => "deprecations.yml",
149
149
  "scripts/folgezettel-id" => "scripts/folgezettel-id",
150
150
  "scripts/read-config" => "scripts/read-config",
151
+ "scripts/hook-session-start" => "scripts/hook-session-start",
152
+ "scripts/hook-continue" => "scripts/hook-continue",
153
+ "scripts/hook-future-intent-check" => "scripts/hook-future-intent-check",
154
+ "scripts/hook-gate-check" => "scripts/hook-gate-check",
155
+ "scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
156
+ "scripts/doctor.rb" => "scripts/doctor.rb",
151
157
  }
152
158
 
153
159
  core_files.each do |src, dest|
@@ -211,6 +217,31 @@ def bootstrap
211
217
  puts " \u{2705} Store bootstrapped"
212
218
  end
213
219
 
220
+ def bootstrap_project_store(slug)
221
+ project_dir = File.join(PLASTIC_HOME, "projects", slug)
222
+ store_dir = File.join(project_dir, "store")
223
+
224
+ FileUtils.mkdir_p(store_dir)
225
+
226
+ template = File.join(PACKAGE_ROOT, "templates", "project.yml")
227
+ dest = File.join(project_dir, "project.yml")
228
+ write_if_missing(dest, File.read(template)) if File.exist?(template)
229
+
230
+ write_if_missing(File.join(project_dir, "INDEX.md"), <<~MD)
231
+ # Index
232
+
233
+ ## Active
234
+
235
+ ## Future
236
+
237
+ ## Clusters
238
+
239
+ ## Abandoned
240
+
241
+ ## Completed
242
+ MD
243
+ end
244
+
214
245
  # --- Agent adapters ---
215
246
 
216
247
  def install_for_agent(key, force)
@@ -239,7 +270,7 @@ def install_claude(config, force)
239
270
 
240
271
  installed = []
241
272
 
242
- # Copy hooks
273
+ # Copy hooks, rewriting script paths for the installed location
243
274
  hook_source = File.join(PACKAGE_ROOT, "hooks")
244
275
  Dir.glob(File.join(hook_source, "*")).each do |f|
245
276
  next unless File.file?(f)
@@ -247,7 +278,9 @@ def install_claude(config, force)
247
278
  next if %w[hooks.json run-hook].include?(basename)
248
279
  dest_name = basename.start_with?("plastic-") ? basename : "plastic-#{basename}"
249
280
  dest = File.join(hooks_dir, dest_name)
250
- FileUtils.cp(f, dest)
281
+ content = File.read(f)
282
+ content = content.gsub('$SCRIPT_DIR/../scripts/', '$HOME/.plastic/scripts/')
283
+ File.write(dest, content)
251
284
  FileUtils.chmod(0o755, dest)
252
285
  installed << dest
253
286
  end
@@ -261,7 +294,10 @@ def install_claude(config, force)
261
294
  File.write(version_file, "#{VERSION}\n")
262
295
  installed << version_file
263
296
 
264
- # Merge hooks into settings.json
297
+ # Sync marketplace plugin so Claude Code discovers skills
298
+ sync_marketplace_plugin(config[:dir])
299
+
300
+ # Merge hooks + enabledPlugins into settings.json
265
301
  settings_path = File.join(config[:dir], "settings.json")
266
302
  merge_claude_hooks(settings_path)
267
303
 
@@ -300,46 +336,167 @@ def install_hermes(config, force)
300
336
  { agent: config[:name], success: true, files: installed.size }
301
337
  end
302
338
 
339
+ # --- Marketplace plugin sync ---
340
+
341
+ def sync_marketplace_plugin(claude_dir)
342
+ marketplace_dir = File.join(claude_dir, "plugins", "marketplaces", "plastic")
343
+ plugin_meta_dir = File.join(marketplace_dir, ".claude-plugin")
344
+
345
+ FileUtils.mkdir_p(plugin_meta_dir)
346
+
347
+ plugin_json = {
348
+ "name" => "plastic",
349
+ "description" => "Intent-driven state management for AI coding sessions. Neuroplasticity for your codebase.",
350
+ "version" => VERSION,
351
+ "author" => { "name" => "Zlatko Alomerovic", "email" => "zlatko.alomerovic@gmail.com" },
352
+ "homepage" => "https://github.com/zalom/plastic",
353
+ "repository" => "https://github.com/zalom/plastic",
354
+ "license" => "MIT",
355
+ "keywords" => %w[intent-driven state-management zettelkasten ai-agent]
356
+ }
357
+ write_json_atomic(File.join(plugin_meta_dir, "plugin.json"), plugin_json)
358
+
359
+ marketplace_json = {
360
+ "name" => "plastic",
361
+ "description" => "Marketplace for Plastic — intent-driven state management",
362
+ "owner" => { "name" => "Zlatko Alomerovic", "email" => "zlatko.alomerovic@gmail.com" },
363
+ "plugins" => [{
364
+ "name" => "plastic",
365
+ "description" => "Intent-driven state management for AI coding sessions",
366
+ "version" => VERSION,
367
+ "source" => "./",
368
+ "author" => { "name" => "Zlatko Alomerovic", "email" => "zlatko.alomerovic@gmail.com" }
369
+ }]
370
+ }
371
+ write_json_atomic(File.join(plugin_meta_dir, "marketplace.json"), marketplace_json)
372
+
373
+ # Sync skills
374
+ skills_source = File.join(PACKAGE_ROOT, "skills")
375
+ skills_dest = File.join(marketplace_dir, "skills")
376
+ if File.directory?(skills_source)
377
+ FileUtils.rm_rf(skills_dest)
378
+ copy_dir_recursive(skills_source, skills_dest)
379
+ end
380
+
381
+ # Sync agents
382
+ agents_source = File.join(PACKAGE_ROOT, "agents")
383
+ agents_dest = File.join(marketplace_dir, "agents")
384
+ if File.directory?(agents_source)
385
+ FileUtils.rm_rf(agents_dest)
386
+ copy_dir_recursive(agents_source, agents_dest)
387
+ end
388
+
389
+ # Write .claude/settings.json (permissions)
390
+ claude_settings_dir = File.join(marketplace_dir, ".claude")
391
+ FileUtils.mkdir_p(claude_settings_dir)
392
+ write_json_atomic(File.join(claude_settings_dir, "settings.json"), {
393
+ "permissions" => {
394
+ "allow" => [
395
+ "Bash(ruby *)",
396
+ "Bash(ls *)",
397
+ "Bash(find *)",
398
+ "Bash(git *)",
399
+ "Bash(mkdir *)",
400
+ "Bash(chmod *)",
401
+ "mcp__serena__*"
402
+ ]
403
+ }
404
+ })
405
+ end
406
+
303
407
  # --- settings.json merge (read-modify-write, never clobber) ---
304
408
 
305
409
  def merge_claude_hooks(settings_path)
306
410
  settings = read_json_safe(settings_path) || {}
307
- return if settings.nil? # unparseable — refuse to modify
411
+ return if settings.nil?
308
412
 
309
413
  hooks = settings["hooks"] ||= {}
310
414
  hook_dir = File.join(Dir.home, ".claude", "hooks")
311
415
 
416
+ purge_stale_plastic_hooks(hooks)
417
+
312
418
  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
- ],
419
+ "SessionStart" => {
420
+ "matcher" => "",
421
+ "hooks" => [
422
+ { "type" => "command", "command" => "#{hook_dir}/plastic-session-start", "statusMessage" => "Loading Plastic context..." },
423
+ { "type" => "command", "command" => "#{hook_dir}/plastic-check-update", "statusMessage" => "" },
424
+ ],
425
+ },
426
+ "PreCompact" => {
427
+ "matcher" => "",
428
+ "hooks" => [
429
+ { "type" => "command", "command" => "#{hook_dir}/plastic-savepoint", "statusMessage" => "Saving Plastic intent state..." },
430
+ ],
431
+ },
432
+ "PostToolUse" => {
433
+ "matcher" => "Write|Edit",
434
+ "hooks" => [
435
+ { "type" => "command", "command" => "#{hook_dir}/plastic-gate-check", "statusMessage" => "Checking lifecycle gates..." },
436
+ ],
437
+ },
438
+ "UserPromptSubmit" => {
439
+ "matcher" => "",
440
+ "hooks" => [
441
+ { "type" => "command", "command" => "#{hook_dir}/plastic-continue", "statusMessage" => "Checking for continue..." },
442
+ { "type" => "command", "command" => "#{hook_dir}/plastic-future-intent-check", "statusMessage" => "Checking future intents..." },
443
+ ],
444
+ },
330
445
  }
331
446
 
332
- plastic_hooks.each do |event, entries|
447
+ plastic_hooks.each do |event, group|
333
448
  hooks[event] ||= []
334
- entries.each do |entry|
335
- already = hooks[event].any? { |h| h["command"] == entry["command"] }
336
- hooks[event] << entry unless already
449
+ existing = hooks[event].find { |g| g.is_a?(Hash) && g["hooks"].is_a?(Array) && g["hooks"].any? { |h| h["command"].to_s.include?("plastic-") } }
450
+
451
+ if existing
452
+ existing["matcher"] = group["matcher"]
453
+ existing["hooks"] = group["hooks"]
454
+ else
455
+ hooks[event] << group
337
456
  end
338
457
  end
339
458
 
459
+ existing_status = settings["statusLine"]
460
+ if existing_status && !existing_status.dig("command").to_s.include?("plastic-")
461
+ cache_dir = File.join(PLASTIC_HOME, ".cache")
462
+ FileUtils.mkdir_p(cache_dir)
463
+ File.write(File.join(cache_dir, "original-statusline.json"), JSON.pretty_generate(existing_status))
464
+ end
465
+
466
+ settings["statusLine"] = { "type" => "command", "command" => "#{hook_dir}/plastic-statusline" }
467
+
468
+ # Register Plastic as an enabled plugin for skill discovery
469
+ plugins = settings["enabledPlugins"] ||= {}
470
+ plugins["plastic@plastic"] = true
471
+
472
+ # Register the marketplace source
473
+ marketplaces = settings["extraKnownMarketplaces"] ||= {}
474
+ marketplaces["plastic"] ||= { "source" => { "source" => "github", "repo" => "zalom/plastic" } }
475
+
340
476
  write_json_atomic(settings_path, settings)
341
477
  end
342
478
 
479
+ def purge_stale_plastic_hooks(hooks)
480
+ plastic_cmd = ->(cmd) { cmd.to_s.include?("plastic-") }
481
+
482
+ hooks.delete("statusLine")
483
+
484
+ hooks.each do |event, groups|
485
+ next unless groups.is_a?(Array)
486
+
487
+ hooks[event] = groups.map do |group|
488
+ if group.is_a?(Hash) && group["hooks"].is_a?(Array)
489
+ group["hooks"].reject! { |h| plastic_cmd.call(h["command"]) }
490
+ group unless group["hooks"].empty?
491
+ elsif group.is_a?(Hash) && group["command"]
492
+ plastic_cmd.call(group["command"]) ? nil : group
493
+ else
494
+ group
495
+ end
496
+ end.compact
497
+ end
498
+ end
499
+
343
500
  # --- Uninstall ---
344
501
 
345
502
  def handle_uninstall(agents)
@@ -384,7 +541,12 @@ def uninstall_agent(key, config)
384
541
 
385
542
  # Clean known directories
386
543
  dirs_to_clean = case key
387
- when "claude" then [File.join(config[:dir], "plastic"), File.join(config[:dir], "skills", "plastic")]
544
+ when "claude"
545
+ [
546
+ File.join(config[:dir], "plastic"),
547
+ File.join(config[:dir], "skills", "plastic"),
548
+ File.join(config[:dir], "plugins", "marketplaces", "plastic"),
549
+ ]
388
550
  else [File.join(config[:dir], "skills", "plastic")]
389
551
  end
390
552
 
@@ -403,11 +565,37 @@ def remove_claude_hooks(settings_path)
403
565
  settings = read_json_safe(settings_path)
404
566
  return unless settings && settings["hooks"]
405
567
 
406
- settings["hooks"].each do |event, entries|
407
- settings["hooks"][event] = entries.reject { |h| (h["command"] || "").include?("plastic-") }
568
+ settings["hooks"].each do |event, groups|
569
+ next unless groups.is_a?(Array)
570
+
571
+ settings["hooks"][event] = groups.map do |group|
572
+ if group.is_a?(Hash) && group["hooks"].is_a?(Array)
573
+ group["hooks"].reject! { |h| h["command"].to_s.include?("plastic-") }
574
+ group unless group["hooks"].empty?
575
+ elsif group.is_a?(Hash) && group["command"]
576
+ group["command"].to_s.include?("plastic-") ? nil : group
577
+ else
578
+ group
579
+ end
580
+ end.compact
408
581
  end
409
- settings["hooks"].delete_if { |_, v| v.empty? }
582
+
583
+ settings["hooks"].delete_if { |_, v| v.is_a?(Array) && v.empty? }
410
584
  settings.delete("hooks") if settings["hooks"]&.empty?
585
+ if settings.dig("statusLine", "command").to_s.include?("plastic-")
586
+ settings.delete("statusLine")
587
+ original_path = File.join(PLASTIC_HOME, ".cache", "original-statusline.json")
588
+ if File.exist?(original_path)
589
+ original = JSON.parse(File.read(original_path)) rescue nil
590
+ settings["statusLine"] = original if original
591
+ end
592
+ end
593
+
594
+ # Remove from enabledPlugins
595
+ if settings["enabledPlugins"]
596
+ settings["enabledPlugins"].delete("plastic@plastic")
597
+ settings.delete("enabledPlugins") if settings["enabledPlugins"].empty?
598
+ end
411
599
 
412
600
  write_json_atomic(settings_path, settings)
413
601
  end
@@ -2,6 +2,7 @@
2
2
  # encoding: UTF-8
3
3
 
4
4
  require "json"
5
+ require "yaml"
5
6
  require "fileutils"
6
7
  require "tempfile"
7
8
 
@@ -136,4 +137,37 @@ module Bridge
136
137
 
137
138
  nil # no gate violation
138
139
  end
140
+
141
+ PROJECT_CONFIG_DEFAULTS = {
142
+ "governing_docs" => ["AGENTS.md"],
143
+ "release" => {
144
+ "on_complete" => "commit",
145
+ },
146
+ }.freeze
147
+
148
+ def self.read_project_config(slug)
149
+ path = File.join(Dir.home, ".plastic", "projects", slug, "project.yml")
150
+ config = if File.exist?(path)
151
+ YAML.safe_load(File.read(path)) || {}
152
+ else
153
+ {}
154
+ end
155
+
156
+ deep_merge(PROJECT_CONFIG_DEFAULTS, config)
157
+ rescue => e
158
+ $stderr.puts "Warning: failed to read project config for #{slug}: #{e.message}"
159
+ PROJECT_CONFIG_DEFAULTS.dup
160
+ end
161
+
162
+ def self.deep_merge(base, overlay)
163
+ result = base.dup
164
+ overlay.each do |key, value|
165
+ if value.is_a?(Hash) && result[key].is_a?(Hash)
166
+ result[key] = deep_merge(result[key], value)
167
+ else
168
+ result[key] = value
169
+ end
170
+ end
171
+ result
172
+ end
139
173
  end
@@ -0,0 +1,26 @@
1
+ ## Active Intent Gate
2
+
3
+ Before proceeding, resolve the active intent:
4
+
5
+ 1. **Detect store:**
6
+ - Read `~/.plastic/projects.yml`
7
+ - Match CWD against registered project paths
8
+ - If match → project store at `~/.plastic/projects/{slug}/store/`
9
+ - If no match → global store at `~/.plastic/store/`
10
+
11
+ 2. **Find active intent:**
12
+ - Read `INDEX.md` from the detected store
13
+ - Look under `## Active` for intent entries
14
+ - If exactly one active intent → use it
15
+ - If multiple active intents → ask user which one
16
+ - If no active intent → refuse: "No active intent. Create one first with /plastic:creating-intent"
17
+
18
+ 3. **Resolve paths:**
19
+ - Intent directory: `{store}/store/{id}--{slug}/`
20
+ - Spec: `{intent_dir}/spec.md`
21
+ - Plan: `{intent_dir}/plan.md`
22
+ - Checklist: `{intent_dir}/checklist.md`
23
+ - Resources: `{intent_dir}/resources/`
24
+ - Outcome: `{intent_dir}/outcome.md`
25
+
26
+ All lifecycle artifacts MUST be written to the intent directory. Never write to `docs/superpowers/specs/`, `docs/superpowers/plans/`, or any other external path.
@@ -111,12 +111,26 @@ During initial project creation, all decisions are non-destructive by definition
111
111
  1. Verify all checklist items are checked
112
112
  2. Write `outcome.md` with detailed results
113
113
  3. Write `## Outcome` summary in the intent file (1-2 sentences)
114
- 4. Review `## Insights` for observations that should spawn future intents. If any:
114
+ 4. **Release (if configured)**
115
+ 1. Detect project — match CWD against paths in `~/.plastic/projects.yml` to find the project slug. If no match, skip to step 5 (default commit-only behavior).
116
+ 2. Read `~/.plastic/projects/{slug}/project.yml`. If the file doesn't exist or has no `release` key, skip to step 5.
117
+ 3. Based on `release.on_complete`:
118
+ - `commit` — git add + commit (same as default, proceed to step 5)
119
+ - `commit_and_push` — git add + commit + push
120
+ - `manual` — skip auto-commit, notify user: "Release configured as manual — commit when ready."
121
+ 4. If `release.verify` is set, run the verify command (e.g. `bundle exec rake test`):
122
+ - **Exit 0 (green):** proceed to sub-step 5
123
+ - **Non-zero (red):** check `release.on_red`:
124
+ - `fix_and_retry` — attempt to fix the failure, re-run verify (max 2 retries)
125
+ - `stop` — write `savepoint.md` with current state, notify user: "Verify failed — savepoint written.", **STOP**
126
+ - `manual` — notify user: "Verify failed: [summary]. Resolve manually."
127
+ 5. If `release.on_green` has items, invoke `plastic:releasing` to handle them (tag, changelog, publish, etc.). Do NOT duplicate release logic — delegate entirely.
128
+ 5. Review `## Insights` for observations that should spawn future intents. If any:
115
129
  - Create them (using `plastic:creating-intent` conventions)
116
130
  - Update `chain` in the current intent's frontmatter
117
- 5. Move intent from `## Active` to `## Completed` in INDEX.md (with today's date)
118
- 6. Auto-commit: `cd <store-root> && git add . && git commit -m "feat: deliver intent <ID> — <name>"`
119
- 7. Notify user: "Intent [ID] — [name] delivered. [1-2 sentence summary]. See outcome.md for details."
131
+ 6. Move intent from `## Active` to `## Completed` in INDEX.md (with today's date)
132
+ 7. Auto-commit: `cd <store-root> && git add . && git commit -m "feat: deliver intent <ID> — <name>"`
133
+ 8. Notify user: "Intent [ID] — [name] delivered. [1-2 sentence summary]. See outcome.md for details."
120
134
 
121
135
  ## Error Handling
122
136
 
@@ -125,3 +139,7 @@ If the agent gets stuck (can't resolve a gap, dependency is missing, tests fail
125
139
  2. Write `savepoint.md` with current state
126
140
  3. Notify user: "Blocked on intent [ID] — [name]: [description]. Savepoint written."
127
141
  4. **STOP.** Do not attempt workarounds that could leave the project in a broken state.
142
+
143
+ ## References
144
+
145
+ - Read `references/agent-architecture.md` for the full agent hierarchy (Main Orchestrator, Project Orchestrators, coordination loop) when dispatching agents or understanding autonomous delivery scope
@@ -0,0 +1,60 @@
1
+ # Agent Architecture
2
+
3
+ ## Main Orchestrator
4
+
5
+ The Main Orchestrator manages the global store (Main Knowledge Base). It:
6
+ - Recognizes, creates, updates, and groups intents
7
+ - Spawns Project Orchestrators for registered projects
8
+ - Receives contributions back from Project Orchestrators
9
+ - Is the only agent that runs in a loop (continuous Build→Observe→Repeat)
10
+
11
+ ## Project Orchestrators
12
+
13
+ Project Orchestrators manage project stores (Project Knowledge Bases). They:
14
+ - Care about intents and execution within their project
15
+ - Spawn teams to develop and execute intents
16
+ - Contribute back to the Main Orchestrator when new intents are born
17
+ that could enrich the Main Knowledge Base
18
+
19
+ Rules:
20
+ - 1 Main Orchestrator : 1 Global Store (`~/.plastic/`)
21
+ - 1 Main Orchestrator : N Project Orchestrators
22
+ - 1 Project Orchestrator : 1 Project Store
23
+ - 1 Agent : 1 Intent (exclusive assignment)
24
+ - 1 Agent : N Sub-agents (for parallel Actions within an intent)
25
+
26
+ ## Two Modes
27
+
28
+ - **Human-driven:** Human chats with Main Orchestrator, creates intents,
29
+ brainstorms, then Main Orchestrator dispatches Project Orchestrators and
30
+ Agents for execution.
31
+ - **Autonomous:** Human gives Main Orchestrator a starting intent with defined
32
+ outcomes. Main Orchestrator runs the full cycle — Agents do the lifecycle
33
+ (What→Why→How→Exec), Main Orchestrator reviews Insights, spawns next intents,
34
+ dispatches again.
35
+
36
+ ## Autonomous Delivery
37
+
38
+ Human owns What and Why for human-initiated intents. Agent assists (research,
39
+ exploration) but human drives until handoff. When Why is complete — or human
40
+ triggers `plastic:auto` — the agent takes over How and Exec autonomously.
41
+
42
+ - **Safe-by-default:** Agent always prefers non-destructive routes (rename vs
43
+ delete, additive migrations, backups before changes). Destructive actions on
44
+ existing projects require human approval unless `--skip-permissions` is set.
45
+ - **One agent per intent.** Agent follows the full W→W→H→E lifecycle.
46
+ - **Notification only on:** finish or hard stop (blocked on destructive action,
47
+ unresolvable error). No progress reports — `## Insights` tracks everything.
48
+ - **Greenfield autonomy:** During initial project creation, all decisions are
49
+ non-destructive (nothing to destroy). Agent has full autonomy for greenfield choices.
50
+ - **Autonomous decisions** are logged in `## Insights` with `(autonomous)` marker.
51
+
52
+ ## Coordinator Loop
53
+
54
+ When "work on Project X":
55
+ 1. Read `projects.yml` → find project path
56
+ 2. Load global config (defaults)
57
+ 3. Load project config (overrides)
58
+ 4. Load global INDEX.md → find hub intents tagged `project-<name>`
59
+ 5. Load project INDEX.md → tactical intents
60
+ 6. Coordinator has full picture, dispatches Agent teams