@zalom/plastic 2.0.0-alpha.21 → 2.0.0-alpha.23

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 (86) hide show
  1. package/PLASTIC.md +20 -18
  2. package/agents/plastic-enforcer.md +6 -3
  3. package/agents/plastic-executor.md +4 -0
  4. package/agents/plastic-node-research.md +28 -0
  5. package/agents/plastic-node-verify.md +27 -0
  6. package/agents/plastic-node-work.md +32 -0
  7. package/bin/lib/context_budget.rb +1 -1
  8. package/hooks/hooks.json +12 -0
  9. package/hooks/statusline +28 -0
  10. package/hooks/stop +5 -0
  11. package/package.json +1 -1
  12. package/scripts/doctor.rb +159 -10
  13. package/scripts/end-intent +3 -3
  14. package/scripts/graph-measure +249 -0
  15. package/scripts/hook-capture +1 -0
  16. package/scripts/hook-savepoint +24 -2
  17. package/scripts/hook-session-start +333 -321
  18. package/scripts/hook-stop +57 -0
  19. package/scripts/insight-append +18 -4
  20. package/scripts/lib/active_delivery.rb +61 -0
  21. package/scripts/lib/agent_models.rb +10 -1
  22. package/scripts/lib/codex_adapter.rb +197 -0
  23. package/scripts/lib/doctor_core.rb +8 -3
  24. package/scripts/lib/engine_permissions.rb +88 -0
  25. package/scripts/lib/graph_edges.rb +4 -4
  26. package/scripts/lib/graph_file.rb +4 -4
  27. package/scripts/lib/graph_measure.rb +645 -0
  28. package/scripts/lib/graph_measure_budget.rb +408 -0
  29. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  30. package/scripts/lib/graph_measure_models.rb +411 -0
  31. package/scripts/lib/graph_measure_report.rb +532 -0
  32. package/scripts/lib/graph_tree.rb +2 -2
  33. package/scripts/lib/handoff.rb +36 -5
  34. package/scripts/lib/harness_adapter.rb +184 -0
  35. package/scripts/lib/hook_registry.rb +13 -1
  36. package/scripts/lib/hook_replay.rb +23 -5
  37. package/scripts/lib/index_projection.rb +1 -1
  38. package/scripts/lib/installer_core.rb +112 -6
  39. package/scripts/lib/intent_screen.rb +1 -1
  40. package/scripts/lib/intent_validator.rb +2 -2
  41. package/scripts/lib/meter_watch.rb +15 -9
  42. package/scripts/lib/node_file.rb +3 -3
  43. package/scripts/lib/node_ledger.rb +8 -1
  44. package/scripts/lib/node_progress.rb +153 -0
  45. package/scripts/lib/outcome_report.rb +1 -1
  46. package/scripts/lib/report_screen.rb +10 -6
  47. package/scripts/lib/roadmap_graph.rb +1 -1
  48. package/scripts/lib/roadmap_queue.rb +1 -1
  49. package/scripts/lib/roadmap_render.rb +1 -1
  50. package/scripts/lib/runner_absorb.rb +31 -5
  51. package/scripts/lib/runner_dispatch.rb +26 -11
  52. package/scripts/lib/runner_until_empty.rb +252 -0
  53. package/scripts/lib/runner_watch.rb +389 -0
  54. package/scripts/lib/savepoint.rb +3 -3
  55. package/scripts/lib/session_git.rb +2 -2
  56. package/scripts/lib/stop_gate.rb +95 -0
  57. package/scripts/lib/verify_intent.rb +2 -2
  58. package/scripts/lib/work_graph_validator.rb +6 -6
  59. package/scripts/new-intent +1 -1
  60. package/scripts/node-run +224 -0
  61. package/scripts/read-config +6 -0
  62. package/scripts/runner +203 -19
  63. package/scripts/skill-lint +115 -6
  64. package/scripts/verify-intent +1 -1
  65. package/skills/auto/SKILL.md +54 -56
  66. package/skills/auto/references/agent-architecture.md +10 -8
  67. package/skills/auto/references/human-report-contract.md +1 -1
  68. package/skills/conventions/references/completion-and-done.md +7 -7
  69. package/skills/conventions/references/knowledge-graph.md +9 -0
  70. package/skills/conventions/references/locks-and-worktrees.md +3 -3
  71. package/skills/conventions/references/maintenance-and-revisions.md +1 -1
  72. package/skills/doctor/SKILL.md +3 -3
  73. package/skills/doctor/report.md +1 -1
  74. package/skills/intent-continuing/references/boarding-matrix.md +2 -2
  75. package/skills/intent-creating/SKILL.md +58 -133
  76. package/skills/intent-creating/evals/evals.json +1 -1
  77. package/skills/intent-ending/SKILL.md +48 -56
  78. package/skills/intent-ending/evals/evals.json +1 -1
  79. package/skills/intent-executing/SKILL.md +43 -136
  80. package/skills/intent-speccing/SKILL.md +3 -0
  81. package/skills/releasing/SKILL.md +1 -1
  82. package/skills/releasing/references/release-lines.md +1 -1
  83. package/skills/tutorial/SKILL.md +2 -1
  84. package/skills/tutorial/references/track-1-guided.md +21 -40
  85. package/skills/tutorial/references/track-2-auto.md +3 -3
  86. package/templates/agents.md +2 -2
@@ -8,12 +8,15 @@ require "date"
8
8
  require "yaml"
9
9
  require "fileutils"
10
10
  require_relative "lib/bridge"
11
- require_relative "lib/savepoint"
12
11
  require_relative "lib/boot_banner"
13
12
  require_relative "lib/qmd_sync"
14
13
  require_relative "lib/doctor_core"
15
14
  require_relative "lib/session_ledger"
16
15
  require_relative "lib/day_summary"
16
+ require_relative "lib/packet_wrapper"
17
+ require_relative "lib/active_delivery"
18
+ require_relative "lib/runner_core"
19
+ require_relative "lib/runner_watch"
17
20
 
18
21
  index_path, plastic_home, mode, plugin_root = ARGV
19
22
  exit 0 unless index_path && plastic_home && mode
@@ -58,141 +61,10 @@ end
58
61
  # doubled store/ segment.
59
62
  store_dir = File.join(plastic_home, "store")
60
63
 
61
- # --- Parse INDEX.md ---
62
-
63
- lines = File.readlines(index_path)
64
- active = []
65
- future = []
66
- section = nil
67
-
68
- lines.each do |line|
69
- if line.start_with?("## Active")
70
- section = :active
71
- next
72
- elsif line.start_with?("## Future")
73
- section = :future
74
- next
75
- elsif line.start_with?("## ")
76
- section = nil
77
- next
78
- end
79
-
80
- next unless section && line.strip.start_with?("- [")
81
-
82
- if section == :active
83
- active << line.strip
84
- elsif section == :future
85
- future << line.strip
86
- end
87
- end
88
-
89
- # --- Stage of the single active intent (the /tmp bridge this once derived was removed in 2.0, intent 307) ---
90
-
91
- stage_line = nil
92
- if active.length == 1 && active.first =~ /store\/([\w-]+)\//
93
- dir_name = $1
94
- intent_dir = "#{store_dir}/#{dir_name}"
95
- begin
96
- stage = Savepoint.derive_stage(intent_dir)
97
- missing = Savepoint.missing_for_stage(stage, intent_dir)
98
- missing_str = missing.empty? ? "none" : missing.join(", ")
99
- stage_line = "Stage: #{stage} | Next: #{missing_str}"
100
- rescue StandardError
101
- stage_line = nil
102
- end
103
- end
104
-
105
- # --- Detect stale future intents ---
106
-
107
- stale = []
108
- read_config = if plugin_root && !plugin_root.empty?
109
- "#{plugin_root}/scripts/read-config"
110
- else
111
- File.expand_path("~/.plastic/scripts/read-config")
112
- end
113
- stale_days = `"#{read_config}" stale_threshold_days`.strip.to_i
114
- stale_days = 3 if stale_days == 0
115
-
116
- future.each do |f|
117
- if f =~ /store\/([\w-]+)\//
118
- dir_name = $1
119
- intent_file = "#{store_dir}/#{dir_name}/#{dir_name}.md"
120
- next unless File.exist?(intent_file)
121
-
122
- content = File.read(intent_file)
123
- if content =~ /^created:\s*['"]?(\d{4}-\d{2}-\d{2})/
124
- age = (Date.today - Date.parse($1)).to_i
125
- if age >= stale_days
126
- name = f[/\[([^\]]+)\]/, 1] || "unknown"
127
- stale << { name: name, age: age, entry: f }
128
- end
129
- end
130
- end
131
- end
132
-
133
- # --- Detect current project ---
134
-
135
- current_project = nil
136
- project_active = []
137
- project_future = []
138
-
139
- projects_path = "#{plastic_home}/projects.yml"
140
- if File.exist?(projects_path)
141
- projects = YAML.safe_load(File.read(projects_path)) rescue {}
142
- cwd = Dir.pwd
143
- (projects["projects"] || {}).each do |slug, info|
144
- project_path = File.expand_path(info["path"])
145
- if cwd.start_with?(project_path)
146
- current_project = { "slug" => slug, "parent" => info["parent"], "path" => project_path }
147
- project_index = "#{plastic_home}/projects/#{slug}/INDEX.md"
148
- if File.exist?(project_index)
149
- p_section = nil
150
- File.readlines(project_index).each do |pline|
151
- if pline.start_with?("## Active")
152
- p_section = :active
153
- next
154
- elsif pline.start_with?("## Future")
155
- p_section = :future
156
- next
157
- elsif pline.start_with?("## ")
158
- p_section = nil
159
- next
160
- end
161
- next unless p_section && pline.strip.start_with?("- [")
162
- project_active << pline.strip if p_section == :active
163
- project_future << pline.strip if p_section == :future
164
- end
165
- end
166
- break
167
- end
168
- end
169
- end
170
-
171
- # --- Load PLASTIC.md conventions ---
172
-
173
- plastic_md_path = "#{plastic_home}/PLASTIC.md"
174
- plastic_md = File.exist?(plastic_md_path) ? File.read(plastic_md_path).strip : nil
175
-
176
- # --- Load deprecations ---
177
-
178
- dep_file = if plugin_root && !plugin_root.empty?
179
- "#{plugin_root}/deprecations.yml"
180
- else
181
- "#{plastic_home}/deprecations.yml"
182
- end
183
-
184
- deprecations = []
185
- if File.exist?(dep_file)
186
- dep_data = YAML.safe_load(File.read(dep_file)) rescue {}
187
- deprecations = dep_data["deprecations"] || []
188
- end
189
-
190
- dismissed_json = `"#{read_config}" deprecations_dismissed`.strip
191
- dismissed = begin
192
- JSON.parse(dismissed_json)
193
- rescue
194
- []
195
- end
64
+ # --- Current version ---
65
+ # Read early, before any of the richer INDEX/project parsing below, so the
66
+ # core banner (the one thing every session must get) never depends on
67
+ # anything that can raise.
196
68
 
197
69
  current_version = nil
198
70
  version_file = "#{plastic_home}/VERSION"
@@ -206,23 +78,6 @@ elsif plugin_root && !plugin_root.empty?
206
78
  end
207
79
  end
208
80
 
209
- active_deprecations = deprecations.select do |dep|
210
- next true if dep["severity"] == "critical"
211
- next true if current_version && dep["removal"] == current_version
212
- !dismissed.include?(dep["id"])
213
- end
214
-
215
- # --- Check for available updates (from previous session's check) ---
216
-
217
- update_notice = nil
218
- cache_file = "#{plastic_home}/.cache/update-check.json"
219
- if File.exist?(cache_file)
220
- cache = JSON.parse(File.read(cache_file)) rescue {}
221
- if cache["updateAvailable"]
222
- update_notice = "Plastic update available: #{cache["current"]} -> #{cache["latest"]} — run /plastic-update"
223
- end
224
- end
225
-
226
81
  # --- Run core health in-process (intent 36a) ---
227
82
  # Reuse Doctor's own --core checks so there is one source of truth for "core
228
83
  # health" (no second process spawn, no duplicated check list). Never blocks the
@@ -235,206 +90,363 @@ rescue
235
90
  end
236
91
  core_banner = BootBanner.render(health: core_health, version: current_version)
237
92
 
238
- # --- Assemble session context ---
239
-
240
- all_active = active + project_active
241
- all_future = future + project_future
242
-
243
93
  # --- Build context ---
244
- # PLASTIC.md conventions and intent context render only when conventions are
245
- # installed. Deprecation warnings and update notices render regardless, so a
246
- # partial install (or a missing PLASTIC.md) still surfaces critical warnings.
247
-
248
- parts = []
249
-
250
- # Boot banner first: every session start surfaces that Plastic loaded, with the
251
- # version (clean) or the first failing core check (degraded). Owned by the hook
252
- # so it runs by construction the plastic-intent-continuing skill no longer does this.
253
- parts << core_banner
254
- parts << ""
255
-
256
- # --- QMD search status (intent 45a, READ-ONLY) ---
257
- # Report-only line for the model (additionalContext), never systemMessage and
258
- # never the exit code. QmdSync.status shells out to `qmd collection list`, so the
259
- # whole block is guarded: a 2s timeout caps any hang, and rescue-all guarantees a
260
- # slow/broken/missing qmd appends nothing and the hook continues cleanly.
94
+ # The core banner is the one thing a session always gets. Everything below it
95
+ # (the project/global banner with its active intent, the QMD line,
96
+ # deprecations, the update notice, the first-boot sweep, the day ledger) is
97
+ # best-effort: intent 341, G8 wraps all of it in one rescue so any exception
98
+ # anywhere in that assembly degrades to a banner-only boot, never nothing.
99
+ # That is the same "as today" contract every individual sub-block already
100
+ # kept on its own, made a whole-block guarantee now that the doctrine dump
101
+ # this used to gate everything on is cut (PLASTIC.md's conventions prose, the
102
+ # active-intents listing, the stage line, the stale-future list), since a
103
+ # skill or the conventions chapter already carries that text.
104
+
105
+ parts = [core_banner, ""]
106
+
261
107
  begin
262
- require "timeout"
263
- qmd_status = Timeout.timeout(2) { QmdSync.status(plastic_home: plastic_home) }
264
- if qmd_status[:present]
265
- if qmd_status[:all_registered]
266
- parts << "QMD: #{qmd_status[:registered].size} Plastic collections indexed (search with the qmd skill)."
267
- else
268
- parts << "QMD detected — run `qmd-sync register --all` to index your Plastic stores for search."
108
+ # --- Parse INDEX.md ---
109
+
110
+ lines = File.readlines(index_path)
111
+ active = []
112
+ future = []
113
+ section = nil
114
+
115
+ lines.each do |line|
116
+ if line.start_with?("## Active")
117
+ section = :active
118
+ next
119
+ elsif line.start_with?("## Future")
120
+ section = :future
121
+ next
122
+ elsif line.start_with?("## ")
123
+ section = nil
124
+ next
269
125
  end
270
- end
271
- rescue Exception
272
- # Any failure (timeout, missing binary, parse error) — stay silent, never crash.
273
- end unless subagent_session
274
-
275
- if plastic_md && !subagent_session
276
- # Conventions always loaded first
277
- parts << plastic_md
278
- parts << "\n---\n"
279
-
280
- if current_project
281
- slug = current_project["slug"]
282
- banner = "Project: #{slug} | Store: ~/.plastic/projects/#{slug}/store/"
283
- if project_active.any? && project_active.first =~ /\[([^\]]+)\].*store\/([\w-]+)\//
284
- intent_name, dir_name = $1, $2
285
- intent_id = dir_name.split("--").first
286
- banner += "\nActive: [#{intent_id} — #{intent_name}] | Artifacts → store/#{dir_name}/"
126
+
127
+ next unless section && line.strip.start_with?("- [")
128
+
129
+ if section == :active
130
+ active << line.strip
131
+ elsif section == :future
132
+ future << line.strip
287
133
  end
288
- parts << banner + "\n"
134
+ end
135
+
136
+ # --- read-config path (used below for author and dismissed deprecations) ---
137
+
138
+ read_config = if plugin_root && !plugin_root.empty?
139
+ "#{plugin_root}/scripts/read-config"
289
140
  else
290
- parts << "PLASTIC — Global store loaded from ~/.plastic/\n"
141
+ File.expand_path("~/.plastic/scripts/read-config")
291
142
  end
292
143
 
293
- if all_active.any?
294
- parts << "Active intents:\n"
295
- all_active.each { |a| parts << a }
296
- parts << stage_line if stage_line
297
- parts << ""
144
+ # --- Detect current project ---
145
+
146
+ current_project = nil
147
+ project_active = []
148
+ project_future = []
149
+
150
+ projects_path = "#{plastic_home}/projects.yml"
151
+ if File.exist?(projects_path)
152
+ projects = YAML.safe_load(File.read(projects_path)) rescue {}
153
+ cwd = Dir.pwd
154
+ (projects["projects"] || {}).each do |slug, info|
155
+ project_path = File.expand_path(info["path"])
156
+ if cwd.start_with?(project_path)
157
+ current_project = { "slug" => slug, "parent" => info["parent"], "path" => project_path }
158
+ project_index = "#{plastic_home}/projects/#{slug}/INDEX.md"
159
+ if File.exist?(project_index)
160
+ p_section = nil
161
+ File.readlines(project_index).each do |pline|
162
+ if pline.start_with?("## Active")
163
+ p_section = :active
164
+ next
165
+ elsif pline.start_with?("## Future")
166
+ p_section = :future
167
+ next
168
+ elsif pline.start_with?("## ")
169
+ p_section = nil
170
+ next
171
+ end
172
+ next unless p_section && pline.strip.start_with?("- [")
173
+ project_active << pline.strip if p_section == :active
174
+ project_future << pline.strip if p_section == :future
175
+ end
176
+ end
177
+ break
178
+ end
179
+ end
180
+ end
181
+
182
+ # --- Load PLASTIC.md conventions ---
183
+ # Its content no longer renders at boot (intent 341, G8): the gate stays,
184
+ # so a partial install without PLASTIC.md still renders no project banner,
185
+ # but the text itself is never read into `parts`.
186
+
187
+ plastic_md_path = "#{plastic_home}/PLASTIC.md"
188
+ plastic_md = File.exist?(plastic_md_path) ? File.read(plastic_md_path).strip : nil
189
+
190
+ # --- Load deprecations ---
191
+
192
+ dep_file = if plugin_root && !plugin_root.empty?
193
+ "#{plugin_root}/deprecations.yml"
298
194
  else
299
- parts << "No active intents. #{future.length} future intents available.\n"
195
+ "#{plastic_home}/deprecations.yml"
196
+ end
197
+
198
+ deprecations = []
199
+ if File.exist?(dep_file)
200
+ dep_data = YAML.safe_load(File.read(dep_file)) rescue {}
201
+ deprecations = dep_data["deprecations"] || []
202
+ end
203
+
204
+ dismissed_json = `"#{read_config}" deprecations_dismissed`.strip
205
+ dismissed = begin
206
+ JSON.parse(dismissed_json)
207
+ rescue
208
+ []
209
+ end
210
+
211
+ active_deprecations = deprecations.select do |dep|
212
+ next true if dep["severity"] == "critical"
213
+ next true if current_version && dep["removal"] == current_version
214
+ !dismissed.include?(dep["id"])
300
215
  end
301
216
 
302
- if stale.any?
303
- parts << "\nStale future intents (untouched for days):\n"
304
- stale.each do |s|
305
- parts << "- #{s[:name]} (#{s[:age]} days) — consider: activate, abandon, or defer to agent"
217
+ # --- Check for available updates (from previous session's check) ---
218
+
219
+ update_notice = nil
220
+ cache_file = "#{plastic_home}/.cache/update-check.json"
221
+ if File.exist?(cache_file)
222
+ cache = JSON.parse(File.read(cache_file)) rescue {}
223
+ if cache["updateAvailable"]
224
+ update_notice = "Plastic update available: #{cache["current"]} -> #{cache["latest"]} — run /plastic-update"
306
225
  end
307
- parts << "\nWhen appropriate, ask the user what to do with stale intents."
308
226
  end
309
- end
310
227
 
311
- if active_deprecations.any? && !subagent_session
312
- parts << ""
313
- active_deprecations.each do |dep|
314
- severity = dep["severity"] || "info"
315
- summary = dep["summary"] || dep["id"]
316
- removal = dep["removal"]
317
- link = dep["link"]
318
- steps = dep["migration_steps"] || []
319
-
320
- if severity == "info"
321
- line = "i Deprecation: #{summary}. Removed in: #{removal}."
322
- line += " See: #{link}" if link
323
- parts << line
228
+ # --- QMD search status (intent 45a, READ-ONLY) ---
229
+ # Report-only line for the model (additionalContext), never systemMessage and
230
+ # never the exit code. QmdSync.status shells out to `qmd collection list`, so the
231
+ # whole block is guarded: a 2s timeout caps any hang, and rescue-all guarantees a
232
+ # slow/broken/missing qmd appends nothing and the hook continues cleanly.
233
+ begin
234
+ require "timeout"
235
+ qmd_status = Timeout.timeout(2) { QmdSync.status(plastic_home: plastic_home) }
236
+ if qmd_status[:present]
237
+ # C23 (intent 341, G8): a QMD hit is untrusted text, so it never sits
238
+ # loose in additionalContext alongside the owner's own banners; it
239
+ # rides inside PacketWrapper's data boundary.
240
+ qmd_line = if qmd_status[:all_registered]
241
+ "QMD: #{qmd_status[:registered].size} Plastic collections indexed (search with the qmd skill)."
242
+ else
243
+ "QMD detected — run `qmd-sync register --all` to index your Plastic stores for search."
244
+ end
245
+ token = PacketWrapper.boundary_token([qmd_line])
246
+ parts << PacketWrapper.wrap(qmd_line, label: "qmd-hit", source: "qmd-sync", token: token).rstrip
247
+ end
248
+ rescue Exception
249
+ # Any failure (timeout, missing binary, parse error) — stay silent, never crash.
250
+ end unless subagent_session
251
+
252
+ # --- Delivery watch (intent 340a, G7b, n3, graph.md D10) ---
253
+ # One unrecorded tick (record: false: reclaim and classify, no snapshot,
254
+ # no record line, so boot never advances a quiet count) per intent
255
+ # ActiveDelivery.candidate_intent_dirs finds, naming every stalled or
256
+ # done_unreported one in a single line, nothing when there are none. The
257
+ # whole block is one guarded unit, like the QMD block above it: a raise or
258
+ # a hang on any one candidate must add nothing rather than name only the
259
+ # candidates seen before the failure, so the line a boot does print is
260
+ # never a partial one.
261
+ begin
262
+ Timeout.timeout(2) do
263
+ roots_raw = IO.popen({ "PLASTIC_HOME" => plastic_home }, [read_config, "project_roots"],
264
+ err: File::NULL, &:read).to_s.strip
265
+ project_roots = roots_raw.empty? ? [] : Array(JSON.parse(roots_raw))
266
+ project_roots = project_roots.map { |root| File.expand_path(root.to_s) }
267
+
268
+ attention = []
269
+ ActiveDelivery.candidate_intent_dirs(global_store: store_dir, project_roots: project_roots).uniq.each do |dir|
270
+ watch_context = RunnerCore.context(intent_dir: dir)
271
+ result = RunnerWatch.tick(watch_context, record: false)
272
+ next unless %w[stalled done_unreported].include?(result[:class])
273
+
274
+ watch_intent_id = File.basename(dir).split("--").first
275
+ if result[:class] == "stalled"
276
+ blocker = Array(result[:blockers]).first
277
+ attention << (blocker ? "#{watch_intent_id} stalled (#{blocker})" : "#{watch_intent_id} stalled")
278
+ else
279
+ attention << "#{watch_intent_id} done_unreported"
280
+ end
281
+ end
282
+ parts << "PLASTIC watch: #{attention.join(', ')}" if attention.any?
283
+ end
284
+ rescue Exception
285
+ # Any failure or timeout (a malformed intent directory, a hung tick) -
286
+ # stay silent, never crash boot.
287
+ end unless subagent_session
288
+
289
+ # --- Project (or global) banner with its active intent ---
290
+ # The one piece of intent context a live boot still carries: which store
291
+ # is live, and which intent (if any) is active in it (intent 341, G8, D4).
292
+ if plastic_md && !subagent_session
293
+ if current_project
294
+ slug = current_project["slug"]
295
+ banner = "Project: #{slug} | Store: ~/.plastic/projects/#{slug}/store/"
296
+ if project_active.any? && project_active.first =~ /\[([^\]]+)\].*store\/([\w-]+)\//
297
+ intent_name, dir_name = $1, $2
298
+ intent_id = dir_name.split("--").first
299
+ banner += "\nActive: [#{intent_id} — #{intent_name}] | Artifacts → store/#{dir_name}/"
300
+ end
301
+ parts << banner
324
302
  else
325
- marker = severity == "critical" ? "!! DEPRECATION (critical)" : "! DEPRECATION (warning)"
326
- parts << "#{marker}: #{summary}"
327
- if steps.any?
328
- parts << " Migration steps:"
329
- steps.each_with_index { |s, i| parts << " #{i + 1}. #{s}" }
303
+ banner = "PLASTIC Global store loaded from ~/.plastic/"
304
+ if active.any? && active.first =~ /\[([^\]]+)\].*store\/([\w-]+)\//
305
+ intent_name, dir_name = $1, $2
306
+ intent_id = dir_name.split("--").first
307
+ banner += "\nActive: [#{intent_id} #{intent_name}] | Artifacts → store/#{dir_name}/"
330
308
  end
331
- trail = " Removed in: #{removal}"
332
- trail += " | Details: #{link}" if link
333
- parts << trail
309
+ parts << banner
334
310
  end
335
311
  end
336
- end
337
312
 
338
- if update_notice && !subagent_session
339
- parts.unshift("! #{update_notice}\n")
340
- end
313
+ if active_deprecations.any? && !subagent_session
314
+ parts << ""
315
+ active_deprecations.each do |dep|
316
+ severity = dep["severity"] || "info"
317
+ summary = dep["summary"] || dep["id"]
318
+ removal = dep["removal"]
319
+ link = dep["link"]
320
+ steps = dep["migration_steps"] || []
321
+
322
+ if severity == "info"
323
+ line = "i Deprecation: #{summary}. Removed in: #{removal}."
324
+ line += " See: #{link}" if link
325
+ parts << line
326
+ else
327
+ marker = severity == "critical" ? "!! DEPRECATION (critical)" : "! DEPRECATION (warning)"
328
+ parts << "#{marker}: #{summary}"
329
+ if steps.any?
330
+ parts << " Migration steps:"
331
+ steps.each_with_index { |s, i| parts << " #{i + 1}. #{s}" }
332
+ end
333
+ trail = " Removed in: #{removal}"
334
+ trail += " | Details: #{link}" if link
335
+ parts << trail
336
+ end
337
+ end
338
+ end
341
339
 
342
- # --- First-boot sweep (intent 301, spec D9): file every unclosed prior day
343
- # ledger into today, oldest first, at most 3 per boot within a 5-second
344
- # budget. Each day runs the sibling file-session-intent in its own rescue;
345
- # the boot never blocks on it.
346
- begin
347
- require "open3"
348
- require "timeout"
349
- require_relative "lib/session_backfill"
350
-
351
- sweep_today = SessionLedger.day_id
352
- sweep_root = SessionLedger.sessions_root(store_dir)
353
- sweep_filer = File.expand_path("file-session-intent", __dir__)
354
- sweep_templates = File.expand_path("../templates", __dir__)
355
- candidates = []
356
- if Dir.exist?(sweep_root) && File.exist?(sweep_filer)
357
- candidates = Dir.children(sweep_root).select do |name|
358
- File.directory?(File.join(sweep_root, name)) && SessionLedger.valid_day_id?(name) &&
359
- name < sweep_today && !SessionBackfill.closed?(store_dir, name)
360
- end.sort
340
+ if update_notice && !subagent_session
341
+ parts.unshift("! #{update_notice}\n")
361
342
  end
362
- filed = 0
363
- budget_end = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
364
- candidates.first(3).each do |day|
365
- break if Process.clock_gettime(Process::CLOCK_MONOTONIC) > budget_end
366
343
 
344
+ # --- First-boot sweep (intent 301, spec D9): file every unclosed prior day
345
+ # ledger into today, oldest first, at most 3 per boot within a 5-second
346
+ # budget. Each day runs the sibling file-session-intent in its own rescue;
347
+ # the boot never blocks on it.
348
+ unless subagent_session
367
349
  begin
368
- Timeout.timeout(5) do
369
- _out, _err, sweep_status = Open3.capture3({ "RUBYOPT" => nil }, RbConfig.ruby, sweep_filer,
370
- "--day", day, "--carry-to", sweep_today,
371
- "--store", store_dir, "--templates", sweep_templates)
372
- filed += 1 if sweep_status.success? && SessionBackfill.closed?(store_dir, day)
350
+ require "open3"
351
+ require "timeout"
352
+ require_relative "lib/session_backfill"
353
+
354
+ sweep_today = SessionLedger.day_id
355
+ sweep_root = SessionLedger.sessions_root(store_dir)
356
+ sweep_filer = File.expand_path("file-session-intent", __dir__)
357
+ sweep_templates = File.expand_path("../templates", __dir__)
358
+ candidates = []
359
+ if Dir.exist?(sweep_root) && File.exist?(sweep_filer)
360
+ candidates = Dir.children(sweep_root).select do |name|
361
+ File.directory?(File.join(sweep_root, name)) && SessionLedger.valid_day_id?(name) &&
362
+ name < sweep_today && !SessionBackfill.closed?(store_dir, name)
363
+ end.sort
364
+ end
365
+ filed = 0
366
+ budget_end = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
367
+ candidates.first(3).each do |day|
368
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) > budget_end
369
+
370
+ begin
371
+ Timeout.timeout(5) do
372
+ _out, _err, sweep_status = Open3.capture3({ "RUBYOPT" => nil }, RbConfig.ruby, sweep_filer,
373
+ "--day", day, "--carry-to", sweep_today,
374
+ "--store", store_dir, "--templates", sweep_templates)
375
+ filed += 1 if sweep_status.success? && SessionBackfill.closed?(store_dir, day)
376
+ end
377
+ rescue StandardError
378
+ nil
379
+ end
380
+ end
381
+ if filed.positive?
382
+ line = "PLASTIC: filed #{filed} prior day ledger(s) into #{sweep_today}"
383
+ remaining = candidates.size - filed
384
+ line += ", #{remaining} more wait for the next boot" if remaining.positive?
385
+ parts << line
373
386
  end
374
387
  rescue StandardError
375
388
  nil
376
389
  end
377
390
  end
378
- if filed.positive?
379
- line = "PLASTIC: filed #{filed} prior day ledger(s) into #{sweep_today}"
380
- remaining = candidates.size - filed
381
- line += ", #{remaining} more wait for the next boot" if remaining.positive?
382
- parts << line
383
- end
384
- rescue StandardError
385
- nil
386
- end unless subagent_session
387
391
 
388
- # --- Session day ledger: open or join today, write the per-session pointer
389
- # and heartbeat (intent 298, spec D4). Best-effort: any failure here degrades
390
- # to no ledger context line, never blocks the session.
391
- begin
392
- templates = File.expand_path("../templates", __dir__)
393
- day = SessionLedger.day_id
394
- author = `"#{read_config}" author`.strip
395
- author = "session" if author.empty?
396
- SessionLedger.open_day(store: store_dir, day: day, templates: templates, author: author)
397
-
398
- session = if !payload_session_id.empty?
399
- payload_session_id
400
- else
401
- ENV["CLAUDE_CODE_SESSION_ID"] || Process.pid.to_s
402
- end
403
- sid = SessionLedger.short_session_id(nil, session)
404
- SessionLedger.ensure_tmp_root(store_dir)
405
- FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store_dir, sid))
406
- File.write(SessionLedger.heartbeat_path(store_dir, sid), "#{Time.now.utc.iso8601}\n")
407
- pointer = SessionLedger.pointer_path(store_dir, sid)
408
- File.write(pointer, "#{day}\n") unless File.exist?(pointer)
409
-
410
- checklist = SessionLedger.checklist_path(store_dir, day)
411
- open_count = 0
412
- pending_count = 0
413
- if File.exist?(checklist)
414
- File.readlines(checklist).each do |line|
415
- parsed = SessionLedger.parse_checklist_line(line)
416
- next unless parsed
417
-
418
- open_count += 1 if parsed[:state] == :open
419
- pending_count += 1 if parsed[:state] == :pending
392
+ # --- Session day ledger: open or join today, write the per-session pointer
393
+ # and heartbeat (intent 298, spec D4). Best-effort: any failure here degrades
394
+ # to no ledger context line, never blocks the session.
395
+ unless subagent_session
396
+ begin
397
+ templates = File.expand_path("../templates", __dir__)
398
+ day = SessionLedger.day_id
399
+ author = `"#{read_config}" author`.strip
400
+ author = "session" if author.empty?
401
+ SessionLedger.open_day(store: store_dir, day: day, templates: templates, author: author)
402
+
403
+ session = if !payload_session_id.empty?
404
+ payload_session_id
405
+ else
406
+ ENV["CLAUDE_CODE_SESSION_ID"] || Process.pid.to_s
407
+ end
408
+ sid = SessionLedger.short_session_id(nil, session)
409
+ SessionLedger.ensure_tmp_root(store_dir)
410
+ FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store_dir, sid))
411
+ File.write(SessionLedger.heartbeat_path(store_dir, sid), "#{Time.now.utc.iso8601}\n")
412
+ pointer = SessionLedger.pointer_path(store_dir, sid)
413
+ File.write(pointer, "#{day}\n") unless File.exist?(pointer)
414
+
415
+ checklist = SessionLedger.checklist_path(store_dir, day)
416
+ open_count = 0
417
+ pending_count = 0
418
+ if File.exist?(checklist)
419
+ File.readlines(checklist).each do |line|
420
+ parsed = SessionLedger.parse_checklist_line(line)
421
+ next unless parsed
422
+
423
+ open_count += 1 if parsed[:state] == :open
424
+ pending_count += 1 if parsed[:state] == :pending
425
+ end
426
+ end
427
+ parts << "PLASTIC: day ledger #{day} joined (#{open_count} open items, #{pending_count} pending)"
428
+
429
+ # The day summary (intent 311, spec D8): open items, the last five done,
430
+ # live auto intents, other active sessions. Never the raw ledger. A
431
+ # failure here leaves the joined line alone.
432
+ begin
433
+ summary = DaySummary.build(store: store_dir, day: day, session: sid, home: plastic_home, now: Time.now)
434
+ parts << summary unless summary.empty?
435
+ rescue StandardError
436
+ nil
437
+ end
438
+ rescue StandardError
439
+ nil
420
440
  end
421
441
  end
422
- parts << "PLASTIC: day ledger #{day} joined (#{open_count} open items, #{pending_count} pending)"
423
-
424
- # The day summary (intent 311, spec D8): open items, the last five done,
425
- # live auto intents, other active sessions. Never the raw ledger. A
426
- # failure here leaves the joined line alone.
427
- begin
428
- summary = DaySummary.build(store: store_dir, day: day, session: sid, home: plastic_home, now: Time.now)
429
- parts << summary unless summary.empty?
430
- rescue StandardError
431
- nil
432
- end
433
442
  rescue StandardError
434
- nil
435
- end unless subagent_session
443
+ # Anything unhandled above (a malformed INDEX.md, a broken projects.yml, or
444
+ # any other surprise) degrades the whole boot to the core banner alone,
445
+ # never a naked crash with no JSON at all (intent 341, G8, row 2.4).
446
+ parts = [core_banner, ""]
447
+ end
436
448
 
437
- # Emit nothing when there is genuinely nothing to surface (no conventions,
449
+ # Emit nothing when there is genuinely nothing to surface (no active intent,
438
450
  # no deprecations, no update notice).
439
451
  exit 0 if parts.join.strip.empty?
440
452