@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.
- package/PLASTIC.md +20 -18
- package/package.json +1 -1
- package/scripts/doctor.rb +79 -4
- package/scripts/hook-session-start +293 -321
- package/scripts/insight-append +18 -4
- package/scripts/lib/installer_core.rb +3 -3
- package/scripts/skill-lint +115 -6
- package/skills/auto/SKILL.md +53 -55
- package/skills/auto/references/agent-architecture.md +10 -8
- package/skills/auto/references/human-report-contract.md +1 -1
- package/skills/conventions/references/completion-and-done.md +7 -7
- package/skills/conventions/references/locks-and-worktrees.md +3 -3
- package/skills/conventions/references/maintenance-and-revisions.md +1 -1
- package/skills/doctor/report.md +1 -1
- package/skills/intent-continuing/references/boarding-matrix.md +2 -2
- package/skills/intent-creating/SKILL.md +58 -133
- package/skills/intent-ending/SKILL.md +48 -56
- package/skills/intent-ending/evals/evals.json +1 -1
- package/skills/intent-executing/SKILL.md +38 -137
- package/skills/intent-speccing/SKILL.md +3 -0
- package/skills/releasing/SKILL.md +1 -1
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/tutorial/SKILL.md +2 -1
- package/skills/tutorial/references/track-1-guided.md +21 -40
- package/skills/tutorial/references/track-2-auto.md +2 -2
- package/templates/agents.md +2 -2
|
@@ -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
|
-
# ---
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
#
|
|
245
|
-
#
|
|
246
|
-
#
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
#
|
|
251
|
-
#
|
|
252
|
-
#
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
+
File.expand_path("~/.plastic/scripts/read-config")
|
|
291
139
|
end
|
|
292
140
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
332
|
-
trail += " | Details: #{link}" if link
|
|
333
|
-
parts << trail
|
|
269
|
+
parts << banner
|
|
334
270
|
end
|
|
335
271
|
end
|
|
336
|
-
end
|
|
337
272
|
|
|
338
|
-
if
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
343
|
-
#
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
435
|
-
|
|
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
|
|
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
|
|