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

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.
@@ -8,12 +8,12 @@ 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
17
 
18
18
  index_path, plastic_home, mode, plugin_root = ARGV
19
19
  exit 0 unless index_path && plastic_home && mode
@@ -58,141 +58,10 @@ end
58
58
  # doubled store/ segment.
59
59
  store_dir = File.join(plastic_home, "store")
60
60
 
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
61
+ # --- Current version ---
62
+ # Read early, before any of the richer INDEX/project parsing below, so the
63
+ # core banner (the one thing every session must get) never depends on
64
+ # anything that can raise.
196
65
 
197
66
  current_version = nil
198
67
  version_file = "#{plastic_home}/VERSION"
@@ -206,23 +75,6 @@ elsif plugin_root && !plugin_root.empty?
206
75
  end
207
76
  end
208
77
 
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
78
  # --- Run core health in-process (intent 36a) ---
227
79
  # Reuse Doctor's own --core checks so there is one source of truth for "core
228
80
  # health" (no second process spawn, no duplicated check list). Never blocks the
@@ -235,206 +87,326 @@ rescue
235
87
  end
236
88
  core_banner = BootBanner.render(health: core_health, version: current_version)
237
89
 
238
- # --- Assemble session context ---
239
-
240
- all_active = active + project_active
241
- all_future = future + project_future
242
-
243
90
  # --- 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.
91
+ # The core banner is the one thing a session always gets. Everything below it
92
+ # (the project/global banner with its active intent, the QMD line,
93
+ # deprecations, the update notice, the first-boot sweep, the day ledger) is
94
+ # best-effort: intent 341, G8 wraps all of it in one rescue so any exception
95
+ # anywhere in that assembly degrades to a banner-only boot, never nothing.
96
+ # That is the same "as today" contract every individual sub-block already
97
+ # kept on its own, made a whole-block guarantee now that the doctrine dump
98
+ # this used to gate everything on is cut (PLASTIC.md's conventions prose, the
99
+ # active-intents listing, the stage line, the stale-future list), since a
100
+ # skill or the conventions chapter already carries that text.
101
+
102
+ parts = [core_banner, ""]
103
+
261
104
  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."
105
+ # --- Parse INDEX.md ---
106
+
107
+ lines = File.readlines(index_path)
108
+ active = []
109
+ future = []
110
+ section = nil
111
+
112
+ lines.each do |line|
113
+ if line.start_with?("## Active")
114
+ section = :active
115
+ next
116
+ elsif line.start_with?("## Future")
117
+ section = :future
118
+ next
119
+ elsif line.start_with?("## ")
120
+ section = nil
121
+ next
269
122
  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}/"
123
+
124
+ next unless section && line.strip.start_with?("- [")
125
+
126
+ if section == :active
127
+ active << line.strip
128
+ elsif section == :future
129
+ future << line.strip
287
130
  end
288
- parts << banner + "\n"
131
+ end
132
+
133
+ # --- read-config path (used below for author and dismissed deprecations) ---
134
+
135
+ read_config = if plugin_root && !plugin_root.empty?
136
+ "#{plugin_root}/scripts/read-config"
289
137
  else
290
- parts << "PLASTIC — Global store loaded from ~/.plastic/\n"
138
+ File.expand_path("~/.plastic/scripts/read-config")
291
139
  end
292
140
 
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 << ""
141
+ # --- Detect current project ---
142
+
143
+ current_project = nil
144
+ project_active = []
145
+ project_future = []
146
+
147
+ projects_path = "#{plastic_home}/projects.yml"
148
+ if File.exist?(projects_path)
149
+ projects = YAML.safe_load(File.read(projects_path)) rescue {}
150
+ cwd = Dir.pwd
151
+ (projects["projects"] || {}).each do |slug, info|
152
+ project_path = File.expand_path(info["path"])
153
+ if cwd.start_with?(project_path)
154
+ current_project = { "slug" => slug, "parent" => info["parent"], "path" => project_path }
155
+ project_index = "#{plastic_home}/projects/#{slug}/INDEX.md"
156
+ if File.exist?(project_index)
157
+ p_section = nil
158
+ File.readlines(project_index).each do |pline|
159
+ if pline.start_with?("## Active")
160
+ p_section = :active
161
+ next
162
+ elsif pline.start_with?("## Future")
163
+ p_section = :future
164
+ next
165
+ elsif pline.start_with?("## ")
166
+ p_section = nil
167
+ next
168
+ end
169
+ next unless p_section && pline.strip.start_with?("- [")
170
+ project_active << pline.strip if p_section == :active
171
+ project_future << pline.strip if p_section == :future
172
+ end
173
+ end
174
+ break
175
+ end
176
+ end
177
+ end
178
+
179
+ # --- Load PLASTIC.md conventions ---
180
+ # Its content no longer renders at boot (intent 341, G8): the gate stays,
181
+ # so a partial install without PLASTIC.md still renders no project banner,
182
+ # but the text itself is never read into `parts`.
183
+
184
+ plastic_md_path = "#{plastic_home}/PLASTIC.md"
185
+ plastic_md = File.exist?(plastic_md_path) ? File.read(plastic_md_path).strip : nil
186
+
187
+ # --- Load deprecations ---
188
+
189
+ dep_file = if plugin_root && !plugin_root.empty?
190
+ "#{plugin_root}/deprecations.yml"
298
191
  else
299
- parts << "No active intents. #{future.length} future intents available.\n"
192
+ "#{plastic_home}/deprecations.yml"
193
+ end
194
+
195
+ deprecations = []
196
+ if File.exist?(dep_file)
197
+ dep_data = YAML.safe_load(File.read(dep_file)) rescue {}
198
+ deprecations = dep_data["deprecations"] || []
199
+ end
200
+
201
+ dismissed_json = `"#{read_config}" deprecations_dismissed`.strip
202
+ dismissed = begin
203
+ JSON.parse(dismissed_json)
204
+ rescue
205
+ []
206
+ end
207
+
208
+ active_deprecations = deprecations.select do |dep|
209
+ next true if dep["severity"] == "critical"
210
+ next true if current_version && dep["removal"] == current_version
211
+ !dismissed.include?(dep["id"])
300
212
  end
301
213
 
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"
214
+ # --- Check for available updates (from previous session's check) ---
215
+
216
+ update_notice = nil
217
+ cache_file = "#{plastic_home}/.cache/update-check.json"
218
+ if File.exist?(cache_file)
219
+ cache = JSON.parse(File.read(cache_file)) rescue {}
220
+ if cache["updateAvailable"]
221
+ update_notice = "Plastic update available: #{cache["current"]} -> #{cache["latest"]} — run /plastic-update"
306
222
  end
307
- parts << "\nWhen appropriate, ask the user what to do with stale intents."
308
223
  end
309
- end
310
224
 
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
225
+ # --- QMD search status (intent 45a, READ-ONLY) ---
226
+ # Report-only line for the model (additionalContext), never systemMessage and
227
+ # never the exit code. QmdSync.status shells out to `qmd collection list`, so the
228
+ # whole block is guarded: a 2s timeout caps any hang, and rescue-all guarantees a
229
+ # slow/broken/missing qmd appends nothing and the hook continues cleanly.
230
+ begin
231
+ require "timeout"
232
+ qmd_status = Timeout.timeout(2) { QmdSync.status(plastic_home: plastic_home) }
233
+ if qmd_status[:present]
234
+ # C23 (intent 341, G8): a QMD hit is untrusted text, so it never sits
235
+ # loose in additionalContext alongside the owner's own banners; it
236
+ # rides inside PacketWrapper's data boundary.
237
+ qmd_line = if qmd_status[:all_registered]
238
+ "QMD: #{qmd_status[:registered].size} Plastic collections indexed (search with the qmd skill)."
239
+ else
240
+ "QMD detected — run `qmd-sync register --all` to index your Plastic stores for search."
241
+ end
242
+ token = PacketWrapper.boundary_token([qmd_line])
243
+ parts << PacketWrapper.wrap(qmd_line, label: "qmd-hit", source: "qmd-sync", token: token).rstrip
244
+ end
245
+ rescue Exception
246
+ # Any failure (timeout, missing binary, parse error) — stay silent, never crash.
247
+ end unless subagent_session
248
+
249
+ # --- Project (or global) banner with its active intent ---
250
+ # The one piece of intent context a live boot still carries: which store
251
+ # is live, and which intent (if any) is active in it (intent 341, G8, D4).
252
+ if plastic_md && !subagent_session
253
+ if current_project
254
+ slug = current_project["slug"]
255
+ banner = "Project: #{slug} | Store: ~/.plastic/projects/#{slug}/store/"
256
+ if project_active.any? && project_active.first =~ /\[([^\]]+)\].*store\/([\w-]+)\//
257
+ intent_name, dir_name = $1, $2
258
+ intent_id = dir_name.split("--").first
259
+ banner += "\nActive: [#{intent_id} — #{intent_name}] | Artifacts → store/#{dir_name}/"
260
+ end
261
+ parts << banner
324
262
  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}" }
263
+ banner = "PLASTIC Global store loaded from ~/.plastic/"
264
+ if active.any? && active.first =~ /\[([^\]]+)\].*store\/([\w-]+)\//
265
+ intent_name, dir_name = $1, $2
266
+ intent_id = dir_name.split("--").first
267
+ banner += "\nActive: [#{intent_id} #{intent_name}] | Artifacts → store/#{dir_name}/"
330
268
  end
331
- trail = " Removed in: #{removal}"
332
- trail += " | Details: #{link}" if link
333
- parts << trail
269
+ parts << banner
334
270
  end
335
271
  end
336
- end
337
272
 
338
- if update_notice && !subagent_session
339
- parts.unshift("! #{update_notice}\n")
340
- end
273
+ if active_deprecations.any? && !subagent_session
274
+ parts << ""
275
+ active_deprecations.each do |dep|
276
+ severity = dep["severity"] || "info"
277
+ summary = dep["summary"] || dep["id"]
278
+ removal = dep["removal"]
279
+ link = dep["link"]
280
+ steps = dep["migration_steps"] || []
281
+
282
+ if severity == "info"
283
+ line = "i Deprecation: #{summary}. Removed in: #{removal}."
284
+ line += " See: #{link}" if link
285
+ parts << line
286
+ else
287
+ marker = severity == "critical" ? "!! DEPRECATION (critical)" : "! DEPRECATION (warning)"
288
+ parts << "#{marker}: #{summary}"
289
+ if steps.any?
290
+ parts << " Migration steps:"
291
+ steps.each_with_index { |s, i| parts << " #{i + 1}. #{s}" }
292
+ end
293
+ trail = " Removed in: #{removal}"
294
+ trail += " | Details: #{link}" if link
295
+ parts << trail
296
+ end
297
+ end
298
+ end
341
299
 
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
300
+ if update_notice && !subagent_session
301
+ parts.unshift("! #{update_notice}\n")
361
302
  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
303
 
304
+ # --- First-boot sweep (intent 301, spec D9): file every unclosed prior day
305
+ # ledger into today, oldest first, at most 3 per boot within a 5-second
306
+ # budget. Each day runs the sibling file-session-intent in its own rescue;
307
+ # the boot never blocks on it.
308
+ unless subagent_session
367
309
  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)
310
+ require "open3"
311
+ require "timeout"
312
+ require_relative "lib/session_backfill"
313
+
314
+ sweep_today = SessionLedger.day_id
315
+ sweep_root = SessionLedger.sessions_root(store_dir)
316
+ sweep_filer = File.expand_path("file-session-intent", __dir__)
317
+ sweep_templates = File.expand_path("../templates", __dir__)
318
+ candidates = []
319
+ if Dir.exist?(sweep_root) && File.exist?(sweep_filer)
320
+ candidates = Dir.children(sweep_root).select do |name|
321
+ File.directory?(File.join(sweep_root, name)) && SessionLedger.valid_day_id?(name) &&
322
+ name < sweep_today && !SessionBackfill.closed?(store_dir, name)
323
+ end.sort
324
+ end
325
+ filed = 0
326
+ budget_end = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
327
+ candidates.first(3).each do |day|
328
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) > budget_end
329
+
330
+ begin
331
+ Timeout.timeout(5) do
332
+ _out, _err, sweep_status = Open3.capture3({ "RUBYOPT" => nil }, RbConfig.ruby, sweep_filer,
333
+ "--day", day, "--carry-to", sweep_today,
334
+ "--store", store_dir, "--templates", sweep_templates)
335
+ filed += 1 if sweep_status.success? && SessionBackfill.closed?(store_dir, day)
336
+ end
337
+ rescue StandardError
338
+ nil
339
+ end
340
+ end
341
+ if filed.positive?
342
+ line = "PLASTIC: filed #{filed} prior day ledger(s) into #{sweep_today}"
343
+ remaining = candidates.size - filed
344
+ line += ", #{remaining} more wait for the next boot" if remaining.positive?
345
+ parts << line
373
346
  end
374
347
  rescue StandardError
375
348
  nil
376
349
  end
377
350
  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
351
 
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
352
+ # --- Session day ledger: open or join today, write the per-session pointer
353
+ # and heartbeat (intent 298, spec D4). Best-effort: any failure here degrades
354
+ # to no ledger context line, never blocks the session.
355
+ unless subagent_session
356
+ begin
357
+ templates = File.expand_path("../templates", __dir__)
358
+ day = SessionLedger.day_id
359
+ author = `"#{read_config}" author`.strip
360
+ author = "session" if author.empty?
361
+ SessionLedger.open_day(store: store_dir, day: day, templates: templates, author: author)
362
+
363
+ session = if !payload_session_id.empty?
364
+ payload_session_id
365
+ else
366
+ ENV["CLAUDE_CODE_SESSION_ID"] || Process.pid.to_s
367
+ end
368
+ sid = SessionLedger.short_session_id(nil, session)
369
+ SessionLedger.ensure_tmp_root(store_dir)
370
+ FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store_dir, sid))
371
+ File.write(SessionLedger.heartbeat_path(store_dir, sid), "#{Time.now.utc.iso8601}\n")
372
+ pointer = SessionLedger.pointer_path(store_dir, sid)
373
+ File.write(pointer, "#{day}\n") unless File.exist?(pointer)
374
+
375
+ checklist = SessionLedger.checklist_path(store_dir, day)
376
+ open_count = 0
377
+ pending_count = 0
378
+ if File.exist?(checklist)
379
+ File.readlines(checklist).each do |line|
380
+ parsed = SessionLedger.parse_checklist_line(line)
381
+ next unless parsed
382
+
383
+ open_count += 1 if parsed[:state] == :open
384
+ pending_count += 1 if parsed[:state] == :pending
385
+ end
386
+ end
387
+ parts << "PLASTIC: day ledger #{day} joined (#{open_count} open items, #{pending_count} pending)"
388
+
389
+ # The day summary (intent 311, spec D8): open items, the last five done,
390
+ # live auto intents, other active sessions. Never the raw ledger. A
391
+ # failure here leaves the joined line alone.
392
+ begin
393
+ summary = DaySummary.build(store: store_dir, day: day, session: sid, home: plastic_home, now: Time.now)
394
+ parts << summary unless summary.empty?
395
+ rescue StandardError
396
+ nil
397
+ end
398
+ rescue StandardError
399
+ nil
420
400
  end
421
401
  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
402
  rescue StandardError
434
- nil
435
- end unless subagent_session
403
+ # Anything unhandled above (a malformed INDEX.md, a broken projects.yml, or
404
+ # any other surprise) degrades the whole boot to the core banner alone,
405
+ # never a naked crash with no JSON at all (intent 341, G8, row 2.4).
406
+ parts = [core_banner, ""]
407
+ end
436
408
 
437
- # Emit nothing when there is genuinely nothing to surface (no conventions,
409
+ # Emit nothing when there is genuinely nothing to surface (no active intent,
438
410
  # no deprecations, no update notice).
439
411
  exit 0 if parts.join.strip.empty?
440
412