@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.
@@ -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
- cat <<'HOOKJSON'
3
- {
4
- "systemMessage": "PLASTIC SAVEPOINT — Context is being compacted.\n\nYou MUST perform the savepoint procedure NOW:\n1. Read .plastic/INDEX.md to find active intent(s)\n2. For each active intent in .plastic/store/ID--slug/:\n a. Update checklist.md (check off completed items)\n b. Create/update savepoint.md (in-progress work, next steps, blockers, discoveries)\n c. Update intent.md Build/Observe sections with session work\n3. Update .plastic/INDEX.md active section\n4. Commit: git add .plastic/ && git commit -m 'chore: savepoint — [intent name]'\n5. Tell the user: 'Context is getting large. I have saved progress to the active intent. Please run /clear and say continue to resume.'\n\nDO NOT skip this. The user's work depends on it."
5
- }
6
- HOOKJSON
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "2.0.0-alpha.1",
3
+ "version": "2.0.0-alpha.3",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- return unless opts[:savepoint]
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
- line = SessionLedger.savepoint_line(event, session, project, target_summary, now: Time.now)
135
- SessionLedger.append_line(SessionLedger.savepoint_path(store, day), line, header: nil)
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
@@ -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["user_prompt"].to_s
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
 
@@ -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