@zalom/plastic 2.0.0-alpha.1 → 2.0.0-alpha.3
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/bin/lib/context_budget.rb +453 -0
- package/bin/plastic-bench +78 -0
- package/hooks/savepoint +5 -5
- package/package.json +1 -1
- package/scripts/append-ledger +16 -3
- package/scripts/day-summary +53 -0
- package/scripts/hook-capture +2 -1
- package/scripts/hook-close +3 -1
- package/scripts/hook-savepoint +45 -0
- package/scripts/hook-session-start +11 -0
- package/scripts/lib/compact_instructions.rb +56 -0
- package/scripts/lib/day_summary.rb +206 -0
- package/scripts/lib/doctor_core.rb +48 -0
- package/scripts/lib/handoff.rb +184 -0
- package/scripts/lib/installer_core.rb +85 -10
- package/scripts/lib/session_close.rb +22 -2
- package/scripts/read-config +3 -0
- package/scripts/rollback.rb +6 -0
- package/scripts/write-handoff +60 -0
- package/skills/intent-continuing/SKILL.md +3 -2
- package/templates/config.yml +5 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "date"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "json"
|
|
7
|
+
require "open3"
|
|
8
|
+
require "rbconfig"
|
|
9
|
+
require "tmpdir"
|
|
10
|
+
require "yaml"
|
|
11
|
+
|
|
12
|
+
# ContextBudget (intent 313): the measurement behind Plastic's two ruled context
|
|
13
|
+
# numbers. Intent 296 ruled the core block under 8,192 bytes and the whole
|
|
14
|
+
# per-boot doctrine read under 15,000, and until this module both were estimates
|
|
15
|
+
# in a design document. Everything here is measured: the bench builds a fixture
|
|
16
|
+
# home by running the real installer into a temporary HOME, runs the real
|
|
17
|
+
# `scripts/hook-session-start` against it N times, and reports what a boot
|
|
18
|
+
# actually costs.
|
|
19
|
+
#
|
|
20
|
+
# Maintainer tool. It lives under bin/ beside bin/test, is never registered in
|
|
21
|
+
# installer_core.rb, and is never installed into ~/.plastic: it reads repo
|
|
22
|
+
# fixtures, so it has no meaning on an installed copy.
|
|
23
|
+
#
|
|
24
|
+
# Hermetic and DI throughout: every path is a keyword argument, the boot
|
|
25
|
+
# subprocess's environment is a pure function of the fixture, and the runner is
|
|
26
|
+
# injectable. Nothing reads the real ~/.plastic or ~/.claude, and nothing here
|
|
27
|
+
# touches the network.
|
|
28
|
+
module ContextBudget
|
|
29
|
+
# The two ruled ceilings (intent 296) plus the one ratchet intent 313 adds.
|
|
30
|
+
#
|
|
31
|
+
# core PLASTIC.md, the always-on core block. 296's ruling.
|
|
32
|
+
# boot the additionalContext hook-session-start emits. 296's
|
|
33
|
+
# whole-read ruling, enforced on the only quantity that is
|
|
34
|
+
# actually read on every boot and can be measured exactly.
|
|
35
|
+
# boot_plus_catalog boot injection plus the skill catalog the harness loads.
|
|
36
|
+
# Not a ruling: a 313 ratchet over the measured 16,537, so
|
|
37
|
+
# the second-largest per-boot cost cannot regrow unwatched.
|
|
38
|
+
# Lower it as the catalog shrinks; never raise it.
|
|
39
|
+
CEILINGS = { core: 8_192, boot: 15_000, boot_plus_catalog: 17_500 }.freeze
|
|
40
|
+
|
|
41
|
+
# The doctrine working set (boot + _decision-tables.md + the median skill body)
|
|
42
|
+
# is reported against this target, never enforced: its median term steps by
|
|
43
|
+
# about a kilobyte whenever a skill is added or removed, so a suite that went
|
|
44
|
+
# red on that step would enforce nothing anybody ruled. The gap is printed.
|
|
45
|
+
WORKING_SET_TARGET = 15_000
|
|
46
|
+
|
|
47
|
+
DEFAULT_REPEAT = 5
|
|
48
|
+
|
|
49
|
+
# Fixed inputs. The stale-intent line renders an age, so the fixture's future
|
|
50
|
+
# intents are created this many days before *today*: the rendered "(30 days)"
|
|
51
|
+
# is then a constant instead of a number that drifts with the calendar.
|
|
52
|
+
FIXTURE_STALE_DAYS = 30
|
|
53
|
+
FIXTURE_SESSION_ID = "plastic-context-bench"
|
|
54
|
+
|
|
55
|
+
# What the fixture deliberately leaves out of the measurement, printed with the
|
|
56
|
+
# table so a reader knows what the number does not cover.
|
|
57
|
+
EXCLUSIONS = [
|
|
58
|
+
"the update notice and the prior-day sweep line (both transient, absent from a steady-state boot)",
|
|
59
|
+
"the QMD status line (PATH carries only the running interpreter's directory, so qmd is unfindable on any host)",
|
|
60
|
+
"the harness's own system prompt and tool schemas (not Plastic's, and not readable from here)",
|
|
61
|
+
].freeze
|
|
62
|
+
|
|
63
|
+
Measurement = Struct.new(:lines, :words, :tokens, :bytes, :tokens_by_bytes)
|
|
64
|
+
|
|
65
|
+
# The word-based token estimate is skill_lint.rb:104's arithmetic exactly, so
|
|
66
|
+
# the bench and skill-lint can never report different numbers for one file.
|
|
67
|
+
# bytes / 4 is a second, independent estimate printed for cross-check. Neither
|
|
68
|
+
# is a tokenizer; both are deterministic and offline.
|
|
69
|
+
def self.measure(body)
|
|
70
|
+
words = body.split(/\s+/).reject(&:empty?).length
|
|
71
|
+
Measurement.new(body.lines.count, words, (words * 1.3).round,
|
|
72
|
+
body.bytesize, (body.bytesize / 4.0).round)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# skill_lint.rb:82-90's split, so a skill's frontmatter is counted once (in the
|
|
76
|
+
# catalog row) and its body once (in the median-body row), never both.
|
|
77
|
+
def self.split_skill(content)
|
|
78
|
+
parts = content.split("---", 3)
|
|
79
|
+
return [nil, content] if parts.length < 3
|
|
80
|
+
|
|
81
|
+
[parts[1], parts[2]]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.skill_paths(repo:)
|
|
85
|
+
Dir.glob(File.join(repo, "skills", "*", "SKILL.md")).sort
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# What the harness loads at boot: every skill's name and description VALUES,
|
|
89
|
+
# YAML-parsed. Not the raw frontmatter (that would count the keys and the
|
|
90
|
+
# operational fields), and not a line regex (that would truncate a folded
|
|
91
|
+
# description at its first line).
|
|
92
|
+
def self.skill_catalog_bytes(repo:)
|
|
93
|
+
skill_paths(repo: repo).sum do |path|
|
|
94
|
+
frontmatter, = split_skill(File.read(path))
|
|
95
|
+
data = YAML.safe_load(frontmatter.to_s, permitted_classes: [Date, Time], aliases: true) || {}
|
|
96
|
+
data["name"].to_s.bytesize + data["description"].to_s.bytesize
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.skill_body_sizes(repo:)
|
|
101
|
+
skill_paths(repo: repo).map do |path|
|
|
102
|
+
_frontmatter, body = split_skill(File.read(path))
|
|
103
|
+
body.bytesize
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.median(values)
|
|
108
|
+
return 0 if values.empty?
|
|
109
|
+
|
|
110
|
+
sorted = values.sort
|
|
111
|
+
middle = sorted.length / 2
|
|
112
|
+
return sorted[middle] if sorted.length.odd?
|
|
113
|
+
|
|
114
|
+
((sorted[middle - 1] + sorted[middle]) / 2.0).round
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A fixed Plastic home: a real install, then a fixed store on top of it.
|
|
118
|
+
#
|
|
119
|
+
# The install matters. A fixture without ~/.claude fails
|
|
120
|
+
# Doctor#check_agent_registration (doctor_core.rb:307-314), which short-circuits
|
|
121
|
+
# the rest of the core checks and renders the degraded banner, measuring a boot
|
|
122
|
+
# no real session sees. Running the real installer costs about a quarter of a
|
|
123
|
+
# second and gives `doctor --core run: success`.
|
|
124
|
+
Fixture = Struct.new(:home, :plastic_home, :index, :project_dir, keyword_init: true) do
|
|
125
|
+
def self.build(dir:, repo:, today: Date.today)
|
|
126
|
+
ContextBudget.build_fixture(dir: dir, repo: repo, today: today)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.build_fixture(dir:, repo:, today: Date.today)
|
|
131
|
+
home = File.realpath(dir)
|
|
132
|
+
plastic_home = File.join(home, ".plastic")
|
|
133
|
+
FileUtils.mkdir_p(File.join(home, ".claude"))
|
|
134
|
+
install_into(home: home, plastic_home: plastic_home, repo: repo)
|
|
135
|
+
|
|
136
|
+
project_dir = File.join(home, "project")
|
|
137
|
+
FileUtils.mkdir_p(project_dir)
|
|
138
|
+
# On macOS Dir.pwd resolves /var to /private/var. The hook compares Dir.pwd
|
|
139
|
+
# against the registered project path with start_with?, so an unresolved path
|
|
140
|
+
# silently loses the project banner from the measured context.
|
|
141
|
+
project_dir = File.realpath(project_dir)
|
|
142
|
+
|
|
143
|
+
write_global_store(plastic_home: plastic_home, today: today)
|
|
144
|
+
write_project_store(plastic_home: plastic_home, project_dir: project_dir, today: today)
|
|
145
|
+
|
|
146
|
+
Fixture.new(home: home, plastic_home: plastic_home,
|
|
147
|
+
index: File.join(plastic_home, "INDEX.md"), project_dir: project_dir)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def self.install_into(home:, plastic_home:, repo:)
|
|
151
|
+
installer = File.join(repo, "scripts", "install.rb")
|
|
152
|
+
raise "install: #{installer} not found; #{repo} is not a Plastic checkout" unless File.file?(installer)
|
|
153
|
+
|
|
154
|
+
env = { "HOME" => home, "PLASTIC_HOME" => plastic_home, "RUBYOPT" => nil }
|
|
155
|
+
out, err, status = Open3.capture3(env, RbConfig.ruby, installer, "--claude", chdir: repo)
|
|
156
|
+
return if status.success?
|
|
157
|
+
|
|
158
|
+
raise "install failed (exit #{status.exitstatus}): #{err.strip}#{out.strip}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Intent ids are written one call per intent, never as one array literal:
|
|
162
|
+
# packaging_no_store_ids_test.rb flags any shipped literal carrying five or
|
|
163
|
+
# more digit-leading tokens, and bin/ is inside package.json's files set.
|
|
164
|
+
def self.write_global_store(plastic_home:, today:)
|
|
165
|
+
store = File.join(plastic_home, "store")
|
|
166
|
+
write_intent(store, "0001", "a-global-intent-in-flight", 1, today)
|
|
167
|
+
write_intent(store, "0002", "a-parked-global-intent", FIXTURE_STALE_DAYS, today)
|
|
168
|
+
write_intent(store, "0003", "another-parked-global-intent", FIXTURE_STALE_DAYS, today)
|
|
169
|
+
|
|
170
|
+
File.write(File.join(plastic_home, "INDEX.md"), <<~MD)
|
|
171
|
+
# Index
|
|
172
|
+
|
|
173
|
+
## Active
|
|
174
|
+
#{index_line("0001", "a-global-intent-in-flight", "a global intent that is being delivered right now")}
|
|
175
|
+
|
|
176
|
+
## Future
|
|
177
|
+
#{index_line("0002", "a-parked-global-intent", "a parked global intent waiting on a ruling")}
|
|
178
|
+
#{index_line("0003", "another-parked-global-intent", "another parked global intent waiting on a ruling")}
|
|
179
|
+
MD
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def self.write_project_store(plastic_home:, project_dir:, today:)
|
|
183
|
+
project_root = File.join(plastic_home, "projects", "fixture")
|
|
184
|
+
store = File.join(project_root, "store")
|
|
185
|
+
write_intent(store, "0100", "a-project-intent-in-flight", 1, today)
|
|
186
|
+
write_intent(store, "0101", "a-parked-project-intent", FIXTURE_STALE_DAYS, today)
|
|
187
|
+
|
|
188
|
+
File.write(File.join(project_root, "INDEX.md"), <<~MD)
|
|
189
|
+
# Index
|
|
190
|
+
|
|
191
|
+
## Active
|
|
192
|
+
#{index_line("0100", "a-project-intent-in-flight", "a project intent that is being delivered right now")}
|
|
193
|
+
|
|
194
|
+
## Future
|
|
195
|
+
#{index_line("0101", "a-parked-project-intent", "a parked project intent waiting for a decision")}
|
|
196
|
+
MD
|
|
197
|
+
|
|
198
|
+
registration = { "projects" => { "fixture" => { "path" => project_dir, "parent" => nil } } }
|
|
199
|
+
File.write(File.join(plastic_home, "projects.yml"), YAML.dump(registration))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def self.index_line(id, slug, title)
|
|
203
|
+
"- [#{id} — #{title}](store/#{id}--#{slug}/#{id}--#{slug}.md)"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def self.write_intent(store, id, slug, age_days, today)
|
|
207
|
+
dir = File.join(store, "#{id}--#{slug}")
|
|
208
|
+
FileUtils.mkdir_p(dir)
|
|
209
|
+
File.write(File.join(dir, "#{id}--#{slug}.md"), <<~MD)
|
|
210
|
+
---
|
|
211
|
+
id: "#{id}"
|
|
212
|
+
created: #{(today - age_days).iso8601}
|
|
213
|
+
author: bench
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## Intent
|
|
217
|
+
A fixed fixture intent, so the bench measures the same boot every time.
|
|
218
|
+
MD
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# The child environment is a pure function of the fixture, so a test can assert
|
|
222
|
+
# containment without running anything.
|
|
223
|
+
#
|
|
224
|
+
# PATH is exactly the running interpreter's directory, and that is load-bearing
|
|
225
|
+
# twice. The hook backticks scripts/read-config three times and read-config's
|
|
226
|
+
# shebang is `#!/usr/bin/env ruby`, so a PATH carrying /usr/bin would run those
|
|
227
|
+
# three reads under the system Ruby while the report named a different one. And
|
|
228
|
+
# with nothing else on PATH, `qmd` cannot be found on any host, so the QMD
|
|
229
|
+
# status line never appears and the measurement reproduces off this machine.
|
|
230
|
+
def self.child_env(fixture)
|
|
231
|
+
{
|
|
232
|
+
"HOME" => fixture.home,
|
|
233
|
+
"PLASTIC_HOME" => fixture.plastic_home,
|
|
234
|
+
"PLASTIC_TMP" => File.join(fixture.home, "tmp"),
|
|
235
|
+
"CLAUDE_CODE_SESSION_ID" => FIXTURE_SESSION_ID,
|
|
236
|
+
"PATH" => File.dirname(RbConfig.ruby),
|
|
237
|
+
"RUBYOPT" => nil,
|
|
238
|
+
}
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
DEFAULT_RUNNER = lambda do |env, *command, **options|
|
|
242
|
+
Open3.capture3(env, *command, **options)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Runs the real hook once. Returns [additionalContext, elapsed_ms]. A failed or
|
|
246
|
+
# empty boot raises rather than scoring as a small, passing number.
|
|
247
|
+
def self.boot(fixture:, repo:, runner: DEFAULT_RUNNER)
|
|
248
|
+
hook = File.join(repo, "scripts", "hook-session-start")
|
|
249
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
250
|
+
out, err, status = runner.call(child_env(fixture), RbConfig.ruby, hook,
|
|
251
|
+
fixture.index, fixture.plastic_home, "global", repo,
|
|
252
|
+
chdir: fixture.project_dir)
|
|
253
|
+
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(1)
|
|
254
|
+
|
|
255
|
+
raise "boot failed (exit #{status.exitstatus}): #{err.strip}" unless status.success?
|
|
256
|
+
raise "boot wrote to stderr: #{err.strip}" unless err.to_s.strip.empty?
|
|
257
|
+
|
|
258
|
+
context = JSON.parse(out).dig("hookSpecificOutput", "additionalContext").to_s
|
|
259
|
+
raise "boot emitted no additionalContext" if context.empty?
|
|
260
|
+
|
|
261
|
+
[context, elapsed_ms]
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
Sample = Struct.new(:bytes, :ms, keyword_init: true)
|
|
265
|
+
|
|
266
|
+
Row = Struct.new(:key, :label, :bytes, :tokens, :tokens_by_bytes, :ceiling, :target, keyword_init: true) do
|
|
267
|
+
def enforced?
|
|
268
|
+
!ceiling.nil?
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# The ceiling is a strict bound: "under 8,192" means 8,192 itself is over.
|
|
272
|
+
def over?
|
|
273
|
+
enforced? && bytes >= ceiling
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def headroom
|
|
277
|
+
enforced? ? ceiling - bytes : nil
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def gap
|
|
281
|
+
target.nil? ? nil : bytes - target
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
Report = Struct.new(:rows, :samples, :context, :repeat, :fragment, :ruby_version, :ruby_bin, keyword_init: true) do
|
|
286
|
+
def row(key)
|
|
287
|
+
rows.find { |candidate| candidate.key == key }
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def byte_spread
|
|
291
|
+
samples.map(&:bytes).max - samples.map(&:bytes).min
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def failures
|
|
295
|
+
crossed = rows.select(&:over?).map do |candidate|
|
|
296
|
+
"#{candidate.key} (#{candidate.label}) is #{candidate.bytes} bytes; ceiling #{candidate.ceiling}"
|
|
297
|
+
end
|
|
298
|
+
return crossed if byte_spread.zero?
|
|
299
|
+
|
|
300
|
+
crossed + ["byte spread across #{repeat} repeats is #{byte_spread}, expected 0"]
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def ok?
|
|
304
|
+
failures.empty?
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def to_table
|
|
308
|
+
ContextBudget.render(self)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def self.run(repo:, repeat: DEFAULT_REPEAT, core_file: nil, dir: nil, today: Date.today)
|
|
313
|
+
unless repeat.is_a?(Integer) && repeat >= 1
|
|
314
|
+
raise ArgumentError, "repeat must be an integer of at least 1 (got #{repeat.inspect})"
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
return report_for(dir: dir, repo: repo, repeat: repeat, core_file: core_file, today: today) if dir
|
|
318
|
+
|
|
319
|
+
Dir.mktmpdir("plastic-context-bench") do |tmp|
|
|
320
|
+
report_for(dir: tmp, repo: repo, repeat: repeat, core_file: core_file, today: today)
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def self.report_for(dir:, repo:, repeat:, core_file:, today:)
|
|
325
|
+
fixture = Fixture.build(dir: dir, repo: repo, today: today)
|
|
326
|
+
# --core-file swaps the core block so a crossed ceiling can be observed
|
|
327
|
+
# without editing a real file. check_core_files runs with include_drift:
|
|
328
|
+
# false, so the swap does not change the banner.
|
|
329
|
+
FileUtils.cp(core_file, File.join(fixture.plastic_home, "PLASTIC.md")) if core_file
|
|
330
|
+
|
|
331
|
+
contexts = []
|
|
332
|
+
samples = repeat.times.map do
|
|
333
|
+
context, elapsed_ms = boot(fixture: fixture, repo: repo)
|
|
334
|
+
contexts << context
|
|
335
|
+
Sample.new(bytes: context.bytesize, ms: elapsed_ms)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
Report.new(rows: build_rows(fixture: fixture, repo: repo, context: contexts.first),
|
|
339
|
+
samples: samples, context: contexts.first, repeat: repeat,
|
|
340
|
+
fragment: fragment_bytes(repo: repo),
|
|
341
|
+
ruby_version: RUBY_VERSION, ruby_bin: RbConfig.ruby)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def self.build_rows(fixture:, repo:, context:)
|
|
345
|
+
core = measure(File.read(File.join(fixture.plastic_home, "PLASTIC.md")))
|
|
346
|
+
boot_measurement = measure(context)
|
|
347
|
+
catalog = measure(skill_catalog_text(repo: repo))
|
|
348
|
+
bodies = skill_body_sizes(repo: repo)
|
|
349
|
+
median_body = median(bodies)
|
|
350
|
+
fragment = fragment_bytes(repo: repo)
|
|
351
|
+
|
|
352
|
+
combined = boot_measurement.bytes + catalog.bytes
|
|
353
|
+
working_set = boot_measurement.bytes + fragment + median_body
|
|
354
|
+
|
|
355
|
+
# tokens(w) is a word count of a real body, so the rows that are arithmetic
|
|
356
|
+
# over other rows (a sum, a median) print "-" there rather than a number that
|
|
357
|
+
# looks measured and is not. Every row still carries bytes and bytes / 4.
|
|
358
|
+
[
|
|
359
|
+
row(:core, "core block (PLASTIC.md)", core.bytes, tokens: core.tokens, ceiling: CEILINGS[:core]),
|
|
360
|
+
row(:boot, "boot injection (SessionStart additionalContext)", boot_measurement.bytes,
|
|
361
|
+
tokens: boot_measurement.tokens, ceiling: CEILINGS[:boot]),
|
|
362
|
+
row(:skill_catalog, "skill catalog (#{bodies.length} name + description values)",
|
|
363
|
+
catalog.bytes, tokens: catalog.tokens),
|
|
364
|
+
row(:boot_plus_catalog, "boot injection + skill catalog", combined,
|
|
365
|
+
ceiling: CEILINGS[:boot_plus_catalog]),
|
|
366
|
+
row(:median_skill_body, "median skill body (of #{bodies.length})", median_body),
|
|
367
|
+
row(:working_set, "doctrine working set (boot + fragment + median body)",
|
|
368
|
+
working_set, target: WORKING_SET_TARGET),
|
|
369
|
+
]
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# The catalog as one body, so its word-token estimate is measured the same way
|
|
373
|
+
# every other body's is.
|
|
374
|
+
def self.skill_catalog_text(repo:)
|
|
375
|
+
skill_paths(repo: repo).map do |path|
|
|
376
|
+
frontmatter, = split_skill(File.read(path))
|
|
377
|
+
data = YAML.safe_load(frontmatter.to_s, permitted_classes: [Date, Time], aliases: true) || {}
|
|
378
|
+
"#{data["name"]}#{data["description"]}"
|
|
379
|
+
end.join
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def self.fragment_bytes(repo:)
|
|
383
|
+
path = File.join(repo, "skills", "_decision-tables.md")
|
|
384
|
+
File.file?(path) ? File.size(path) : 0
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def self.row(key, label, bytes, tokens: nil, ceiling: nil, target: nil)
|
|
388
|
+
Row.new(key: key, label: label, bytes: bytes, tokens: tokens,
|
|
389
|
+
tokens_by_bytes: (bytes / 4.0).round, ceiling: ceiling, target: target)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def self.render(report)
|
|
393
|
+
byte_samples = report.samples.map(&:bytes)
|
|
394
|
+
ms_samples = report.samples.map(&:ms)
|
|
395
|
+
|
|
396
|
+
lines = []
|
|
397
|
+
lines << "Plastic context budget bench (intent 313)"
|
|
398
|
+
lines << ""
|
|
399
|
+
lines << " ruby #{report.ruby_version} (#{report.ruby_bin})"
|
|
400
|
+
lines << " repeats #{report.repeat} boot bytes min/median/max #{stat_line(byte_samples)}"
|
|
401
|
+
lines << " time ms min/median/max #{stat_line(ms_samples)} - indicative only, never a pass/fail signal"
|
|
402
|
+
lines << " fixture a real `scripts/install.rb --claude` into a temporary HOME, then a fixed store"
|
|
403
|
+
lines << " (1 active + 2 future global intents, 1 active + 1 future project intents)"
|
|
404
|
+
lines << " estimator words * 1.3 (skill-lint's arithmetic) as tokens(w); bytes / 4 as tokens(b) - neither is a tokenizer"
|
|
405
|
+
lines << ""
|
|
406
|
+
lines << format(" %-52s %8s %9s %9s %9s %9s", "row", "bytes", "tokens(w)", "tokens(b)", "ceiling", "headroom")
|
|
407
|
+
|
|
408
|
+
report.rows.each do |current|
|
|
409
|
+
ceiling = current.enforced? ? current.ceiling.to_s : "reported"
|
|
410
|
+
headroom = current.enforced? ? current.headroom.to_s : "-"
|
|
411
|
+
lines << format(" %-52s %8d %9s %9d %9s %9s",
|
|
412
|
+
current.label, current.bytes, current.tokens || "-", current.tokens_by_bytes,
|
|
413
|
+
ceiling, headroom)
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
working_set = report.row(:working_set)
|
|
417
|
+
if working_set&.target
|
|
418
|
+
lines << ""
|
|
419
|
+
lines << " The doctrine working set is reported against intent 296's ruled target of " \
|
|
420
|
+
"#{working_set.target} bytes, not enforced:"
|
|
421
|
+
lines << " it stands at #{working_set.bytes} (#{format('%+d', working_set.gap)} against the target), " \
|
|
422
|
+
"where the fragment is #{report.fragment} bytes."
|
|
423
|
+
lines << " Its median term steps by about a kilobyte whenever a skill is added or removed; " \
|
|
424
|
+
"the skill bodies are the gap."
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
lines << ""
|
|
428
|
+
lines << " Not counted:"
|
|
429
|
+
EXCLUSIONS.each { |exclusion| lines << " - #{exclusion}" }
|
|
430
|
+
|
|
431
|
+
lines << ""
|
|
432
|
+
if report.ok?
|
|
433
|
+
lines << " PASS - every ceiling holds and the #{report.repeat} repeats are byte-identical."
|
|
434
|
+
else
|
|
435
|
+
lines << " FAIL"
|
|
436
|
+
report.failures.each { |failure| lines << " - #{failure}" }
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
lines.join("\n") + "\n"
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def self.stat_line(values)
|
|
443
|
+
sorted = values.sort
|
|
444
|
+
"#{sorted.first}/#{median_of_samples(sorted)}/#{sorted.last}"
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def self.median_of_samples(sorted)
|
|
448
|
+
middle = sorted.length / 2
|
|
449
|
+
return sorted[middle] if sorted.length.odd?
|
|
450
|
+
|
|
451
|
+
((sorted[middle - 1] + sorted[middle]) / 2.0).round(1)
|
|
452
|
+
end
|
|
453
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# Plastic context budget bench (intent 313). Measures what a session boot costs
|
|
6
|
+
# in context: the core block, the SessionStart injection, the skill catalog the
|
|
7
|
+
# harness loads beside it, and the doctrine working set. Exits non-zero when a
|
|
8
|
+
# ceiling is crossed or when repeated boots of a fixed fixture disagree.
|
|
9
|
+
#
|
|
10
|
+
# Maintainer tool: it reads this repository's own files and a fixture home it
|
|
11
|
+
# builds with the real installer, so it is never installed into ~/.plastic.
|
|
12
|
+
#
|
|
13
|
+
# Usage: bin/plastic-bench [--repeat N] [--core-file PATH] [--repo PATH]
|
|
14
|
+
#
|
|
15
|
+
# --repeat N boots to run (default 5, minimum 1)
|
|
16
|
+
# --core-file PATH measure this file as the core block instead of the repo's
|
|
17
|
+
# PLASTIC.md; the way a crossed ceiling is proved observable
|
|
18
|
+
# without editing a real file
|
|
19
|
+
# --repo PATH the Plastic checkout to measure (default: this one)
|
|
20
|
+
#
|
|
21
|
+
# Exit codes: 0 every ceiling holds, 1 a ceiling was crossed, 2 bad usage.
|
|
22
|
+
|
|
23
|
+
require_relative "lib/context_budget"
|
|
24
|
+
|
|
25
|
+
USAGE = <<~TEXT
|
|
26
|
+
usage: plastic-bench [--repeat N] [--core-file PATH] [--repo PATH]
|
|
27
|
+
|
|
28
|
+
--repeat N boots to run against the fixture (default #{ContextBudget::DEFAULT_REPEAT}, minimum 1)
|
|
29
|
+
--core-file PATH measure this file as the core block instead of PLASTIC.md
|
|
30
|
+
--repo PATH the Plastic checkout to measure (default: this checkout)
|
|
31
|
+
--help this message
|
|
32
|
+
|
|
33
|
+
Exits 0 when every ceiling holds, 1 when one is crossed, 2 on bad usage.
|
|
34
|
+
TEXT
|
|
35
|
+
|
|
36
|
+
def fail_usage(message)
|
|
37
|
+
warn "plastic-bench: #{message}"
|
|
38
|
+
warn USAGE
|
|
39
|
+
exit 2
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
repeat = ContextBudget::DEFAULT_REPEAT
|
|
43
|
+
core_file = nil
|
|
44
|
+
repo = File.expand_path("..", __dir__)
|
|
45
|
+
|
|
46
|
+
argv = ARGV.dup
|
|
47
|
+
until argv.empty?
|
|
48
|
+
case (flag = argv.shift)
|
|
49
|
+
when "--help", "-h"
|
|
50
|
+
puts USAGE
|
|
51
|
+
exit 0
|
|
52
|
+
when "--repeat"
|
|
53
|
+
value = argv.shift
|
|
54
|
+
fail_usage("--repeat needs a whole number of at least 1") unless value.to_s.match?(/\A\d+\z/)
|
|
55
|
+
repeat = value.to_i
|
|
56
|
+
fail_usage("--repeat needs a whole number of at least 1") if repeat < 1
|
|
57
|
+
when "--core-file"
|
|
58
|
+
core_file = argv.shift
|
|
59
|
+
fail_usage("--core-file needs a path") if core_file.to_s.empty?
|
|
60
|
+
fail_usage("--core-file #{core_file} does not exist") unless File.file?(core_file)
|
|
61
|
+
when "--repo"
|
|
62
|
+
repo = argv.shift
|
|
63
|
+
fail_usage("--repo needs a path") if repo.to_s.empty?
|
|
64
|
+
fail_usage("--repo #{repo} is not a directory") unless File.directory?(repo)
|
|
65
|
+
else
|
|
66
|
+
fail_usage("unknown argument #{flag}")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
begin
|
|
71
|
+
report = ContextBudget.run(repo: repo, repeat: repeat, core_file: core_file)
|
|
72
|
+
rescue StandardError => error
|
|
73
|
+
warn "plastic-bench: #{error.message}"
|
|
74
|
+
exit 1
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
print report.to_table
|
|
78
|
+
exit(report.ok? ? 0 : 1)
|
package/hooks/savepoint
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
INPUT=""
|
|
3
|
+
[ -t 0 ] || INPUT=$(cat)
|
|
4
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
5
|
+
echo "$INPUT" | env -u RUBYOPT ruby "$SCRIPT_DIR/../scripts/hook-savepoint" "$HOME/.plastic"
|
|
6
|
+
exit 0
|
package/package.json
CHANGED
package/scripts/append-ledger
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
# 3 - promote/tick could not take an exclusive lock (filesystem without flock support)
|
|
32
32
|
|
|
33
33
|
require_relative "lib/session_ledger"
|
|
34
|
+
require_relative "lib/handoff"
|
|
34
35
|
|
|
35
36
|
VERBS = %w[pending item promote tick savepoint].freeze
|
|
36
37
|
|
|
@@ -129,10 +130,21 @@ def run_transition(from, to, event, store, day, session, project, opts)
|
|
|
129
130
|
exit 2
|
|
130
131
|
end
|
|
131
132
|
|
|
132
|
-
|
|
133
|
+
if opts[:savepoint]
|
|
134
|
+
line = SessionLedger.savepoint_line(event, session, project, target_summary, now: Time.now)
|
|
135
|
+
SessionLedger.append_line(SessionLedger.savepoint_path(store, day), line, header: nil)
|
|
136
|
+
end
|
|
133
137
|
|
|
134
|
-
|
|
135
|
-
|
|
138
|
+
return unless to == :done
|
|
139
|
+
|
|
140
|
+
# The hand-off at every tick (intent 311, spec D4): derived and
|
|
141
|
+
# regenerable, so a failure here never fails the tick that was recorded.
|
|
142
|
+
begin
|
|
143
|
+
Handoff.write(store: store, day: day, session: session, trigger: "tick",
|
|
144
|
+
templates: opts[:templates] || default_templates_dir)
|
|
145
|
+
rescue StandardError
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
136
148
|
end
|
|
137
149
|
|
|
138
150
|
def run_savepoint(store, day, session, project, opts)
|
|
@@ -153,6 +165,7 @@ def main(argv)
|
|
|
153
165
|
store = opts[:store] ? expand(opts[:store]) : File.join(plastic_home, "store")
|
|
154
166
|
templates = opts[:templates] ? expand(opts[:templates]) : default_templates_dir
|
|
155
167
|
usage_abort("templates dir not found: #{templates}") unless Dir.exist?(templates)
|
|
168
|
+
opts[:templates] = templates
|
|
156
169
|
cwd = opts[:cwd] ? expand(opts[:cwd]) : Dir.pwd
|
|
157
170
|
|
|
158
171
|
day = opts[:day] || SessionLedger.day_id
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# day-summary (intent 311): print the block SessionStart injects at boot
|
|
6
|
+
# (open items, the last five done, live auto intents, other active
|
|
7
|
+
# sessions). Prints nothing when there is nothing to say.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# day-summary [--store <dir>] [--day <YYYYMMDD>] [--session <id>] [--home <dir>]
|
|
11
|
+
#
|
|
12
|
+
# Defaults: the home is $PLASTIC_HOME (~/.plastic), the store is <home>/store,
|
|
13
|
+
# the day is today (local wall clock), the session is $CLAUDE_CODE_SESSION_ID.
|
|
14
|
+
#
|
|
15
|
+
# Exit codes: 0 done; 2 usage error.
|
|
16
|
+
|
|
17
|
+
require_relative "lib/session_ledger"
|
|
18
|
+
require_relative "lib/day_summary"
|
|
19
|
+
|
|
20
|
+
def usage_abort(message)
|
|
21
|
+
warn "day-summary: #{message}"
|
|
22
|
+
exit 2
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def expand(path)
|
|
26
|
+
File.expand_path(path.to_s.sub(/\A~/, Dir.home))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def parse_args(argv)
|
|
30
|
+
opts = {}
|
|
31
|
+
i = 0
|
|
32
|
+
while i < argv.length
|
|
33
|
+
flag = argv[i]
|
|
34
|
+
usage_abort("unknown argument #{flag.inspect}") unless flag.start_with?("--")
|
|
35
|
+
usage_abort("#{flag} requires a value") if i + 1 >= argv.length
|
|
36
|
+
key = flag.delete_prefix("--").to_sym
|
|
37
|
+
usage_abort("unknown flag #{flag.inspect}") unless %i[store day session home].include?(key)
|
|
38
|
+
opts[key] = argv[i + 1]
|
|
39
|
+
i += 2
|
|
40
|
+
end
|
|
41
|
+
opts
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
opts = parse_args(ARGV)
|
|
45
|
+
home = opts[:home] ? expand(opts[:home]) : expand(ENV.fetch("PLASTIC_HOME", "~/.plastic"))
|
|
46
|
+
store = opts[:store] ? expand(opts[:store]) : File.join(home, "store")
|
|
47
|
+
day = opts[:day] || SessionLedger.day_id
|
|
48
|
+
usage_abort("--day must be eight digits parsing as a real date, got #{day.inspect}") unless SessionLedger.valid_day_id?(day)
|
|
49
|
+
session = SessionLedger.short_session_id(opts[:session], ENV["CLAUDE_CODE_SESSION_ID"])
|
|
50
|
+
|
|
51
|
+
text = DaySummary.build(store: store, day: day, session: session, home: home)
|
|
52
|
+
puts text unless text.empty?
|
|
53
|
+
exit 0
|
package/scripts/hook-capture
CHANGED
|
@@ -31,7 +31,8 @@ end
|
|
|
31
31
|
exit 0 unless payload.is_a?(Hash)
|
|
32
32
|
|
|
33
33
|
session_id = payload["session_id"].to_s
|
|
34
|
-
prompt = payload["
|
|
34
|
+
prompt = payload["prompt"].to_s
|
|
35
|
+
prompt = payload["user_prompt"].to_s if prompt.empty?
|
|
35
36
|
cwd = payload["cwd"].to_s
|
|
36
37
|
cwd = Dir.pwd if cwd.empty?
|
|
37
38
|
|
package/scripts/hook-close
CHANGED
|
@@ -25,9 +25,11 @@ payload =
|
|
|
25
25
|
|
|
26
26
|
store = File.join(File.expand_path(plastic_home), "store")
|
|
27
27
|
filer = File.expand_path("file-session-intent", __dir__)
|
|
28
|
+
templates = File.expand_path("../templates", __dir__)
|
|
28
29
|
begin
|
|
29
30
|
SessionClose.run(payload: payload, store: store, today: SessionLedger.day_id,
|
|
30
|
-
spawner: SessionClose.default_spawner(filer)
|
|
31
|
+
spawner: SessionClose.default_spawner(filer),
|
|
32
|
+
handoff: SessionClose.default_handoff(templates))
|
|
31
33
|
rescue StandardError
|
|
32
34
|
nil
|
|
33
35
|
end
|