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