@zalom/plastic 1.14.1 → 2.0.0-alpha.2

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.
Files changed (200) hide show
  1. package/PLASTIC.md +101 -294
  2. package/README.md +5 -6
  3. package/agents/plastic-advisor.md +18 -17
  4. package/agents/plastic-enforcer.md +57 -69
  5. package/agents/plastic-executor.md +34 -20
  6. package/agents/plastic-faux-advisor.md +18 -18
  7. package/bin/lib/context_budget.rb +453 -0
  8. package/bin/plastic-bench +78 -0
  9. package/config_asks.yml +38 -0
  10. package/hooks/capture +4 -0
  11. package/hooks/close +5 -0
  12. package/hooks/hooks.json +9 -49
  13. package/hooks/record +4 -0
  14. package/hooks/savepoint +5 -5
  15. package/package.json +3 -2
  16. package/scripts/agent-report +8 -9
  17. package/scripts/append-ledger +208 -0
  18. package/scripts/codex-hook +78 -90
  19. package/scripts/dashboard.rb +4 -4
  20. package/scripts/day-summary +53 -0
  21. package/scripts/doctor.rb +106 -43
  22. package/scripts/end-intent +77 -76
  23. package/scripts/exec-worktree +11 -20
  24. package/scripts/file-session-intent +61 -0
  25. package/scripts/hook-capture +242 -0
  26. package/scripts/hook-close +36 -0
  27. package/scripts/hook-record +149 -0
  28. package/scripts/hook-savepoint +45 -0
  29. package/scripts/hook-session-start +106 -12
  30. package/scripts/install.rb +8 -6
  31. package/scripts/lib/agent_models.rb +1 -7
  32. package/scripts/lib/arm.rb +283 -0
  33. package/scripts/lib/backfill_intent.rb +316 -0
  34. package/scripts/lib/bridge.rb +24 -1597
  35. package/scripts/lib/compact_instructions.rb +56 -0
  36. package/scripts/lib/day_summary.rb +206 -0
  37. package/scripts/lib/doctor_core.rb +65 -88
  38. package/scripts/lib/doctor_session_ledger.rb +106 -0
  39. package/scripts/lib/exec_worktree.rb +14 -60
  40. package/scripts/lib/handoff.rb +184 -0
  41. package/scripts/lib/harness_text.rb +5 -4
  42. package/scripts/lib/hook_registry.rb +44 -107
  43. package/scripts/lib/insights.rb +3 -4
  44. package/scripts/lib/installer_core.rb +104 -40
  45. package/scripts/lib/lock.rb +6 -26
  46. package/scripts/lib/outcome_guard.rb +7 -5
  47. package/scripts/lib/project_validator.rb +30 -1
  48. package/scripts/lib/roadmap_queue.rb +3 -3
  49. package/scripts/lib/roadmap_savepoint.rb +1 -1
  50. package/scripts/lib/rule_catalog.rb +6 -3
  51. package/scripts/lib/savepoint.rb +320 -0
  52. package/scripts/lib/scaffold_intent.rb +13 -179
  53. package/scripts/lib/session_backfill.rb +184 -0
  54. package/scripts/lib/session_close.rb +104 -0
  55. package/scripts/lib/session_git.rb +469 -0
  56. package/scripts/lib/session_ledger.rb +497 -0
  57. package/scripts/lib/store_discovery.rb +1 -1
  58. package/scripts/lib/store_provisioning.rb +1 -1
  59. package/scripts/maintenance-run +7 -7
  60. package/scripts/new-intent +63 -4
  61. package/scripts/plastic-lock +94 -41
  62. package/scripts/promote-session-item +150 -0
  63. package/scripts/read-config +3 -0
  64. package/scripts/rebuild-graph +1 -1
  65. package/scripts/roadmap-next +2 -2
  66. package/scripts/rollback.rb +48 -3
  67. package/scripts/scaffold-intent +37 -63
  68. package/scripts/session-commit +116 -0
  69. package/scripts/spawn-preamble +5 -5
  70. package/scripts/update.rb +7 -3
  71. package/scripts/write-handoff +60 -0
  72. package/skills/agent-advisor/SKILL.md +3 -3
  73. package/skills/agent-advisor/references/advisor-protocol.md +20 -20
  74. package/skills/auto/SKILL.md +218 -317
  75. package/skills/auto/evals/evals.json +10 -10
  76. package/skills/auto/references/agent-architecture.md +102 -90
  77. package/skills/auto/references/agent-report-contract.md +4 -21
  78. package/skills/auto/references/end-tail.md +47 -53
  79. package/skills/auto/references/human-report-contract.md +8 -8
  80. package/skills/conventions/SKILL.md +1 -3
  81. package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
  82. package/skills/conventions/references/locks-and-worktrees.md +47 -55
  83. package/skills/conventions/references/maintenance-and-revisions.md +12 -12
  84. package/skills/dashboard/SKILL.md +1 -1
  85. package/skills/direct/SKILL.md +66 -0
  86. package/skills/direct/references/request-signals.md +59 -0
  87. package/skills/doctor/SKILL.md +58 -11
  88. package/skills/doctor/report.md +3 -1
  89. package/skills/install/SKILL.md +11 -9
  90. package/skills/intent-continuing/SKILL.md +132 -89
  91. package/skills/intent-continuing/references/boarding-matrix.md +34 -0
  92. package/skills/intent-continuing/references/context-management.md +2 -2
  93. package/skills/intent-creating/SKILL.md +2 -2
  94. package/skills/intent-creating/references/lifecycle.md +1 -1
  95. package/skills/intent-ending/SKILL.md +28 -37
  96. package/skills/intent-ending/evals/evals.json +4 -4
  97. package/skills/intent-executing/SKILL.md +12 -49
  98. package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
  99. package/skills/intent-speccing/SKILL.md +108 -51
  100. package/skills/intent-speccing/evals/evals.json +5 -5
  101. package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
  102. package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
  103. package/skills/project-creating/SKILL.md +1 -1
  104. package/skills/releasing/SKILL.md +7 -8
  105. package/skills/tutorial/SKILL.md +2 -2
  106. package/skills/tutorial/references/track-1-guided.md +16 -18
  107. package/skills/tutorial/references/track-2-auto.md +12 -13
  108. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
  109. package/templates/agents.md +4 -7
  110. package/templates/config.yml +5 -0
  111. package/templates/project.yml +7 -0
  112. package/templates/roadmap.md +1 -1
  113. package/templates/savepoint.md +2 -2
  114. package/templates/session-intent.md +29 -0
  115. package/agents/plastic-brainstorming.md +0 -48
  116. package/agents/plastic-future-intent-researcher.md +0 -35
  117. package/agents/plastic-intent-curator.md +0 -47
  118. package/agents/plastic-intent-discovery.md +0 -43
  119. package/agents/plastic-planner.md +0 -56
  120. package/agents/plastic-spec-specialist.md +0 -53
  121. package/hooks/auto-arm +0 -5
  122. package/hooks/bash-gate +0 -3
  123. package/hooks/continue +0 -31
  124. package/hooks/edit-gates +0 -3
  125. package/hooks/future-intent-check +0 -25
  126. package/hooks/gate-check +0 -12
  127. package/hooks/power-tools +0 -8
  128. package/scripts/hook-auto-arm +0 -51
  129. package/scripts/hook-bash-gate +0 -78
  130. package/scripts/hook-code-gate +0 -38
  131. package/scripts/hook-continue +0 -48
  132. package/scripts/hook-create-gate +0 -51
  133. package/scripts/hook-edit-gates +0 -58
  134. package/scripts/hook-future-intent-check +0 -90
  135. package/scripts/hook-gate-check +0 -169
  136. package/scripts/hook-links-gate +0 -54
  137. package/scripts/hook-lock-gate +0 -47
  138. package/scripts/hook-power-tools +0 -38
  139. package/scripts/hook-savepoint-pre +0 -32
  140. package/scripts/lib/codex_edit_gates.rb +0 -138
  141. package/scripts/lib/edit_gates.rb +0 -398
  142. package/scripts/lib/links_gate.rb +0 -140
  143. package/scripts/lib/qmd_hook.rb +0 -41
  144. package/scripts/lib/spec_header.rb +0 -83
  145. package/scripts/lib/start_intent.rb +0 -296
  146. package/scripts/start-intent +0 -89
  147. package/skills/_active-intent-gate.md +0 -26
  148. package/skills/auto/references/tiers.md +0 -100
  149. package/skills/continuing/SKILL.md +0 -34
  150. package/skills/continuing/evals/evals.json +0 -91
  151. package/skills/conventions/references/gates-and-enforcement.md +0 -53
  152. package/skills/conventions/references/tiers-and-dispatch.md +0 -135
  153. package/skills/doctor/references/gates-stuck-detection.md +0 -43
  154. package/skills/intent-brainstorming/SKILL.md +0 -118
  155. package/skills/intent-brainstorming/evals/evals.json +0 -67
  156. package/skills/intent-continuing/evals/evals.json +0 -145
  157. package/skills/intent-discovering/SKILL.md +0 -56
  158. package/skills/intent-grilling/SKILL.md +0 -108
  159. package/skills/intent-linking/SKILL.md +0 -128
  160. package/skills/intent-linking/evals/evals.json +0 -22
  161. package/skills/intent-linking/references/zettelkasten.md +0 -45
  162. package/skills/intent-locking/SKILL.md +0 -64
  163. package/skills/intent-planning/SKILL.md +0 -145
  164. package/skills/intent-planning/evals/evals.json +0 -81
  165. package/skills/intent-planning/references/plan-format.md +0 -130
  166. package/skills/intent-researching/SKILL.md +0 -123
  167. package/skills/intent-researching/evals/evals.json +0 -22
  168. package/skills/intent-savepoint/SKILL.md +0 -83
  169. package/skills/intent-savepoint/references/context-management.md +0 -32
  170. package/skills/intent-starting/SKILL.md +0 -151
  171. package/skills/intent-starting/evals/evals.json +0 -117
  172. package/skills/intent-starting/references/boarding-matrix.md +0 -35
  173. package/skills/project-continuing/SKILL.md +0 -119
  174. package/skills/project-continuing/evals/evals.json +0 -100
  175. package/skills/roadmap-continuing/SKILL.md +0 -89
  176. package/skills/roadmap-continuing/evals/evals.json +0 -82
  177. package/skills/skill-creating/SKILL.md +0 -75
  178. package/skills/skill-creating/evals/evals.json +0 -108
  179. package/skills/skill-creating/references/agents.md +0 -168
  180. package/skills/skill-creating/references/defaults-first.md +0 -23
  181. package/skills/skill-creating/references/evals.md +0 -41
  182. package/skills/skill-creating/references/hooks.md +0 -251
  183. package/skills/skill-creating/references/progressive-disclosure.md +0 -176
  184. package/skills/skill-creating/references/scripts.md +0 -166
  185. package/skills/skill-creating/references/skills.md +0 -169
  186. package/skills/skill-creating/scripts/scaffold.rb +0 -313
  187. package/skills/skill-evaluating/SKILL.md +0 -141
  188. package/skills/skill-evaluating/assets/eval-template.json +0 -12
  189. package/skills/skill-evaluating/evals/evals.json +0 -75
  190. package/skills/skill-evaluating/references/convention-checks.md +0 -76
  191. package/skills/skill-evaluating/references/eval-methodology.md +0 -154
  192. package/skills/store-curating/SKILL.md +0 -64
  193. package/skills/store-curating/evals/evals.json +0 -38
  194. package/skills/store-indexing/SKILL.md +0 -93
  195. package/skills/store-indexing/evals/evals.json +0 -22
  196. package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
  197. package/skills/store-provisioning/SKILL.md +0 -55
  198. /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
  199. /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
  200. /package/skills/{intent-brainstorming → intent-speccing}/references/design-principles.md +0 -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)
@@ -0,0 +1,38 @@
1
+ # Plastic config-ask notices
2
+ # Each entry declares a config question a release introduced. The update CLI
3
+ # and doctor both read this file (never skill prose) so the ask fires from
4
+ # fresh code on every hop, not only the release that shipped it.
5
+ #
6
+ # Schema:
7
+ # id: unique slug (used for dismissal tracking, config_asks_dismissed)
8
+ # key: dot-notation config key the answer is written to (read-config/write-config)
9
+ # question: one-line question text
10
+ # options: ordered list of { label:, value: } -- value is written verbatim via
11
+ # write-config; the first option is presented as the recommended default
12
+ # introduced: semver where the entry was added (informational only -- see
13
+ # "Pending predicate" below, introduced does NOT gate firing)
14
+ # agents: optional list of harnesses this applies to (default: all)
15
+ #
16
+ # Pending predicate: key resolves to nil/empty in config.yml AND id is not in
17
+ # config_asks_dismissed. Answering (write-config <key> <value>) or dismissing
18
+ # (write-config config_asks_dismissed --push <id>) both clear it permanently.
19
+ # This deliberately ignores "introduced" so an entry fires on the very next
20
+ # update/doctor run no matter how many releases have already passed (retro-fire).
21
+ #
22
+ # Footgun for whoever adds the next entry: this predicate reads config.yml
23
+ # directly and never merges in read-config's DEFAULTS, so keying a new entry
24
+ # on something that already has a non-nil value in read-config's DEFAULTS
25
+ # would make that entry look unset, and therefore pending, forever, even
26
+ # though the rest of Plastic already treats it as answered by that default.
27
+
28
+ config_asks:
29
+ - id: advisor-default
30
+ key: advisor.claude.default
31
+ introduced: "1.3.0"
32
+ agents: ["claude"]
33
+ question: "Which advisor should be the default?"
34
+ options:
35
+ - label: "Faux Fable (recommended): Opus 4.8 carrying the frontier reasoning instructions. Much cheaper, available on any plan, reasons in the same disciplined way."
36
+ value: "plastic-faux-advisor"
37
+ - label: "Fable 5: the frontier model itself. The strongest reasoning available, billed through usage credits, so summon it for a few rounds and close it."
38
+ value: "plastic-advisor"
package/hooks/capture ADDED
@@ -0,0 +1,4 @@
1
+ #!/bin/bash
2
+ INPUT=$(cat)
3
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
4
+ echo "$INPUT" | env -u RUBYOPT ruby "$SCRIPT_DIR/../scripts/hook-capture"
package/hooks/close ADDED
@@ -0,0 +1,5 @@
1
+ #!/bin/bash
2
+ INPUT=$(cat)
3
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
4
+ echo "$INPUT" | env -u RUBYOPT ruby "$SCRIPT_DIR/../scripts/hook-close" "$HOME/.plastic"
5
+ exit 0
package/hooks/hooks.json CHANGED
@@ -29,36 +29,26 @@
29
29
  ]
30
30
  }
31
31
  ],
32
- "PreToolUse": [
32
+ "SessionEnd": [
33
33
  {
34
- "matcher": "Write|Edit|NotebookEdit|mcp__serena__replace_content|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol|mcp__serena__safe_delete_symbol|mcp__serena__rename_symbol",
35
- "hooks": [
36
- {
37
- "type": "command",
38
- "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" edit-gates",
39
- "statusMessage": "Checking Plastic gates..."
40
- }
41
- ]
42
- },
43
- {
44
- "matcher": "Bash",
34
+ "matcher": "",
45
35
  "hooks": [
46
36
  {
47
37
  "type": "command",
48
- "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" bash-gate",
49
- "statusMessage": "Checking lifecycle gate..."
38
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" close",
39
+ "statusMessage": "Closing the Plastic session..."
50
40
  }
51
41
  ]
52
42
  }
53
43
  ],
54
44
  "PostToolUse": [
55
45
  {
56
- "matcher": "Write|Edit",
46
+ "matcher": "Write|Edit|NotebookEdit|mcp__serena__replace_content|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol|mcp__serena__safe_delete_symbol|mcp__serena__rename_symbol",
57
47
  "hooks": [
58
48
  {
59
49
  "type": "command",
60
- "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" gate-check",
61
- "statusMessage": "Checking lifecycle gates..."
50
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" record",
51
+ "statusMessage": "Recording Plastic session state..."
62
52
  }
63
53
  ]
64
54
  }
@@ -69,38 +59,8 @@
69
59
  "hooks": [
70
60
  {
71
61
  "type": "command",
72
- "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" continue",
73
- "statusMessage": "Checking for continue..."
74
- }
75
- ]
76
- },
77
- {
78
- "matcher": "",
79
- "hooks": [
80
- {
81
- "type": "command",
82
- "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/future-intent-check\"",
83
- "statusMessage": "Checking future intents..."
84
- }
85
- ]
86
- },
87
- {
88
- "matcher": "",
89
- "hooks": [
90
- {
91
- "type": "command",
92
- "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/auto-arm\"",
93
- "statusMessage": "Checking auto mode..."
94
- }
95
- ]
96
- },
97
- {
98
- "matcher": "",
99
- "hooks": [
100
- {
101
- "type": "command",
102
- "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" power-tools",
103
- "statusMessage": "Checking power tools..."
62
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" capture",
63
+ "statusMessage": "Capturing prompt into the session ledger..."
104
64
  }
105
65
  ]
106
66
  }
package/hooks/record ADDED
@@ -0,0 +1,4 @@
1
+ #!/bin/bash
2
+ INPUT=$(cat)
3
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
4
+ echo "$INPUT" | env -u RUBYOPT ruby "$SCRIPT_DIR/../scripts/hook-record"