@zalom/plastic 2.0.0-alpha.20 → 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.
Files changed (45) hide show
  1. package/PLASTIC.md +20 -18
  2. package/bin/test +24 -4
  3. package/hooks/call-budget +4 -0
  4. package/hooks/hooks.json +12 -0
  5. package/package.json +1 -1
  6. package/scripts/doctor.rb +79 -4
  7. package/scripts/hook-call-budget +222 -0
  8. package/scripts/hook-session-start +307 -319
  9. package/scripts/insight-append +18 -4
  10. package/scripts/lib/compact_instructions.rb +5 -5
  11. package/scripts/lib/doctor_core.rb +2 -1
  12. package/scripts/lib/graph_edges.rb +16 -0
  13. package/scripts/lib/hook_registry.rb +14 -2
  14. package/scripts/lib/installer_core.rb +13 -5
  15. package/scripts/lib/meter_watch.rb +179 -0
  16. package/scripts/lib/node_packet.rb +27 -5
  17. package/scripts/lib/runner_dispatch.rb +29 -5
  18. package/scripts/lib/runner_policy.rb +31 -0
  19. package/scripts/lib/runner_proposals.rb +21 -0
  20. package/scripts/lib/session_usage.rb +190 -0
  21. package/scripts/meter-watch +57 -0
  22. package/scripts/read-config +3 -3
  23. package/scripts/runner +5 -0
  24. package/scripts/session-usage +56 -0
  25. package/scripts/skill-lint +115 -6
  26. package/skills/auto/SKILL.md +61 -63
  27. package/skills/auto/references/agent-architecture.md +10 -8
  28. package/skills/auto/references/human-report-contract.md +1 -1
  29. package/skills/conventions/references/completion-and-done.md +7 -7
  30. package/skills/conventions/references/locks-and-worktrees.md +3 -3
  31. package/skills/conventions/references/maintenance-and-revisions.md +1 -1
  32. package/skills/doctor/report.md +1 -1
  33. package/skills/intent-continuing/references/boarding-matrix.md +2 -2
  34. package/skills/intent-creating/SKILL.md +58 -133
  35. package/skills/intent-ending/SKILL.md +48 -56
  36. package/skills/intent-ending/evals/evals.json +1 -1
  37. package/skills/intent-executing/SKILL.md +39 -134
  38. package/skills/intent-speccing/SKILL.md +3 -0
  39. package/skills/releasing/SKILL.md +1 -1
  40. package/skills/releasing/references/release-lines.md +1 -1
  41. package/skills/tutorial/SKILL.md +2 -1
  42. package/skills/tutorial/references/track-1-guided.md +21 -40
  43. package/skills/tutorial/references/track-2-auto.md +2 -2
  44. package/templates/agents.md +2 -2
  45. package/templates/config.yml +3 -3
@@ -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
@@ -36,147 +36,32 @@ rescue StandardError
36
36
  end
37
37
  payload_session_id = stdin_payload.is_a?(Hash) ? stdin_payload["session_id"].to_s : ""
38
38
 
39
+ # --- subagent marker (intent 355 spec D9, node n7; review fix n8, B6): the
40
+ # stdin payload carries agent_id only when this SessionStart call runs
41
+ # inside a spawned agent. agent_type alone is not enough: a live
42
+ # `claude --agent` session carries agent_type on every turn but never
43
+ # agent_id, so keying on agent_type would boot a live agent session with the
44
+ # core banner only. Read from that payload only, never from an environment
45
+ # variable, so a helper needs the core banner alone. An absent agent_id (the
46
+ # common case) is a live session. Any exception here still boots the banner,
47
+ # never nothing: the rescue falls back to a live session, whose own content
48
+ # already comes from paths this file already guards independently.
49
+ subagent_session = begin
50
+ stdin_payload.is_a?(Hash) && !!stdin_payload["agent_id"]
51
+ rescue StandardError
52
+ false
53
+ end
54
+
39
55
  # Plastic home and the store are two different paths (intent 231). The shim passes
40
56
  # home (~/.plastic) as argument 2; the store lives one level below it. Compose the
41
57
  # store exactly once here, so no later line re-derives it and no path can gain a
42
58
  # doubled store/ segment.
43
59
  store_dir = File.join(plastic_home, "store")
44
60
 
45
- # --- Parse INDEX.md ---
46
-
47
- lines = File.readlines(index_path)
48
- active = []
49
- future = []
50
- section = nil
51
-
52
- lines.each do |line|
53
- if line.start_with?("## Active")
54
- section = :active
55
- next
56
- elsif line.start_with?("## Future")
57
- section = :future
58
- next
59
- elsif line.start_with?("## ")
60
- section = nil
61
- next
62
- end
63
-
64
- next unless section && line.strip.start_with?("- [")
65
-
66
- if section == :active
67
- active << line.strip
68
- elsif section == :future
69
- future << line.strip
70
- end
71
- end
72
-
73
- # --- Stage of the single active intent (the /tmp bridge this once derived was removed in 2.0, intent 307) ---
74
-
75
- stage_line = nil
76
- if active.length == 1 && active.first =~ /store\/([\w-]+)\//
77
- dir_name = $1
78
- intent_dir = "#{store_dir}/#{dir_name}"
79
- begin
80
- stage = Savepoint.derive_stage(intent_dir)
81
- missing = Savepoint.missing_for_stage(stage, intent_dir)
82
- missing_str = missing.empty? ? "none" : missing.join(", ")
83
- stage_line = "Stage: #{stage} | Next: #{missing_str}"
84
- rescue StandardError
85
- stage_line = nil
86
- end
87
- end
88
-
89
- # --- Detect stale future intents ---
90
-
91
- stale = []
92
- read_config = if plugin_root && !plugin_root.empty?
93
- "#{plugin_root}/scripts/read-config"
94
- else
95
- File.expand_path("~/.plastic/scripts/read-config")
96
- end
97
- stale_days = `"#{read_config}" stale_threshold_days`.strip.to_i
98
- stale_days = 3 if stale_days == 0
99
-
100
- future.each do |f|
101
- if f =~ /store\/([\w-]+)\//
102
- dir_name = $1
103
- intent_file = "#{store_dir}/#{dir_name}/#{dir_name}.md"
104
- next unless File.exist?(intent_file)
105
-
106
- content = File.read(intent_file)
107
- if content =~ /^created:\s*['"]?(\d{4}-\d{2}-\d{2})/
108
- age = (Date.today - Date.parse($1)).to_i
109
- if age >= stale_days
110
- name = f[/\[([^\]]+)\]/, 1] || "unknown"
111
- stale << { name: name, age: age, entry: f }
112
- end
113
- end
114
- end
115
- end
116
-
117
- # --- Detect current project ---
118
-
119
- current_project = nil
120
- project_active = []
121
- project_future = []
122
-
123
- projects_path = "#{plastic_home}/projects.yml"
124
- if File.exist?(projects_path)
125
- projects = YAML.safe_load(File.read(projects_path)) rescue {}
126
- cwd = Dir.pwd
127
- (projects["projects"] || {}).each do |slug, info|
128
- project_path = File.expand_path(info["path"])
129
- if cwd.start_with?(project_path)
130
- current_project = { "slug" => slug, "parent" => info["parent"], "path" => project_path }
131
- project_index = "#{plastic_home}/projects/#{slug}/INDEX.md"
132
- if File.exist?(project_index)
133
- p_section = nil
134
- File.readlines(project_index).each do |pline|
135
- if pline.start_with?("## Active")
136
- p_section = :active
137
- next
138
- elsif pline.start_with?("## Future")
139
- p_section = :future
140
- next
141
- elsif pline.start_with?("## ")
142
- p_section = nil
143
- next
144
- end
145
- next unless p_section && pline.strip.start_with?("- [")
146
- project_active << pline.strip if p_section == :active
147
- project_future << pline.strip if p_section == :future
148
- end
149
- end
150
- break
151
- end
152
- end
153
- end
154
-
155
- # --- Load PLASTIC.md conventions ---
156
-
157
- plastic_md_path = "#{plastic_home}/PLASTIC.md"
158
- plastic_md = File.exist?(plastic_md_path) ? File.read(plastic_md_path).strip : nil
159
-
160
- # --- Load deprecations ---
161
-
162
- dep_file = if plugin_root && !plugin_root.empty?
163
- "#{plugin_root}/deprecations.yml"
164
- else
165
- "#{plastic_home}/deprecations.yml"
166
- end
167
-
168
- deprecations = []
169
- if File.exist?(dep_file)
170
- dep_data = YAML.safe_load(File.read(dep_file)) rescue {}
171
- deprecations = dep_data["deprecations"] || []
172
- end
173
-
174
- dismissed_json = `"#{read_config}" deprecations_dismissed`.strip
175
- dismissed = begin
176
- JSON.parse(dismissed_json)
177
- rescue
178
- []
179
- 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.
180
65
 
181
66
  current_version = nil
182
67
  version_file = "#{plastic_home}/VERSION"
@@ -190,23 +75,6 @@ elsif plugin_root && !plugin_root.empty?
190
75
  end
191
76
  end
192
77
 
193
- active_deprecations = deprecations.select do |dep|
194
- next true if dep["severity"] == "critical"
195
- next true if current_version && dep["removal"] == current_version
196
- !dismissed.include?(dep["id"])
197
- end
198
-
199
- # --- Check for available updates (from previous session's check) ---
200
-
201
- update_notice = nil
202
- cache_file = "#{plastic_home}/.cache/update-check.json"
203
- if File.exist?(cache_file)
204
- cache = JSON.parse(File.read(cache_file)) rescue {}
205
- if cache["updateAvailable"]
206
- update_notice = "Plastic update available: #{cache["current"]} -> #{cache["latest"]} — run /plastic-update"
207
- end
208
- end
209
-
210
78
  # --- Run core health in-process (intent 36a) ---
211
79
  # Reuse Doctor's own --core checks so there is one source of truth for "core
212
80
  # health" (no second process spawn, no duplicated check list). Never blocks the
@@ -219,206 +87,326 @@ rescue
219
87
  end
220
88
  core_banner = BootBanner.render(health: core_health, version: current_version)
221
89
 
222
- # --- Assemble session context ---
223
-
224
- all_active = active + project_active
225
- all_future = future + project_future
226
-
227
90
  # --- Build context ---
228
- # PLASTIC.md conventions and intent context render only when conventions are
229
- # installed. Deprecation warnings and update notices render regardless, so a
230
- # partial install (or a missing PLASTIC.md) still surfaces critical warnings.
231
-
232
- parts = []
233
-
234
- # Boot banner first: every session start surfaces that Plastic loaded, with the
235
- # version (clean) or the first failing core check (degraded). Owned by the hook
236
- # so it runs by construction the plastic-intent-continuing skill no longer does this.
237
- parts << core_banner
238
- parts << ""
239
-
240
- # --- QMD search status (intent 45a, READ-ONLY) ---
241
- # Report-only line for the model (additionalContext), never systemMessage and
242
- # never the exit code. QmdSync.status shells out to `qmd collection list`, so the
243
- # whole block is guarded: a 2s timeout caps any hang, and rescue-all guarantees a
244
- # 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
+
245
104
  begin
246
- require "timeout"
247
- qmd_status = Timeout.timeout(2) { QmdSync.status(plastic_home: plastic_home) }
248
- if qmd_status[:present]
249
- if qmd_status[:all_registered]
250
- parts << "QMD: #{qmd_status[:registered].size} Plastic collections indexed (search with the qmd skill)."
251
- else
252
- 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
253
122
  end
254
- end
255
- rescue Exception
256
- # Any failure (timeout, missing binary, parse error) — stay silent, never crash.
257
- end
258
123
 
259
- if plastic_md
260
- # Conventions always loaded first
261
- parts << plastic_md
262
- parts << "\n---\n"
263
-
264
- if current_project
265
- slug = current_project["slug"]
266
- banner = "Project: #{slug} | Store: ~/.plastic/projects/#{slug}/store/"
267
- if project_active.any? && project_active.first =~ /\[([^\]]+)\].*store\/([\w-]+)\//
268
- intent_name, dir_name = $1, $2
269
- intent_id = dir_name.split("--").first
270
- banner += "\nActive: [#{intent_id} — #{intent_name}] | Artifacts → store/#{dir_name}/"
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
271
130
  end
272
- 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"
273
137
  else
274
- parts << "PLASTIC — Global store loaded from ~/.plastic/\n"
138
+ File.expand_path("~/.plastic/scripts/read-config")
275
139
  end
276
140
 
277
- if all_active.any?
278
- parts << "Active intents:\n"
279
- all_active.each { |a| parts << a }
280
- parts << stage_line if stage_line
281
- 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"
282
191
  else
283
- 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"] || []
284
199
  end
285
200
 
286
- if stale.any?
287
- parts << "\nStale future intents (untouched for days):\n"
288
- stale.each do |s|
289
- parts << "- #{s[:name]} (#{s[:age]} days) — consider: activate, abandon, or defer to agent"
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"])
212
+ end
213
+
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"
290
222
  end
291
- parts << "\nWhen appropriate, ask the user what to do with stale intents."
292
223
  end
293
- end
294
224
 
295
- if active_deprecations.any?
296
- parts << ""
297
- active_deprecations.each do |dep|
298
- severity = dep["severity"] || "info"
299
- summary = dep["summary"] || dep["id"]
300
- removal = dep["removal"]
301
- link = dep["link"]
302
- steps = dep["migration_steps"] || []
303
-
304
- if severity == "info"
305
- line = "i Deprecation: #{summary}. Removed in: #{removal}."
306
- line += " See: #{link}" if link
307
- 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
308
262
  else
309
- marker = severity == "critical" ? "!! DEPRECATION (critical)" : "! DEPRECATION (warning)"
310
- parts << "#{marker}: #{summary}"
311
- if steps.any?
312
- parts << " Migration steps:"
313
- 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}/"
314
268
  end
315
- trail = " Removed in: #{removal}"
316
- trail += " | Details: #{link}" if link
317
- parts << trail
269
+ parts << banner
318
270
  end
319
271
  end
320
- end
321
272
 
322
- if update_notice
323
- parts.unshift("! #{update_notice}\n")
324
- 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
325
299
 
326
- # --- First-boot sweep (intent 301, spec D9): file every unclosed prior day
327
- # ledger into today, oldest first, at most 3 per boot within a 5-second
328
- # budget. Each day runs the sibling file-session-intent in its own rescue;
329
- # the boot never blocks on it.
330
- begin
331
- require "open3"
332
- require "timeout"
333
- require_relative "lib/session_backfill"
334
-
335
- sweep_today = SessionLedger.day_id
336
- sweep_root = SessionLedger.sessions_root(store_dir)
337
- sweep_filer = File.expand_path("file-session-intent", __dir__)
338
- sweep_templates = File.expand_path("../templates", __dir__)
339
- candidates = []
340
- if Dir.exist?(sweep_root) && File.exist?(sweep_filer)
341
- candidates = Dir.children(sweep_root).select do |name|
342
- File.directory?(File.join(sweep_root, name)) && SessionLedger.valid_day_id?(name) &&
343
- name < sweep_today && !SessionBackfill.closed?(store_dir, name)
344
- end.sort
300
+ if update_notice && !subagent_session
301
+ parts.unshift("! #{update_notice}\n")
345
302
  end
346
- filed = 0
347
- budget_end = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
348
- candidates.first(3).each do |day|
349
- break if Process.clock_gettime(Process::CLOCK_MONOTONIC) > budget_end
350
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
351
309
  begin
352
- Timeout.timeout(5) do
353
- _out, _err, sweep_status = Open3.capture3({ "RUBYOPT" => nil }, RbConfig.ruby, sweep_filer,
354
- "--day", day, "--carry-to", sweep_today,
355
- "--store", store_dir, "--templates", sweep_templates)
356
- 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
357
346
  end
358
347
  rescue StandardError
359
348
  nil
360
349
  end
361
350
  end
362
- if filed.positive?
363
- line = "PLASTIC: filed #{filed} prior day ledger(s) into #{sweep_today}"
364
- remaining = candidates.size - filed
365
- line += ", #{remaining} more wait for the next boot" if remaining.positive?
366
- parts << line
367
- end
368
- rescue StandardError
369
- nil
370
- end
371
351
 
372
- # --- Session day ledger: open or join today, write the per-session pointer
373
- # and heartbeat (intent 298, spec D4). Best-effort: any failure here degrades
374
- # to no ledger context line, never blocks the session.
375
- begin
376
- templates = File.expand_path("../templates", __dir__)
377
- day = SessionLedger.day_id
378
- author = `"#{read_config}" author`.strip
379
- author = "session" if author.empty?
380
- SessionLedger.open_day(store: store_dir, day: day, templates: templates, author: author)
381
-
382
- session = if !payload_session_id.empty?
383
- payload_session_id
384
- else
385
- ENV["CLAUDE_CODE_SESSION_ID"] || Process.pid.to_s
386
- end
387
- sid = SessionLedger.short_session_id(nil, session)
388
- SessionLedger.ensure_tmp_root(store_dir)
389
- FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store_dir, sid))
390
- File.write(SessionLedger.heartbeat_path(store_dir, sid), "#{Time.now.utc.iso8601}\n")
391
- pointer = SessionLedger.pointer_path(store_dir, sid)
392
- File.write(pointer, "#{day}\n") unless File.exist?(pointer)
393
-
394
- checklist = SessionLedger.checklist_path(store_dir, day)
395
- open_count = 0
396
- pending_count = 0
397
- if File.exist?(checklist)
398
- File.readlines(checklist).each do |line|
399
- parsed = SessionLedger.parse_checklist_line(line)
400
- next unless parsed
401
-
402
- open_count += 1 if parsed[:state] == :open
403
- 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
404
400
  end
405
401
  end
406
- parts << "PLASTIC: day ledger #{day} joined (#{open_count} open items, #{pending_count} pending)"
407
-
408
- # The day summary (intent 311, spec D8): open items, the last five done,
409
- # live auto intents, other active sessions. Never the raw ledger. A
410
- # failure here leaves the joined line alone.
411
- begin
412
- summary = DaySummary.build(store: store_dir, day: day, session: sid, home: plastic_home, now: Time.now)
413
- parts << summary unless summary.empty?
414
- rescue StandardError
415
- nil
416
- end
417
402
  rescue StandardError
418
- nil
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, ""]
419
407
  end
420
408
 
421
- # Emit nothing when there is genuinely nothing to surface (no conventions,
409
+ # Emit nothing when there is genuinely nothing to surface (no active intent,
422
410
  # no deprecations, no update notice).
423
411
  exit 0 if parts.join.strip.empty?
424
412