@zalom/plastic 1.0.0-alpha.2 → 1.0.0-alpha.21

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 (76) hide show
  1. package/PLASTIC.md +128 -473
  2. package/README.md +90 -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 +7 -6
  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 +10 -0
  12. package/hooks/hooks.json +25 -4
  13. package/hooks/statusline +50 -10
  14. package/package.json +2 -2
  15. package/scripts/dashboard.rb +480 -0
  16. package/scripts/doctor.rb +973 -0
  17. package/scripts/hook-auto-arm +52 -0
  18. package/scripts/hook-bash-gate +53 -0
  19. package/scripts/hook-code-gate +39 -0
  20. package/scripts/hook-continue +15 -114
  21. package/scripts/hook-gate-check +19 -4
  22. package/scripts/hook-session-start +76 -31
  23. package/scripts/install.rb +91 -480
  24. package/scripts/lib/bridge.rb +255 -0
  25. package/scripts/lib/installer_core.rb +760 -0
  26. package/scripts/migrate-to-global +1 -1
  27. package/scripts/select-update-target +93 -0
  28. package/scripts/uninstall.rb +53 -0
  29. package/scripts/update.rb +142 -0
  30. package/scripts/versions.rb +141 -0
  31. package/skills/_active-intent-gate.md +26 -0
  32. package/skills/auto/SKILL.md +62 -9
  33. package/skills/auto/evals/evals.json +92 -0
  34. package/skills/auto/references/agent-architecture.md +60 -0
  35. package/skills/brainstorming/SKILL.md +143 -0
  36. package/skills/brainstorming-grill-me/SKILL.md +5 -5
  37. package/skills/continuing/SKILL.md +102 -77
  38. package/skills/continuing/evals/evals.json +136 -0
  39. package/skills/continuing/references/context-management.md +32 -0
  40. package/skills/creating-intent/SKILL.md +16 -1
  41. package/skills/creating-intent/references/lifecycle.md +74 -0
  42. package/skills/creating-intent/references/wikilinks.md +8 -0
  43. package/skills/creating-project/SKILL.md +8 -4
  44. package/skills/creating-project/references/hubs-projects.md +55 -0
  45. package/skills/dashboard/SKILL.md +92 -0
  46. package/skills/doctor/SKILL.md +116 -0
  47. package/skills/doctor/references/gates-stuck-detection.md +38 -0
  48. package/skills/doctor/report.md +96 -0
  49. package/skills/evaluating-skills/SKILL.md +140 -0
  50. package/skills/evaluating-skills/assets/eval-template.json +12 -0
  51. package/skills/evaluating-skills/evals/evals.json +75 -0
  52. package/skills/evaluating-skills/references/convention-checks.md +76 -0
  53. package/skills/evaluating-skills/references/eval-methodology.md +154 -0
  54. package/skills/executing-plan/SKILL.md +3 -3
  55. package/skills/install/SKILL.md +56 -8
  56. package/skills/intent-curator/SKILL.md +3 -3
  57. package/skills/linking-intents/SKILL.md +5 -1
  58. package/skills/linking-intents/references/zettelkasten.md +33 -0
  59. package/skills/managing-index/SKILL.md +5 -1
  60. package/skills/releasing/SKILL.md +119 -18
  61. package/skills/releasing/references/deprecations.md +44 -0
  62. package/skills/research/SKILL.md +114 -0
  63. package/skills/savepoint/SKILL.md +46 -37
  64. package/skills/savepoint/references/context-management.md +32 -0
  65. package/skills/uninstall/SKILL.md +39 -28
  66. package/skills/update/SKILL.md +41 -36
  67. package/skills/versions/SKILL.md +65 -0
  68. package/skills/writing-instructions/SKILL.md +159 -0
  69. package/skills/writing-instructions/references/agentskills-spec.md +135 -0
  70. package/skills/writing-plans/SKILL.md +183 -0
  71. package/templates/agents.md +16 -0
  72. package/templates/outcome.md +13 -0
  73. package/templates/project.yml +5 -0
  74. package/templates/savepoint.md +14 -13
  75. package/templates/spec.md +25 -0
  76. package/bin/install.js +0 -29
@@ -0,0 +1,52 @@
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
+ session = ENV["CLAUDE_SESSION_ID"]
30
+ bridge_data = (session && !session.empty? && File.exist?(Bridge.path(session))) ? Bridge.read(session) : nil
31
+ if bridge_data && bridge_data.dig("build", "auto") == true
32
+ intent = bridge_data["intent"] || {}
33
+ store = intent["store"]; dir = intent["dir"]
34
+ if store && dir
35
+ intent_dir = File.expand_path("#{store}/#{dir}")
36
+ reached_how = File.exist?("#{intent_dir}/plan.md") && File.exist?("#{intent_dir}/checklist.md")
37
+ unless reached_how
38
+ parts << "Auto mode armed on intent #{intent["id"]} — produce spec -> plan -> " \
39
+ "checklist before editing project code. Code edits are gate-blocked until then."
40
+ end
41
+ end
42
+ end
43
+
44
+ exit 0 if parts.empty?
45
+
46
+ payload = {
47
+ "hookSpecificOutput" => {
48
+ "hookEventName" => "UserPromptSubmit",
49
+ "additionalContext" => parts.join("\n")
50
+ }
51
+ }
52
+ puts JSON.generate(payload)
@@ -0,0 +1,53 @@
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 (mirror hook-code-gate resolution) ---
32
+ session = ENV["CLAUDE_SESSION_ID"]
33
+ bridge_data = nil
34
+
35
+ if session && !session.empty?
36
+ bridge_data = Bridge.read(session) if File.exist?(Bridge.path(session))
37
+ end
38
+
39
+ unless bridge_data
40
+ candidates = Dir.glob("/tmp/plastic-*.json").reject { |f| f.end_with?(".tmp") }
41
+ if candidates.any?
42
+ newest = candidates.max_by { |f| File.mtime(f) }
43
+ bridge_data = JSON.parse(File.read(newest)) rescue nil
44
+ end
45
+ end
46
+
47
+ exit 0 unless bridge_data
48
+
49
+ reason = Bridge.bash_gate_decision(bridge_data, command, cwd: cwd)
50
+ exit 0 unless reason
51
+
52
+ $stderr.puts "PLASTIC GATE — #{reason}"
53
+ exit 2
@@ -0,0 +1,39 @@
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
+ # --- Load bridge (mirror hook-gate-check resolution) ---
18
+ session = ENV["CLAUDE_SESSION_ID"]
19
+ bridge_data = nil
20
+
21
+ if session && !session.empty?
22
+ bridge_data = Bridge.read(session) if File.exist?(Bridge.path(session))
23
+ end
24
+
25
+ unless bridge_data
26
+ candidates = Dir.glob("/tmp/plastic-*.json").reject { |f| f.end_with?(".tmp") }
27
+ if candidates.any?
28
+ newest = candidates.max_by { |f| File.mtime(f) }
29
+ bridge_data = JSON.parse(File.read(newest)) rescue nil
30
+ end
31
+ end
32
+
33
+ exit 0 unless bridge_data
34
+
35
+ reason = Bridge.code_gate_decision(bridge_data, file_path)
36
+ exit 0 unless reason
37
+
38
+ $stderr.puts "PLASTIC GATE — #{reason}"
39
+ 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)
@@ -98,13 +98,21 @@ if is_stage_file || is_action_file
98
98
 
99
99
  Bridge.write(session, bridge_data)
100
100
 
101
+ # Cycle-step savepoint ledger (intent 34) — record this stage-file milestone.
102
+ # Best-effort: the ledger is derived sugar, so a failure must never block.
103
+ begin
104
+ Bridge.append_savepoint(intent_dir_abs, file_path_abs)
105
+ rescue StandardError
106
+ # ignore — rebuildable from disk
107
+ end
108
+
101
109
  # Build transition context
102
110
  stage_labels = { "what" => "What", "why" => "Why", "how" => "How", "exec" => "Exec", "done" => "Done" }
103
111
  next_hints = {
104
112
  "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."
113
+ "how" => "Why complete. Invoke plastic-auto to deliver autonomously, or write plan.md manually.",
114
+ "exec" => "How complete. Invoke plastic-auto or plastic-executing-plan to execute, or work through the checklist manually.",
115
+ "done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic-auto or do it manually."
108
116
  }
109
117
 
110
118
  context_parts = ["PLASTIC"]
@@ -129,7 +137,14 @@ if is_stage_file || is_action_file
129
137
  puts JSON.generate(payload)
130
138
  exit 0
131
139
  else
132
- # Regular file in intent dir — just update last_activity
140
+ # Regular file in intent dir — just update last_activity. The intent file
141
+ # (What milestone) lands here, so still offer it to the savepoint ledger;
142
+ # non-milestone files self-filter to a no-op (intent 34).
143
+ begin
144
+ Bridge.append_savepoint(intent_dir_abs, file_path_abs)
145
+ rescue StandardError
146
+ # ignore — rebuildable from disk
147
+ end
133
148
  bridge_data["build"]["last_activity"] = Time.now.utc.iso8601
134
149
  Bridge.write(session, bridge_data)
135
150
  exit 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,42 +161,68 @@ 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
+ # --- Assemble session context ---
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?
158
179
 
159
180
  # --- Build context ---
181
+ # PLASTIC.md conventions and intent context render only when conventions are
182
+ # installed. Deprecation warnings and update notices render regardless, so a
183
+ # partial install (or a missing PLASTIC.md) still surfaces critical warnings.
160
184
 
161
185
  parts = []
162
186
 
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
187
+ if plastic_md
188
+ # Conventions always loaded first
189
+ parts << plastic_md
190
+ parts << "\n---\n"
168
191
 
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}"
192
+ if current_project
193
+ slug = current_project["slug"]
194
+ banner = "Project: #{slug} | Store: ~/.plastic/projects/#{slug}/store/"
195
+ if project_active.any? && project_active.first =~ /\[([^\]]+)\].*store\/([\w-]+)\//
196
+ intent_name, dir_name = $1, $2
197
+ intent_id = dir_name.split("--").first
198
+ banner += "\nActive: [#{intent_id} #{intent_name}] | Artifacts → store/#{dir_name}/"
199
+ end
200
+ parts << banner + "\n"
201
+ else
202
+ parts << "PLASTIC — Global store loaded from ~/.plastic/\n"
177
203
  end
178
- parts << ""
179
- else
180
- parts << "No active intents. #{future.length} future intents available.\n"
181
- end
182
204
 
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"
205
+ if all_active.any?
206
+ parts << "Active intents:\n"
207
+ all_active.each { |a| parts << a }
208
+ if bridge_data
209
+ stage = bridge_data["build"]["stage"]
210
+ missing = bridge_data["build"]["missing"]
211
+ missing_str = missing.empty? ? "none" : missing.join(", ")
212
+ parts << "Stage: #{stage} | Next: #{missing_str}"
213
+ end
214
+ parts << ""
215
+ else
216
+ parts << "No active intents. #{future.length} future intents available.\n"
217
+ end
218
+
219
+ if stale.any?
220
+ parts << "\nStale future intents (untouched for days):\n"
221
+ stale.each do |s|
222
+ parts << "- #{s[:name]} (#{s[:age]} days) — consider: activate, abandon, or defer to agent"
223
+ end
224
+ parts << "\nWhen appropriate, ask the user what to do with stale intents."
187
225
  end
188
- parts << "\nWhen appropriate, ask the user what to do with stale intents."
189
226
  end
190
227
 
191
228
  if active_deprecations.any?
@@ -215,6 +252,14 @@ if active_deprecations.any?
215
252
  end
216
253
  end
217
254
 
255
+ if update_notice
256
+ parts.unshift("! #{update_notice}\n")
257
+ end
258
+
259
+ # Emit nothing when there is genuinely nothing to surface (no conventions,
260
+ # no deprecations, no update notice).
261
+ exit 0 if parts.join.strip.empty?
262
+
218
263
  payload = {
219
264
  "hookSpecificOutput" => {
220
265
  "hookEventName" => "SessionStart",