@zalom/plastic 2.0.0-alpha.1 → 2.0.0-alpha.10
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/hooks.json +12 -0
- package/hooks/message-display +81 -0
- package/hooks/savepoint +5 -5
- package/package.json +1 -1
- package/scripts/agent-report +8 -2
- package/scripts/append-ledger +16 -3
- package/scripts/dashboard.rb +39 -10
- package/scripts/day-summary +53 -0
- package/scripts/doctor.rb +163 -0
- package/scripts/end-intent +93 -0
- package/scripts/hook-capture +21 -8
- package/scripts/hook-close +3 -1
- package/scripts/hook-message-display +74 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-savepoint +45 -0
- package/scripts/hook-session-start +34 -1
- package/scripts/intent-screen +77 -0
- package/scripts/lib/arm.rb +26 -1
- package/scripts/lib/compact_instructions.rb +56 -0
- package/scripts/lib/day_summary.rb +211 -0
- package/scripts/lib/doctor_core.rb +52 -3
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/handoff.rb +184 -0
- package/scripts/lib/hook_registry.rb +14 -0
- package/scripts/lib/installer_core.rb +117 -11
- package/scripts/lib/intent_screen.rb +309 -0
- package/scripts/lib/intent_screen_ansi.rb +262 -0
- package/scripts/lib/message_display.rb +290 -0
- package/scripts/lib/report_screen.rb +648 -0
- package/scripts/lib/savepoint.rb +14 -0
- package/scripts/lib/screen_paint.rb +276 -0
- package/scripts/lib/session_close.rb +22 -2
- package/scripts/lib/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +124 -0
- package/scripts/plastic-lock +8 -1
- package/scripts/read-config +3 -0
- package/scripts/report-screen +120 -0
- package/scripts/rollback.rb +6 -0
- package/scripts/savepoint-note +67 -0
- package/scripts/spawn-preamble +9 -2
- package/scripts/write-handoff +60 -0
- package/skills/auto/SKILL.md +13 -8
- package/skills/auto/references/human-report-contract.md +59 -53
- package/skills/conventions/references/locks-and-worktrees.md +12 -0
- package/skills/intent-continuing/SKILL.md +31 -22
- package/skills/intent-continuing/references/boarding-matrix.md +5 -5
- package/skills/intent-continuing/references/context-management.md +1 -1
- package/skills/intent-ending/SKILL.md +8 -2
- package/skills/intent-executing/SKILL.md +6 -0
- package/templates/config.yml +5 -0
- package/templates/intent-screen.md +17 -0
- package/templates/outcome.md +14 -1
- package/templates/report-state.md +11 -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/hooks.json
CHANGED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# hooks/message-display (intent 316a, O6, round 3 concurrency fix): the
|
|
3
|
+
# MessageDisplay launcher. Fires on every streamed chunk of every assistant
|
|
4
|
+
# message (D11), so the common case — an ordinary chunk of an ordinary
|
|
5
|
+
# message — must decide with shell builtins alone and fork nothing. Only a
|
|
6
|
+
# candidate message hands off to Ruby (scripts/hook-message-display), which
|
|
7
|
+
# is the one place allowed to do real work.
|
|
8
|
+
#
|
|
9
|
+
# No command substitution, no backticks, no sed/jq/cat: case, [, parameter
|
|
10
|
+
# expansion and printf are all builtins. Deliberately does NOT copy hooks/
|
|
11
|
+
# capture's SCRIPT_DIR-via-subshell pattern (cd into dirname of $0, inside a
|
|
12
|
+
# command substitution, then pwd) — that forks a subshell on every single
|
|
13
|
+
# invocation, which is exactly the cost this hook cannot carry.
|
|
14
|
+
#
|
|
15
|
+
# A live run under a real pty found Claude Code fires these chunk processes
|
|
16
|
+
# CONCURRENTLY: a chunk with index > 0 can arrive, and be judged here,
|
|
17
|
+
# before chunk 0 ever runs. The OLD hand-off test — "does a buffer already
|
|
18
|
+
# exist for this message" — answered no in that race and silently dropped
|
|
19
|
+
# the chunk before Ruby ever saw it, no matter what MessageDisplay's own
|
|
20
|
+
# (correct) polling logic would have done. So a later chunk is now also
|
|
21
|
+
# handed off when its OWN delta looks like it could be part of a screen
|
|
22
|
+
# (leading "|" or "**Steps**", or blank — the same cheap test Ruby itself
|
|
23
|
+
# uses to decide whether a wait is worth paying for), and the final chunk is
|
|
24
|
+
# ALWAYS handed off, whatever it looks like, since it is the one that must
|
|
25
|
+
# not race. Ruby is the one place that actually waits (bounded, injectable
|
|
26
|
+
# for tests); this script only ever decides once, fast, and never sleeps.
|
|
27
|
+
#
|
|
28
|
+
# Claude adapter: Claude Code only; the core is harness-agnostic.
|
|
29
|
+
IFS= read -r -d '' INPUT # returns 1 at EOF: do NOT set -e
|
|
30
|
+
case $INPUT in *'"message_id"'*) ;; *) exit 0 ;; esac
|
|
31
|
+
rest=${INPUT#*\"message_id\"}; rest=${rest#*\"}; mid=${rest%%\"*}
|
|
32
|
+
rest=${INPUT#*\"session_id\"}; rest=${rest#*\"}; sid=${rest%%\"*}
|
|
33
|
+
|
|
34
|
+
SCRIPT_DIR=${0%/*}
|
|
35
|
+
TMP_ROOT=${PLASTIC_TMP:-${TMPDIR:-/tmp}}
|
|
36
|
+
export PLASTIC_TMP="$TMP_ROOT"
|
|
37
|
+
MSGDIR="$TMP_ROOT/plastic-message-display/$sid/$mid"
|
|
38
|
+
|
|
39
|
+
is_index_zero=0
|
|
40
|
+
case $INPUT in *'"index":0'*|*'"index": 0'*) is_index_zero=1 ;; esac
|
|
41
|
+
|
|
42
|
+
is_final=0
|
|
43
|
+
case $INPUT in *'"final":true'*|*'"final": true'*) is_final=1 ;; esac
|
|
44
|
+
|
|
45
|
+
handoff=0
|
|
46
|
+
if [ "$is_index_zero" = 1 ]; then
|
|
47
|
+
# Chunk 0 decides synchronously; a bare "#" first delta, in either JSON
|
|
48
|
+
# spacing, is the only shape that can possibly open a screen. Must be a
|
|
49
|
+
# single "#", not "##" — a real screen's own first delta can be as short
|
|
50
|
+
# as "## " — a "##" glob would filter out exactly the message this hook
|
|
51
|
+
# exists to recognize.
|
|
52
|
+
# 317a (B11): a screen can open with "## " (state, delivered), a bare "▶"
|
|
53
|
+
# (roster), or a bare "✔" (delay); the JSON may carry the glyph raw or
|
|
54
|
+
# \u-escaped depending on the encoder. All globs are literal bytes - case
|
|
55
|
+
# matching is byte-wise, so multibyte literals are safe under bash 3.2.
|
|
56
|
+
case $INPUT in
|
|
57
|
+
*'"delta":"#'*|*'"delta": "#'*) handoff=1 ;;
|
|
58
|
+
*'"delta":"▶'*|*'"delta": "▶'*) handoff=1 ;;
|
|
59
|
+
*'"delta":"✔'*|*'"delta": "✔'*) handoff=1 ;;
|
|
60
|
+
*'"delta":"▶'*|*'"delta": "▶'*) handoff=1 ;;
|
|
61
|
+
*'"delta":"✔'*|*'"delta": "✔'*) handoff=1 ;;
|
|
62
|
+
esac
|
|
63
|
+
else
|
|
64
|
+
# A later chunk: hand off when this message's directory already exists
|
|
65
|
+
# (chunk 0 already left a decision or a chunk file), when this chunk is
|
|
66
|
+
# final (it must always be checked, whatever it looks like), or when its
|
|
67
|
+
# own delta is shaped like part of a screen — a Markdown table row, the
|
|
68
|
+
# "**Steps**" heading, or a blank line, in either JSON spacing.
|
|
69
|
+
[ -d "$MSGDIR" ] && handoff=1
|
|
70
|
+
[ "$is_final" = 1 ] && handoff=1
|
|
71
|
+
case $INPUT in
|
|
72
|
+
*'"delta":"|'*|*'"delta": "|'*) handoff=1 ;;
|
|
73
|
+
*'"delta":"**'*|*'"delta": "**'*) handoff=1 ;;
|
|
74
|
+
*'"delta":""'*|*'"delta": ""'*) handoff=1 ;;
|
|
75
|
+
*'"delta":"\n"'*|*'"delta": "\n"'*) handoff=1 ;;
|
|
76
|
+
esac
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
[ "$handoff" = 1 ] || exit 0
|
|
80
|
+
|
|
81
|
+
printf '%s' "$INPUT" | env -u RUBYOPT ruby "$SCRIPT_DIR/../scripts/hook-message-display"
|
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/agent-report
CHANGED
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
# Exit codes: 0 (report emitted), 2 (usage).
|
|
23
23
|
|
|
24
24
|
require_relative "lib/savepoint"
|
|
25
|
+
require_relative "lib/intent_screen"
|
|
26
|
+
|
|
25
27
|
def parse_args(argv)
|
|
26
28
|
role = nil
|
|
27
29
|
positional = []
|
|
@@ -67,11 +69,15 @@ STAGE_LABELS = {
|
|
|
67
69
|
"exec" => "Exec", "done" => "Done"
|
|
68
70
|
}.freeze
|
|
69
71
|
|
|
72
|
+
# Intent 317, D6: name the last LIFECYCLE line, never a trailing Lock/Review/
|
|
73
|
+
# Commit line (same guard as spawn-preamble; plan review finding B1).
|
|
70
74
|
def current_stage(intent_dir)
|
|
71
75
|
ledger = File.join(intent_dir, Savepoint::SAVEPOINT_FILE)
|
|
72
76
|
if File.exist?(ledger)
|
|
73
|
-
|
|
74
|
-
|
|
77
|
+
lines = File.read(ledger).each_line.map(&:strip).reject(&:empty?)
|
|
78
|
+
lifecycle = lines.select { |l| IntentScreen.lifecycle_line?(l) }
|
|
79
|
+
return lifecycle.last if lifecycle.any?
|
|
80
|
+
return lines.last if lines.any?
|
|
75
81
|
end
|
|
76
82
|
STAGE_LABELS.fetch(Savepoint.derive_stage(intent_dir), Savepoint.derive_stage(intent_dir))
|
|
77
83
|
end
|