@zalom/plastic 1.0.0-alpha.3 → 1.0.0-alpha.31

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 (87) hide show
  1. package/PLASTIC.md +134 -469
  2. package/README.md +95 -58
  3. package/agents/future-intent-researcher.md +1 -1
  4. package/agents/intent-curator.md +1 -1
  5. package/bin/plastic.js +57 -0
  6. package/bin/test +28 -0
  7. package/deprecations.yml +1 -10
  8. package/hooks/auto-arm +5 -0
  9. package/hooks/bash-gate +3 -0
  10. package/hooks/check-update +12 -8
  11. package/hooks/code-gate +12 -0
  12. package/hooks/gate-check +3 -1
  13. package/hooks/hooks.json +25 -4
  14. package/hooks/statusline +50 -10
  15. package/package.json +2 -2
  16. package/scripts/dashboard.rb +680 -0
  17. package/scripts/doctor.rb +1227 -0
  18. package/scripts/hook-auto-arm +51 -0
  19. package/scripts/hook-bash-gate +41 -0
  20. package/scripts/hook-code-gate +27 -0
  21. package/scripts/hook-continue +15 -114
  22. package/scripts/hook-gate-check +30 -34
  23. package/scripts/hook-session-start +121 -31
  24. package/scripts/install.rb +91 -487
  25. package/scripts/lib/boot_banner.rb +28 -0
  26. package/scripts/lib/bridge.rb +358 -10
  27. package/scripts/lib/installer_core.rb +747 -0
  28. package/scripts/lib/intent_validator.rb +90 -0
  29. package/scripts/lib/qmd_sync.rb +159 -0
  30. package/scripts/lib/store_provisioning.rb +100 -0
  31. package/scripts/migrate-to-global +1 -1
  32. package/scripts/provision-project-store +53 -0
  33. package/scripts/qmd-sync +92 -0
  34. package/scripts/select-update-target +93 -0
  35. package/scripts/uninstall.rb +53 -0
  36. package/scripts/update.rb +164 -0
  37. package/scripts/validate-intent +54 -0
  38. package/scripts/versions.rb +141 -0
  39. package/skills/_active-intent-gate.md +26 -0
  40. package/skills/add-project-store/SKILL.md +54 -0
  41. package/skills/auto/SKILL.md +69 -9
  42. package/skills/auto/evals/evals.json +92 -0
  43. package/skills/auto/references/agent-architecture.md +60 -0
  44. package/skills/brainstorming/SKILL.md +143 -0
  45. package/skills/brainstorming-grill-me/SKILL.md +5 -5
  46. package/skills/continuing/SKILL.md +92 -82
  47. package/skills/continuing/evals/evals.json +136 -0
  48. package/skills/continuing/references/context-management.md +32 -0
  49. package/skills/creating-intent/SKILL.md +38 -9
  50. package/skills/creating-intent/references/lifecycle.md +74 -0
  51. package/skills/creating-intent/references/wikilinks.md +8 -0
  52. package/skills/creating-project/SKILL.md +40 -8
  53. package/skills/creating-project/references/hubs-projects.md +55 -0
  54. package/skills/dashboard/SKILL.md +121 -0
  55. package/skills/dashboard/templates/dashboard-global.md +31 -0
  56. package/skills/dashboard/templates/dashboard-project.md +40 -0
  57. package/skills/doctor/SKILL.md +159 -0
  58. package/skills/doctor/references/gates-stuck-detection.md +38 -0
  59. package/skills/doctor/report.md +100 -0
  60. package/skills/evaluating-skills/SKILL.md +140 -0
  61. package/skills/evaluating-skills/assets/eval-template.json +12 -0
  62. package/skills/evaluating-skills/evals/evals.json +75 -0
  63. package/skills/evaluating-skills/references/convention-checks.md +76 -0
  64. package/skills/evaluating-skills/references/eval-methodology.md +154 -0
  65. package/skills/executing-plan/SKILL.md +5 -3
  66. package/skills/install/SKILL.md +69 -8
  67. package/skills/intent-curator/SKILL.md +3 -3
  68. package/skills/linking-intents/SKILL.md +5 -1
  69. package/skills/linking-intents/references/zettelkasten.md +33 -0
  70. package/skills/managing-index/SKILL.md +5 -1
  71. package/skills/releasing/SKILL.md +119 -18
  72. package/skills/releasing/references/deprecations.md +60 -0
  73. package/skills/research/SKILL.md +114 -0
  74. package/skills/savepoint/SKILL.md +46 -37
  75. package/skills/savepoint/references/context-management.md +32 -0
  76. package/skills/uninstall/SKILL.md +39 -28
  77. package/skills/update/SKILL.md +41 -36
  78. package/skills/versions/SKILL.md +65 -0
  79. package/skills/writing-instructions/SKILL.md +159 -0
  80. package/skills/writing-instructions/references/agentskills-spec.md +135 -0
  81. package/skills/writing-plans/SKILL.md +183 -0
  82. package/templates/agents.md +16 -0
  83. package/templates/outcome.md +13 -0
  84. package/templates/project.yml +5 -0
  85. package/templates/savepoint.md +14 -13
  86. package/templates/spec.md +25 -0
  87. package/bin/install.js +0 -29
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # Usage: hook-auto-arm <message>
6
+ # UserPromptSubmit arming (intent 27). Two jobs, both additive context — never blocks:
7
+ # 1. Detect auto-trigger phrases -> steer the agent to invoke plastic-auto.
8
+ # 2. If auto mode is armed and the active intent hasn't reached How -> remind the agent
9
+ # that project-code edits are gate-blocked until plan.md + checklist.md exist.
10
+ # Outputs UserPromptSubmit hookSpecificOutput JSON, or exits silently.
11
+
12
+ require "json"
13
+ require_relative "lib/bridge"
14
+
15
+ message = (ARGV[0] || "").downcase
16
+ parts = []
17
+
18
+ # --- 1. Auto-trigger steering ---
19
+ AUTO_TRIGGERS = ["take it from here", "deliver this", "/plastic-auto", "auto mode"].freeze
20
+ # bare "auto" only as a standalone word to avoid matching "automatic", "automation", etc.
21
+ triggered = AUTO_TRIGGERS.any? { |p| message.include?(p) } || message.match?(/\bauto\b/)
22
+ if triggered
23
+ parts << "Auto-mode request detected. Invoke the plastic-auto skill to deliver the " \
24
+ "active intent autonomously — it arms the lifecycle gate so project code " \
25
+ "cannot be edited before plan.md + checklist.md exist."
26
+ end
27
+
28
+ # --- 2. Standing gate reminder when armed and pre-How ---
29
+ bridge_data = Bridge.discover_bridge(session: ENV["CLAUDE_SESSION_ID"], cwd: Dir.pwd)
30
+ if bridge_data && bridge_data.dig("build", "auto") == true
31
+ intent = bridge_data["intent"] || {}
32
+ store = intent["store"]; dir = intent["dir"]
33
+ if store && dir
34
+ intent_dir = File.expand_path("#{store}/#{dir}")
35
+ reached_how = File.exist?("#{intent_dir}/plan.md") && File.exist?("#{intent_dir}/checklist.md")
36
+ unless reached_how
37
+ parts << "Auto mode armed on intent #{intent["id"]} — produce spec -> plan -> " \
38
+ "checklist before editing project code. Code edits are gate-blocked until then."
39
+ end
40
+ end
41
+ end
42
+
43
+ exit 0 if parts.empty?
44
+
45
+ payload = {
46
+ "hookSpecificOutput" => {
47
+ "hookEventName" => "UserPromptSubmit",
48
+ "additionalContext" => parts.join("\n")
49
+ }
50
+ }
51
+ puts JSON.generate(payload)
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # PreToolUse gate for Bash (intent 27a): close the Bash-edit bypass. When auto
6
+ # mode is armed and the active intent has not reached How (plan.md + checklist.md),
7
+ # block Bash commands that WRITE project code outside the store.
8
+ #
9
+ # Reads the Claude Code PreToolUse payload as JSON on STDIN:
10
+ # { "tool_input": { "command": "..." }, "cwd": "..." }
11
+ # Empty / unparseable / no command => exit 0 (allow). Conservatism is the prime
12
+ # directive: when a command's write intent is ambiguous, ALLOW.
13
+ #
14
+ # Exit 0 = allow. Exit 2 = block (reason on stderr, shown to the agent).
15
+
16
+ require "json"
17
+ require_relative "lib/bridge"
18
+
19
+ raw = $stdin.read rescue nil
20
+ exit 0 if raw.nil? || raw.strip.empty?
21
+
22
+ payload = JSON.parse(raw) rescue nil
23
+ exit 0 unless payload.is_a?(Hash)
24
+
25
+ command = payload.dig("tool_input", "command") || payload.dig("tool_params", "command")
26
+ exit 0 if command.nil? || command.to_s.strip.empty?
27
+
28
+ cwd = payload["cwd"]
29
+ cwd = Dir.pwd if cwd.nil? || cwd.to_s.empty?
30
+
31
+ # --- Load bridge (shared resolution; stdin session_id -> env -> /tmp scan) ---
32
+ session = payload["session_id"]
33
+ session = ENV["CLAUDE_SESSION_ID"] if session.nil? || session.to_s.empty?
34
+ bridge_data = Bridge.discover_bridge(session: session, cwd: cwd)
35
+ exit 0 unless bridge_data
36
+
37
+ reason = Bridge.bash_gate_decision(bridge_data, command, cwd: cwd)
38
+ exit 0 unless reason
39
+
40
+ $stderr.puts "PLASTIC GATE — #{reason}"
41
+ exit 2
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # Usage: hook-code-gate <file_path>
6
+ # PreToolUse gate (intent 27): when auto mode is armed and the active intent has not
7
+ # reached How (plan.md + checklist.md), block edits to project code outside the store.
8
+ #
9
+ # Exit 0 = allow. Exit 2 = block (reason on stderr, shown to the agent).
10
+ # No bridge / auto not armed / How reached / path under ~/.plastic or the intent dir = allow.
11
+
12
+ require_relative "lib/bridge"
13
+
14
+ file_path = ARGV[0]
15
+ exit 0 unless file_path && !file_path.empty?
16
+
17
+ session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_SESSION_ID"]
18
+
19
+ # --- Load bridge (shared resolution; stdin session_id -> env -> /tmp scan) ---
20
+ bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
21
+ exit 0 unless bridge_data
22
+
23
+ reason = Bridge.code_gate_decision(bridge_data, file_path)
24
+ exit 0 unless reason
25
+
26
+ $stderr.puts "PLASTIC GATE — #{reason}"
27
+ exit 2
@@ -1,130 +1,31 @@
1
1
  #!/usr/bin/env ruby
2
2
  # encoding: UTF-8
3
3
  # Usage: hook-continue <index_path> <store_root> <mode>
4
- # Outputs hook JSON for the continue hook. Exits silently if nothing to show.
4
+ # Renders the dashboard `continue` cockpit as UserPromptSubmit additionalContext.
5
+ # Exits silently (no output) if the dashboard produces nothing — the bash caller
6
+ # then falls back to a minimal notice.
5
7
 
6
8
  require "json"
7
- require "date"
8
- require "yaml"
9
+ require "open3"
9
10
 
10
- index_path, store_root, mode = ARGV
11
- exit 0 unless index_path && store_root && mode
11
+ index_path, _store_root, _mode = ARGV
12
+ exit 0 unless index_path && File.exist?(index_path)
12
13
 
13
- # --- Parse INDEX.md ---
14
+ dashboard = File.expand_path("dashboard.rb", __dir__)
15
+ exit 0 unless File.exist?(dashboard)
14
16
 
15
- lines = File.readlines(index_path)
16
- active = []
17
- future = []
18
- section = nil
17
+ cockpit, _err, status = Open3.capture3("ruby", dashboard, "continue")
18
+ exit 0 unless status.success? && !cockpit.strip.empty?
19
19
 
20
- lines.each do |line|
21
- if line.start_with?("## Active")
22
- section = :active
23
- next
24
- elsif line.start_with?("## Future")
25
- section = :future
26
- next
27
- elsif line.start_with?("## ")
28
- section = nil
29
- next
30
- end
31
-
32
- next unless section && line.strip.start_with?("- [")
33
-
34
- if section == :active
35
- active << line.strip
36
- elsif section == :future
37
- future << line.strip
38
- end
39
- end
40
-
41
- # --- Detect stale future intents ---
42
-
43
- stale = []
44
- stale_days = 3
45
- config_path = "#{store_root}/config.yml"
46
- if File.exist?(config_path)
47
- config = YAML.safe_load(File.read(config_path)) rescue {}
48
- stale_days = config["stale_threshold_days"] || 3
49
- end
50
-
51
- future.each do |f|
52
- if f =~ /store\/([\w-]+)\//
53
- dir_name = $1
54
- intent_file = "#{store_root}/store/#{dir_name}/#{dir_name}.md"
55
- next unless File.exist?(intent_file)
56
-
57
- content = File.read(intent_file)
58
- if content =~ /^created:\s*['"]?(\d{4}-\d{2}-\d{2})/
59
- age = (Date.today - Date.parse($1)).to_i
60
- if age >= stale_days
61
- name = f[/\[([^\]]+)\]/, 1] || "unknown"
62
- stale << { name: name, age: age, entry: f }
63
- end
64
- end
65
- end
66
- end
67
-
68
- # --- Detect current project ---
69
-
70
- current_project = nil
71
- if mode == "global"
72
- projects_path = "#{store_root}/projects.yml"
73
- if File.exist?(projects_path)
74
- projects = YAML.safe_load(File.read(projects_path)) rescue {}
75
- cwd = Dir.pwd
76
- (projects["projects"] || {}).each do |slug, info|
77
- project_path = File.expand_path(info["path"])
78
- if cwd.start_with?(project_path)
79
- current_project = { "slug" => slug, "parent" => info["parent"], "path" => project_path }
80
- break
81
- end
82
- end
83
- end
84
- end
85
-
86
- # --- Build context ---
87
-
88
- parts = []
89
- parts << "PLASTIC CONTINUE — The user wants to resume work.\n"
90
-
91
- if mode == "global" && current_project
92
- parts << "Project: #{current_project["slug"]} (#{current_project["path"]})\n"
93
- parts << "Governing intent: #{current_project["parent"]}\n" if current_project["parent"]
94
- end
95
-
96
- if active.any?
97
- parts << "## Active Intents (work on these first)\n"
98
- active.each { |a| parts << a }
99
- parts << ""
100
- else
101
- parts << "## No Active Intents\n"
102
- end
103
-
104
- if future.any?
105
- parts << "## Future Intents (offer as next work)\n"
106
- future.each { |f| parts << f }
107
- parts << ""
108
- end
109
-
110
- if stale.any?
111
- parts << "## Stale Future Intents\n"
112
- parts << "These future intents have not been actioned. Ask the user what to do with each:\n"
113
- stale.each do |s|
114
- parts << "- #{s[:name]} (#{s[:age]} days old) — options: activate, abandon, or defer to agent (implement / research / ideate)"
115
- end
116
- parts << ""
117
- end
118
-
119
- parts << "Follow the plastic:continuing skill workflow.\n"
120
- parts << "1. If active intents exist: read their state and resume\n"
121
- parts << "2. If no active intents: present future intents as options\n"
122
- parts << "3. If stale intents exist: surface them and ask user to decide"
20
+ context = cockpit.rstrip +
21
+ "\n\nFollow the plastic-continuing skill workflow to resume: " \
22
+ "if active intents exist, read their state and resume; otherwise offer the " \
23
+ "Value×Effort matrix items above, surfacing any stale (Nd) / ⚑ triage intents."
123
24
 
124
25
  payload = {
125
26
  "hookSpecificOutput" => {
126
27
  "hookEventName" => "UserPromptSubmit",
127
- "additionalContext" => parts.join("\n")
28
+ "additionalContext" => context
128
29
  }
129
30
  }
130
31
  puts JSON.generate(payload)
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
2
  # encoding: UTF-8
3
- # Usage: hook-gate-check <file_path>
3
+ # Usage: hook-gate-check <file_path> [session_id]
4
4
  # PostToolUse gate enforcement for Plastic intent lifecycle.
5
5
  # Checks if a written file triggers a stage-transition gate.
6
6
  # Exit 0 = allow (optionally with transition context)
@@ -12,32 +12,28 @@ require_relative "lib/bridge"
12
12
  file_path = ARGV[0]
13
13
  exit 0 unless file_path && !file_path.empty?
14
14
 
15
- # --- Find bridge file ---
16
-
17
- session = ENV["CLAUDE_SESSION_ID"]
18
- bridge_path = nil
19
- bridge_data = nil
15
+ session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_SESSION_ID"]
20
16
 
21
- if session && !session.empty?
22
- bridge_path = Bridge.path(session)
23
- bridge_data = Bridge.read(session) if File.exist?(bridge_path)
24
- end
17
+ file_path_abs = File.expand_path(file_path)
25
18
 
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
19
+ # --- Decoupled savepoint ledger (intent 34, hardened in intent 52) ---
20
+ # The savepoint is derived purely from the file path's intent directory, BEFORE
21
+ # any bridge resolution. A missing bridge must never skip the savepoint.
22
+ intent_dir_abs = Bridge.intent_dir_for(file_path_abs)
23
+ if intent_dir_abs
24
+ begin
25
+ Bridge.append_savepoint(intent_dir_abs, file_path_abs)
26
+ rescue StandardError
27
+ # ignore — rebuildable from disk
36
28
  end
37
29
  end
38
30
 
39
- # No bridge = no active intent = no enforcement
40
- exit 0 unless bridge_data && session
31
+ # --- Find bridge file ---
32
+ bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
33
+
34
+ # No bridge = no active intent = no stage enforcement. Savepoint already handled.
35
+ exit 0 unless bridge_data
36
+ session = bridge_data["session"]
41
37
 
42
38
  # --- Resolve intent directory ---
43
39
 
@@ -49,15 +45,14 @@ dir = intent_info["dir"]
49
45
  intent_dir = "#{store}/#{dir}"
50
46
 
51
47
  # Normalize both paths for comparison
52
- intent_dir_abs = File.expand_path(intent_dir)
53
- file_path_abs = File.expand_path(file_path)
48
+ bridge_intent_dir_abs = File.expand_path(intent_dir)
54
49
 
55
50
  # Not inside intent dir = not our business
56
- exit 0 unless file_path_abs.start_with?("#{intent_dir_abs}/")
51
+ exit 0 unless file_path_abs.start_with?("#{bridge_intent_dir_abs}/")
57
52
 
58
53
  # --- Determine if this is a stage-transition file ---
59
54
 
60
- relative = file_path_abs.sub("#{intent_dir_abs}/", "")
55
+ relative = file_path_abs.sub("#{bridge_intent_dir_abs}/", "")
61
56
  basename = File.basename(file_path)
62
57
 
63
58
  stage_files = %w[spec.md plan.md checklist.md outcome.md]
@@ -66,7 +61,7 @@ is_action_file = relative.start_with?("actions/")
66
61
 
67
62
  if is_stage_file || is_action_file
68
63
  # Run gate check
69
- error = Bridge.check_gate(intent_dir_abs, file_path_abs)
64
+ error = Bridge.check_gate(bridge_intent_dir_abs, file_path_abs)
70
65
 
71
66
  if error
72
67
  # Gate violation — block the write
@@ -83,11 +78,11 @@ if is_stage_file || is_action_file
83
78
 
84
79
  # Gate passed — update bridge with new stage
85
80
  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)
81
+ new_stage = Bridge.derive_stage(bridge_intent_dir_abs)
82
+ new_missing = Bridge.missing_for_stage(new_stage) - Bridge.has_files(bridge_intent_dir_abs)
88
83
 
89
84
  bridge_data["build"]["stage"] = new_stage
90
- bridge_data["build"]["has"] = Bridge.has_files(intent_dir_abs)
85
+ bridge_data["build"]["has"] = Bridge.has_files(bridge_intent_dir_abs)
91
86
  bridge_data["build"]["missing"] = new_missing
92
87
  bridge_data["build"]["gate_failures"] = 0
93
88
  bridge_data["build"]["last_activity"] = Time.now.utc.iso8601
@@ -102,9 +97,9 @@ if is_stage_file || is_action_file
102
97
  stage_labels = { "what" => "What", "why" => "Why", "how" => "How", "exec" => "Exec", "done" => "Done" }
103
98
  next_hints = {
104
99
  "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."
100
+ "how" => "Why complete. Invoke plastic-auto to deliver autonomously, or write plan.md manually.",
101
+ "exec" => "How complete. Invoke plastic-auto or plastic-executing-plan to execute, or work through the checklist manually.",
102
+ "done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic-auto or do it manually."
108
103
  }
109
104
 
110
105
  context_parts = ["PLASTIC"]
@@ -129,7 +124,8 @@ if is_stage_file || is_action_file
129
124
  puts JSON.generate(payload)
130
125
  exit 0
131
126
  else
132
- # Regular file in intent dir — just update last_activity
127
+ # Regular file in intent dir — just update last_activity. The savepoint for
128
+ # the What milestone was already handled above by the decoupled ledger write.
133
129
  bridge_data["build"]["last_activity"] = Time.now.utc.iso8601
134
130
  Bridge.write(session, bridge_data)
135
131
  exit 0
@@ -7,6 +7,9 @@ require "json"
7
7
  require "date"
8
8
  require "yaml"
9
9
  require_relative "lib/bridge"
10
+ require_relative "lib/boot_banner"
11
+ require_relative "lib/qmd_sync"
12
+ require_relative "doctor"
10
13
 
11
14
  index_path, store_root, mode, plugin_root = ARGV
12
15
  exit 0 unless index_path && store_root && mode
@@ -117,15 +120,23 @@ if File.exist?(projects_path)
117
120
  end
118
121
  end
119
122
 
123
+ # --- Load PLASTIC.md conventions ---
124
+
125
+ plastic_md_path = "#{store_root}/PLASTIC.md"
126
+ plastic_md = File.exist?(plastic_md_path) ? File.read(plastic_md_path).strip : nil
127
+
120
128
  # --- Load deprecations ---
121
129
 
130
+ dep_file = if plugin_root && !plugin_root.empty?
131
+ "#{plugin_root}/deprecations.yml"
132
+ else
133
+ "#{store_root}/deprecations.yml"
134
+ end
135
+
122
136
  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
137
+ if File.exist?(dep_file)
138
+ dep_data = YAML.safe_load(File.read(dep_file)) rescue {}
139
+ deprecations = dep_data["deprecations"] || []
129
140
  end
130
141
 
131
142
  dismissed_json = `"#{read_config}" deprecations_dismissed`.strip
@@ -136,7 +147,10 @@ rescue
136
147
  end
137
148
 
138
149
  current_version = nil
139
- if plugin_root && !plugin_root.empty?
150
+ version_file = "#{store_root}/VERSION"
151
+ if File.exist?(version_file)
152
+ current_version = File.read(version_file).strip
153
+ elsif plugin_root && !plugin_root.empty?
140
154
  plugin_json_path = "#{plugin_root}/.claude-plugin/plugin.json"
141
155
  if File.exist?(plugin_json_path)
142
156
  pj = JSON.parse(File.read(plugin_json_path)) rescue {}
@@ -150,42 +164,105 @@ active_deprecations = deprecations.select do |dep|
150
164
  !dismissed.include?(dep["id"])
151
165
  end
152
166
 
153
- # --- Early exit if nothing to show ---
167
+ # --- Check for available updates (from previous session's check) ---
168
+
169
+ update_notice = nil
170
+ cache_file = "#{store_root}/.cache/update-check.json"
171
+ if File.exist?(cache_file)
172
+ cache = JSON.parse(File.read(cache_file)) rescue {}
173
+ if cache["updateAvailable"]
174
+ update_notice = "Plastic update available: #{cache["current"]} -> #{cache["latest"]} — run /plastic-update"
175
+ end
176
+ end
177
+
178
+ # --- Run core health in-process (intent 36a) ---
179
+ # Reuse Doctor's own --core checks so there is one source of truth for "core
180
+ # health" (no second process spawn, no duplicated check list). Never blocks the
181
+ # session: any failure or exception degrades to a banner and we continue.
182
+
183
+ core_health = begin
184
+ Doctor.new(plastic_home: store_root).run_core_checks("claude")
185
+ rescue
186
+ nil
187
+ end
188
+ core_banner = BootBanner.render(health: core_health, version: current_version)
189
+
190
+ # --- Assemble session context ---
154
191
 
155
192
  all_active = active + project_active
156
193
  all_future = future + project_future
157
- exit 0 if all_active.empty? && stale.empty? && all_future.empty? && active_deprecations.empty?
158
194
 
159
195
  # --- Build context ---
196
+ # PLASTIC.md conventions and intent context render only when conventions are
197
+ # installed. Deprecation warnings and update notices render regardless, so a
198
+ # partial install (or a missing PLASTIC.md) still surfaces critical warnings.
160
199
 
161
200
  parts = []
162
201
 
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
202
+ # Boot banner first: every session start surfaces that Plastic loaded, with the
203
+ # version (clean) or the first failing core check (degraded). Owned by the hook
204
+ # so it runs by construction — the plastic-continuing skill no longer does this.
205
+ parts << core_banner
206
+ parts << ""
168
207
 
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}"
208
+ # --- QMD search status (intent 45a, READ-ONLY) ---
209
+ # Report-only line for the model (additionalContext), never systemMessage and
210
+ # never the exit code. QmdSync.status shells out to `qmd collection list`, so the
211
+ # whole block is guarded: a 2s timeout caps any hang, and rescue-all guarantees a
212
+ # slow/broken/missing qmd appends nothing and the hook continues cleanly.
213
+ begin
214
+ require "timeout"
215
+ qmd_status = Timeout.timeout(2) { QmdSync.status(plastic_home: store_root) }
216
+ if qmd_status[:present]
217
+ if qmd_status[:all_registered]
218
+ parts << "QMD: #{qmd_status[:registered].size} Plastic collections indexed (search with the qmd skill)."
219
+ else
220
+ parts << "QMD detected — run `qmd-sync register --all` to index your Plastic stores for search."
221
+ end
177
222
  end
178
- parts << ""
179
- else
180
- parts << "No active intents. #{future.length} future intents available.\n"
223
+ rescue Exception
224
+ # Any failure (timeout, missing binary, parse error) — stay silent, never crash.
181
225
  end
182
226
 
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"
227
+ if plastic_md
228
+ # Conventions always loaded first
229
+ parts << plastic_md
230
+ parts << "\n---\n"
231
+
232
+ if current_project
233
+ slug = current_project["slug"]
234
+ banner = "Project: #{slug} | Store: ~/.plastic/projects/#{slug}/store/"
235
+ if project_active.any? && project_active.first =~ /\[([^\]]+)\].*store\/([\w-]+)\//
236
+ intent_name, dir_name = $1, $2
237
+ intent_id = dir_name.split("--").first
238
+ banner += "\nActive: [#{intent_id} — #{intent_name}] | Artifacts → store/#{dir_name}/"
239
+ end
240
+ parts << banner + "\n"
241
+ else
242
+ parts << "PLASTIC — Global store loaded from ~/.plastic/\n"
243
+ end
244
+
245
+ if all_active.any?
246
+ parts << "Active intents:\n"
247
+ all_active.each { |a| parts << a }
248
+ if bridge_data
249
+ stage = bridge_data["build"]["stage"]
250
+ missing = bridge_data["build"]["missing"]
251
+ missing_str = missing.empty? ? "none" : missing.join(", ")
252
+ parts << "Stage: #{stage} | Next: #{missing_str}"
253
+ end
254
+ parts << ""
255
+ else
256
+ parts << "No active intents. #{future.length} future intents available.\n"
257
+ end
258
+
259
+ if stale.any?
260
+ parts << "\nStale future intents (untouched for days):\n"
261
+ stale.each do |s|
262
+ parts << "- #{s[:name]} (#{s[:age]} days) — consider: activate, abandon, or defer to agent"
263
+ end
264
+ parts << "\nWhen appropriate, ask the user what to do with stale intents."
187
265
  end
188
- parts << "\nWhen appropriate, ask the user what to do with stale intents."
189
266
  end
190
267
 
191
268
  if active_deprecations.any?
@@ -215,10 +292,23 @@ if active_deprecations.any?
215
292
  end
216
293
  end
217
294
 
295
+ if update_notice
296
+ parts.unshift("! #{update_notice}\n")
297
+ end
298
+
299
+ # Emit nothing when there is genuinely nothing to surface (no conventions,
300
+ # no deprecations, no update notice).
301
+ exit 0 if parts.join.strip.empty?
302
+
218
303
  payload = {
219
304
  "hookSpecificOutput" => {
220
305
  "hookEventName" => "SessionStart",
221
306
  "additionalContext" => parts.join("\n")
222
- }
307
+ },
308
+ # Intent 54: additionalContext is model-only, so the banner stays invisible to
309
+ # the human. The top-level systemMessage channel is rendered in the user's
310
+ # terminal (and re-fires on /clear). Reuse the same BootBanner line so the
311
+ # visible banner and the model-facing banner cannot drift.
312
+ "systemMessage" => core_banner
223
313
  }
224
314
  puts JSON.generate(payload)