@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.
- package/PLASTIC.md +20 -18
- package/bin/test +24 -4
- package/hooks/call-budget +4 -0
- package/hooks/hooks.json +12 -0
- package/package.json +1 -1
- package/scripts/doctor.rb +79 -4
- package/scripts/hook-call-budget +222 -0
- package/scripts/hook-session-start +307 -319
- package/scripts/insight-append +18 -4
- package/scripts/lib/compact_instructions.rb +5 -5
- package/scripts/lib/doctor_core.rb +2 -1
- package/scripts/lib/graph_edges.rb +16 -0
- package/scripts/lib/hook_registry.rb +14 -2
- package/scripts/lib/installer_core.rb +13 -5
- package/scripts/lib/meter_watch.rb +179 -0
- package/scripts/lib/node_packet.rb +27 -5
- package/scripts/lib/runner_dispatch.rb +29 -5
- package/scripts/lib/runner_policy.rb +31 -0
- package/scripts/lib/runner_proposals.rb +21 -0
- package/scripts/lib/session_usage.rb +190 -0
- package/scripts/meter-watch +57 -0
- package/scripts/read-config +3 -3
- package/scripts/runner +5 -0
- package/scripts/session-usage +56 -0
- package/scripts/skill-lint +115 -6
- package/skills/auto/SKILL.md +61 -63
- 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 +39 -134
- 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
- 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
|
-
# ---
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
#
|
|
229
|
-
#
|
|
230
|
-
#
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
#
|
|
235
|
-
#
|
|
236
|
-
#
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
+
File.expand_path("~/.plastic/scripts/read-config")
|
|
275
139
|
end
|
|
276
140
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
316
|
-
trail += " | Details: #{link}" if link
|
|
317
|
-
parts << trail
|
|
269
|
+
parts << banner
|
|
318
270
|
end
|
|
319
271
|
end
|
|
320
|
-
end
|
|
321
272
|
|
|
322
|
-
if
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
327
|
-
#
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|