@zalom/plastic 2.0.0-alpha.22 → 2.0.0-alpha.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/agents/plastic-enforcer.md +6 -3
  2. package/agents/plastic-executor.md +4 -0
  3. package/agents/plastic-node-research.md +28 -0
  4. package/agents/plastic-node-verify.md +27 -0
  5. package/agents/plastic-node-work.md +32 -0
  6. package/bin/lib/context_budget.rb +1 -1
  7. package/hooks/hooks.json +12 -0
  8. package/hooks/statusline +28 -0
  9. package/hooks/stop +5 -0
  10. package/package.json +1 -1
  11. package/scripts/doctor.rb +80 -6
  12. package/scripts/end-intent +3 -3
  13. package/scripts/graph-measure +249 -0
  14. package/scripts/hook-capture +1 -0
  15. package/scripts/hook-savepoint +24 -2
  16. package/scripts/hook-session-start +40 -0
  17. package/scripts/hook-stop +57 -0
  18. package/scripts/lib/active_delivery.rb +61 -0
  19. package/scripts/lib/agent_models.rb +10 -1
  20. package/scripts/lib/codex_adapter.rb +197 -0
  21. package/scripts/lib/doctor_core.rb +8 -3
  22. package/scripts/lib/engine_permissions.rb +88 -0
  23. package/scripts/lib/graph_edges.rb +4 -4
  24. package/scripts/lib/graph_file.rb +4 -4
  25. package/scripts/lib/graph_measure.rb +645 -0
  26. package/scripts/lib/graph_measure_budget.rb +408 -0
  27. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  28. package/scripts/lib/graph_measure_models.rb +411 -0
  29. package/scripts/lib/graph_measure_report.rb +532 -0
  30. package/scripts/lib/graph_tree.rb +2 -2
  31. package/scripts/lib/handoff.rb +36 -5
  32. package/scripts/lib/harness_adapter.rb +184 -0
  33. package/scripts/lib/hook_registry.rb +13 -1
  34. package/scripts/lib/hook_replay.rb +23 -5
  35. package/scripts/lib/index_projection.rb +1 -1
  36. package/scripts/lib/installer_core.rb +109 -3
  37. package/scripts/lib/intent_screen.rb +1 -1
  38. package/scripts/lib/intent_validator.rb +2 -2
  39. package/scripts/lib/meter_watch.rb +15 -9
  40. package/scripts/lib/node_file.rb +3 -3
  41. package/scripts/lib/node_ledger.rb +8 -1
  42. package/scripts/lib/node_progress.rb +153 -0
  43. package/scripts/lib/outcome_report.rb +1 -1
  44. package/scripts/lib/report_screen.rb +10 -6
  45. package/scripts/lib/roadmap_graph.rb +1 -1
  46. package/scripts/lib/roadmap_queue.rb +1 -1
  47. package/scripts/lib/roadmap_render.rb +1 -1
  48. package/scripts/lib/runner_absorb.rb +31 -5
  49. package/scripts/lib/runner_dispatch.rb +26 -11
  50. package/scripts/lib/runner_until_empty.rb +252 -0
  51. package/scripts/lib/runner_watch.rb +389 -0
  52. package/scripts/lib/savepoint.rb +3 -3
  53. package/scripts/lib/session_git.rb +2 -2
  54. package/scripts/lib/stop_gate.rb +95 -0
  55. package/scripts/lib/verify_intent.rb +2 -2
  56. package/scripts/lib/work_graph_validator.rb +6 -6
  57. package/scripts/new-intent +1 -1
  58. package/scripts/node-run +224 -0
  59. package/scripts/read-config +6 -0
  60. package/scripts/runner +203 -19
  61. package/scripts/verify-intent +1 -1
  62. package/skills/auto/SKILL.md +1 -1
  63. package/skills/conventions/references/knowledge-graph.md +9 -0
  64. package/skills/doctor/SKILL.md +3 -3
  65. package/skills/intent-creating/evals/evals.json +1 -1
  66. package/skills/intent-executing/SKILL.md +6 -0
  67. package/skills/tutorial/references/track-2-auto.md +1 -1
@@ -0,0 +1,408 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+ require "json"
6
+ require "tmpdir"
7
+ require_relative "node_ledger"
8
+ require_relative "node_file"
9
+ require_relative "node_packet"
10
+ require_relative "packet_wrapper"
11
+
12
+ # GraphMeasureBudget (intent 343, G10, n4): whether the `budget:` a node's
13
+ # envelope declares (327 C19, D47) is a ceiling that ever actually held.
14
+ # Intent 340 found at 22:14Z on 2026-09-10 that every runner-built packet
15
+ # was capped at 8000 tokens whatever the node declared (v1 major M7); this
16
+ # module is the measurement of whether that still holds, from the ledger
17
+ # and the real packet files alone.
18
+ #
19
+ # Read-only (spec D2): it opens `nodes/*.md`, `savepoint.md` and files under
20
+ # `packets/`, and writes nothing. A measure with no source prints
21
+ # `:unavailable`, never zero and never blank (spec D3); a packet file named
22
+ # by `packet=` that is not on disk is counted and named by its sha, never
23
+ # silently treated as zero bytes (spec D4).
24
+ #
25
+ # Adds no second parser and no second estimator: `NodeLedger` owns the
26
+ # transition-line format, `NodeFile` owns the envelope's `budget:`,
27
+ # `NodePacket` owns packet path resolution and attempt numbering, and
28
+ # `PacketWrapper.estimate_tokens` is the one token formula, the same the
29
+ # packet builder enforced with (spec D8).
30
+ module GraphMeasureBudget
31
+ module_function
32
+
33
+ SAVEPOINT_FILE = "savepoint.md"
34
+ UNAVAILABLE = "unavailable"
35
+
36
+ # {ok:, nodes: {id => {declared_budget:, attempts: [...]}}, ceiling: {...}}.
37
+ # Never raises: a missing savepoint.md, a node with no envelope file, and a
38
+ # packet= naming a file that does not exist on disk all resolve to
39
+ # `:unavailable` fields rather than an exception.
40
+ def read(intent_dir, node_reader: NodeFile.method(:parse))
41
+ dir = File.expand_path(intent_dir.to_s)
42
+ content = read_content(File.join(dir, SAVEPOINT_FILE))
43
+ entries = NodeLedger.entries_from_content(content)
44
+
45
+ running_indices = Hash.new { |h, k| h[k] = [] }
46
+ entries.each_with_index do |e, idx|
47
+ next if e[:torn] || e[:state] != "running"
48
+
49
+ running_indices[e[:subject]] << idx
50
+ end
51
+
52
+ nodes = {}
53
+ running_indices.each do |subject, indices|
54
+ declared_budget = declared_budget_for(dir, subject, node_reader)
55
+ attempts = indices.map do |idx|
56
+ entry = entries[idx]
57
+ # Row 4.5: the attempt number is NodePacket's own count of non-torn
58
+ # `running` lines up to and including this one, never
59
+ # ReadySet.attempts_count (which resets after `done`, `superseded`
60
+ # or `abandoned` and answers "attempts left", not "which attempt was
61
+ # this line").
62
+ attempt_number = NodePacket.compute_attempt_number(
63
+ intent_dir: dir, node: subject, lease_flag_given: false, entries: entries[0..idx]
64
+ )
65
+ build_attempt(dir, subject, attempt_number, entry, declared_budget)
66
+ end
67
+ nodes[subject] = { declared_budget: declared_budget, attempts: attempts }
68
+ end
69
+
70
+ record = { ok: true, nodes: nodes, ceiling: detect_ceiling(nodes) }
71
+ deep_freeze(record)
72
+ end
73
+
74
+ # --- rendering -----------------------------------------------------------
75
+
76
+ def render_json(record)
77
+ JSON.generate(model(record))
78
+ end
79
+
80
+ def render_text(record)
81
+ m = model(record)
82
+ lines = []
83
+ lines.concat(node_block(m["nodes"]))
84
+ lines << ""
85
+ lines.concat(ceiling_block(m["ceiling"]))
86
+ "#{lines.join("\n")}\n"
87
+ end
88
+
89
+ def model(record)
90
+ {
91
+ "nodes" => sort_ids(record[:nodes].keys).map { |id| node_model(id, record[:nodes][id]) },
92
+ "ceiling" => ceiling_model(record[:ceiling]),
93
+ }
94
+ end
95
+
96
+ def node_model(id, node)
97
+ {
98
+ "id" => id,
99
+ "declared_budget" => av(node[:declared_budget]),
100
+ "attempts" => node[:attempts].map { |a| attempt_model(a) },
101
+ }
102
+ end
103
+ private_class_method :node_model
104
+
105
+ def attempt_model(a)
106
+ {
107
+ "attempt" => a[:attempt],
108
+ "packet_sha_declared" => av(a[:packet_sha_declared]),
109
+ "file_exists" => a[:file_exists],
110
+ "packet_sha_actual" => av(a[:packet_sha_actual]),
111
+ "sha_match" => a[:sha_match],
112
+ "bytes" => av(a[:bytes]),
113
+ "estimate_tokens" => av(a[:estimate_tokens]),
114
+ "hop" => a[:hop],
115
+ "effective_tokens" => av(a[:effective_tokens]),
116
+ "over_budget" => av(a[:over_budget]),
117
+ }
118
+ end
119
+ private_class_method :attempt_model
120
+
121
+ def ceiling_model(c)
122
+ {
123
+ "detected" => c[:detected],
124
+ "value" => c[:value] ? c[:value] : UNAVAILABLE,
125
+ "node_count" => c[:node_count],
126
+ "attempt_count" => c[:attempt_count],
127
+ "reason" => c[:reason].to_s,
128
+ }
129
+ end
130
+ private_class_method :ceiling_model
131
+
132
+ def node_block(nodes)
133
+ lines = ["== Budget =="]
134
+ if nodes.empty?
135
+ lines << "(no packet attempts recorded)"
136
+ return lines
137
+ end
138
+
139
+ lines << "| Node | Attempt | Declared budget | Bytes | Est tokens | Hop | Effective tokens | Over budget | Sha match |"
140
+ lines << "| --- | --- | --- | --- | --- | --- | --- | --- | --- |"
141
+ nodes.each do |n|
142
+ n["attempts"].each do |a|
143
+ lines << "| #{n['id']} | #{a['attempt']} | #{n['declared_budget']} | #{a['bytes']} | " \
144
+ "#{a['estimate_tokens']} | #{a['hop']} | #{a['effective_tokens']} | #{a['over_budget']} | #{a['sha_match']} |"
145
+ end
146
+ end
147
+ lines
148
+ end
149
+ private_class_method :node_block
150
+
151
+ def ceiling_block(c)
152
+ [
153
+ "== Suspected ceiling ==",
154
+ "detected: #{c['detected']}",
155
+ "value: #{c['value']}#{c['value'] == UNAVAILABLE ? '' : ' tokens'}",
156
+ "based on: #{c['node_count']} nodes, #{c['attempt_count']} attempts (reason: #{c['reason']})",
157
+ ]
158
+ end
159
+ private_class_method :ceiling_block
160
+
161
+ # --- reading ---------------------------------------------------------------
162
+
163
+ def read_content(path)
164
+ return "" unless path && File.exist?(path)
165
+
166
+ File.read(path).scrub
167
+ end
168
+ private_class_method :read_content
169
+
170
+ # B6 (v1 review): `node_reader` (NodeFile.parse by default) reads its own
171
+ # path and does not scrub, so one bad byte anywhere in a node envelope
172
+ # raised out of NodeFile's regex matching and took the whole `budget` verb
173
+ # down (reproduced by appending an invalid UTF-8 byte to a copy of 340's
174
+ # nodes/n1.md: `budget` exited 1 with "internal error: invalid byte
175
+ # sequence in UTF-8", the same as `cohorts`, while `intent` - which never
176
+ # calls NodeFile.parse without GraphMeasure's own `with_safe_path` guard -
177
+ # stayed at exit 0). `GraphMeasure.with_safe_path` already solves exactly
178
+ # this, but it is `private_class_method` (carried from n1, same pattern
179
+ # n5's `GraphMeasureModels` already uses for its own `present?` and
180
+ # `resolve_kind`): a local copy, not a second parser or a reopen.
181
+ def declared_budget_for(dir, node, node_reader)
182
+ path = NodePacket.find_node_path(dir, node)
183
+ return :unavailable unless path
184
+
185
+ parsed = with_safe_path(path) { |p| p ? node_reader.call(p) : nil }
186
+ budget = parsed && parsed[:budget]
187
+ budget.is_a?(Integer) ? budget : :unavailable
188
+ end
189
+ private_class_method :declared_budget_for
190
+
191
+ # Own copy of GraphMeasure's `with_safe_path` (private there): scrub a
192
+ # bad-UTF-8 file into a throwaway Dir.mktmpdir copy before handing it to a
193
+ # reader that does not scrub itself, never writing into `intent_dir`
194
+ # (spec D2).
195
+ def with_safe_path(path)
196
+ return yield(nil) unless path && File.exist?(path)
197
+
198
+ raw = File.read(path)
199
+ scrubbed = raw.scrub
200
+ return yield(path) if scrubbed == raw
201
+
202
+ Dir.mktmpdir("graph-measure-budget-scrub") do |tmp|
203
+ safe_path = File.join(tmp, File.basename(path))
204
+ File.write(safe_path, scrubbed)
205
+ yield(safe_path)
206
+ end
207
+ end
208
+ private_class_method :with_safe_path
209
+
210
+ def build_attempt(dir, subject, attempt_number, entry, declared_budget)
211
+ fields = entry[:fields] || {}
212
+ packet_sha_declared = present?(fields["packet"]) ? fields["packet"] : :unavailable
213
+ # D8: hop= is written once, on the running line; absent means no hop
214
+ # block was appended (the feature predates this line, or hop is off),
215
+ # never an unknown quantity to subtract.
216
+ hop = present?(fields["hop"]) ? fields["hop"].to_i : 0
217
+ path = NodePacket.packet_path(intent_dir: dir, node: subject, attempt: attempt_number)
218
+ file_exists = File.exist?(path)
219
+
220
+ if file_exists
221
+ raw = File.binread(path)
222
+ bytes = raw.bytesize
223
+ sha_actual = Digest::SHA256.hexdigest(raw)[0, 12]
224
+ estimate = PacketWrapper.estimate_tokens(raw)
225
+ effective = estimate - hop
226
+ over_budget = declared_budget.is_a?(Integer) ? effective > declared_budget : :unavailable
227
+ sha_match = sha_actual == packet_sha_declared
228
+ else
229
+ bytes = :unavailable
230
+ sha_actual = :unavailable
231
+ estimate = :unavailable
232
+ effective = :unavailable
233
+ over_budget = :unavailable
234
+ sha_match = false
235
+ end
236
+
237
+ {
238
+ attempt: attempt_number,
239
+ packet_sha_declared: packet_sha_declared,
240
+ packet_path: path,
241
+ file_exists: file_exists,
242
+ packet_sha_actual: sha_actual,
243
+ sha_match: sha_match,
244
+ bytes: bytes,
245
+ estimate_tokens: estimate,
246
+ hop: hop,
247
+ effective_tokens: effective,
248
+ over_budget: over_budget,
249
+ raw: entry[:raw],
250
+ }
251
+ end
252
+ private_class_method :build_attempt
253
+
254
+ def present?(value)
255
+ !(value.nil? || value.to_s.strip.empty?)
256
+ end
257
+ private_class_method :present?
258
+
259
+ # --- the suspected ceiling: spec matrix rows 4.8, 4.9 -----------------------
260
+
261
+ # A run of packets whose estimates all fall under one value well below
262
+ # their own declared budgets (row 4.8): the candidate ceiling is the
263
+ # largest effective estimate among the usable attempts (every attempt
264
+ # whose declared budget and packet file are both known), and it is only
265
+ # reported when that value sits strictly under EVERY one of those
266
+ # attempts' own declared budgets - never a "cluster within a few percent"
267
+ # of each other, which never fires against a real spread of packet sizes.
268
+ # Row 4.9: fewer than two distinct nodes never reports a ceiling; one
269
+ # node's own repeated attempts are not evidence of a ceiling shared across
270
+ # the intent.
271
+ #
272
+ # B5 (v1 review): `candidate < u[:budget]` alone reads "nothing went over
273
+ # budget", which is true of every intent that simply stayed under its own
274
+ # declared budgets - reproduced with two nodes estimated at 1999 and 1998
275
+ # effective tokens against declared budgets of 2000 each, which the old
276
+ # code reported as "detected: true, value: 1999" though there was no
277
+ # ceiling there at all: both attempts used over 99.9% of their own
278
+ # declared allowance, they just never quite crossed it.
279
+ #
280
+ # "Well below" needs a stated threshold: WELL_BELOW_RATIO is that
281
+ # threshold, and a ceiling is reported only when the candidate uses no
282
+ # more than this fraction of EVERY usable attempt's own declared budget.
283
+ # Reproduced against 340's real packets: declared budgets there run from
284
+ # 50000 to 160000 tokens (327 D47's per-kind defaults), and every
285
+ # attempt's effective token count clusters between 3380 and 7717 - the
286
+ # candidate (7717) is at most 7717/50000 = 15.4% of even the SMALLEST
287
+ # declared budget among the usable attempts, so it clears this threshold
288
+ # by a wide margin and 340 still reports a detected ceiling. The
289
+ # synthetic 1999-vs-2000 case clears none of it (99.95%, not <= 50%) and
290
+ # correctly reports no ceiling.
291
+ WELL_BELOW_RATIO = 0.5
292
+
293
+ # NEW-2 (v2 review, D21): `candidate <= budget * WELL_BELOW_RATIO` checked
294
+ # against EVERY usable attempt in the intent, with no requirement that
295
+ # more than one of them actually sit anywhere near the candidate. That
296
+ # fires on any spread of generous, unrelated budgets (reproduced: nodes at
297
+ # 3000, 7000 and 1200 effective tokens against declared budgets of
298
+ # 100000, 100000 and 150000 reported "detected: true, value: 7000" though
299
+ # only the 7000 attempt sits anywhere near that number), and it misses a
300
+ # genuine shared ceiling under tight budgets, where the honest utilization
301
+ # of a real cluster crosses 50% (reproduced: three nodes converging
302
+ # tightly on 7717/7690/7650 against a declared 10000 each reported
303
+ # "not_well_below_declared_budgets"). D21's own fix: a candidate is
304
+ # evidence of a ceiling only once several distinct usable attempts
305
+ # actually approach it from below (CLUSTER_BAND_RATIO), and the "well
306
+ # below budget" bar only needs to be strict (WELL_BELOW_RATIO) when that
307
+ # cluster is thin (fewer than SEVERAL_CLUSTER_THRESHOLD distinct nodes);
308
+ # once several distinct nodes converge on the same value, the convergence
309
+ # itself is the primary evidence and the budget bar relaxes to
310
+ # CLUSTERED_WELL_BELOW_RATIO. 340's real cluster (five distinct nodes
311
+ # converging between 6237 and 7717) clears either bar by a wide margin;
312
+ # the two-node 1999/1998-vs-2000 case never reaches the cluster threshold
313
+ # of three, so it still runs against the strict 50% bar unchanged.
314
+ CLUSTER_BAND_RATIO = 0.8
315
+ SEVERAL_CLUSTER_THRESHOLD = 3
316
+ # D25 (after v3 M2): 0.9 let a cluster using ninety percent of its own
317
+ # declared budget report a ceiling - the case where the declared budget IS
318
+ # the ceiling, the opposite of what C19 asks for. 0.8 is "far under" and
319
+ # still clears the pinned cluster case (7717/10000 = 77.17%).
320
+ CLUSTERED_WELL_BELOW_RATIO = 0.8
321
+
322
+ def detect_ceiling(nodes)
323
+ usable = []
324
+ nodes.each do |id, node|
325
+ budget = node[:declared_budget]
326
+ # D21: an attempt whose declared budget is absent or zero carries no
327
+ # evidence either way and is disqualified from the comparison, never
328
+ # the whole intent (reproduced: two nodes declaring a budget of 0
329
+ # with 0 effective tokens each reported "detected: true, value: 0",
330
+ # an artifact of comparing a candidate against a budget of 0 rather
331
+ # than real evidence of a ceiling).
332
+ next unless budget.is_a?(Integer) && budget.positive?
333
+
334
+ node[:attempts].each do |a|
335
+ next unless a[:effective_tokens].is_a?(Integer)
336
+
337
+ usable << { node: id, effective: a[:effective_tokens], budget: budget }
338
+ end
339
+ end
340
+
341
+ distinct_nodes = usable.map { |u| u[:node] }.uniq
342
+ attempt_count = usable.length
343
+
344
+ if distinct_nodes.length < 2
345
+ return { detected: false, reason: :insufficient_nodes, value: nil,
346
+ node_count: distinct_nodes.length, attempt_count: attempt_count }
347
+ end
348
+
349
+ candidate = usable.map { |u| u[:effective] }.max
350
+ cluster = usable.select { |u| u[:effective] >= candidate * CLUSTER_BAND_RATIO }
351
+ cluster_nodes = cluster.map { |u| u[:node] }.uniq
352
+
353
+ if cluster_nodes.length < 2
354
+ return { detected: false, reason: :no_cluster, value: nil,
355
+ node_count: distinct_nodes.length, attempt_count: attempt_count }
356
+ end
357
+
358
+ ratio = cluster_nodes.length >= SEVERAL_CLUSTER_THRESHOLD ? CLUSTERED_WELL_BELOW_RATIO : WELL_BELOW_RATIO
359
+ well_below = cluster.all? { |u| candidate <= u[:budget] * ratio }
360
+
361
+ unless well_below
362
+ return { detected: false, reason: :not_well_below_declared_budgets, value: nil,
363
+ node_count: distinct_nodes.length, attempt_count: attempt_count }
364
+ end
365
+
366
+ # M1 (v3 review, D25): SEVERAL_CLUSTER_THRESHOLD was read only to pick
367
+ # which budget bar applies, never enforced as the bar for "several"
368
+ # itself, so any two-node band that cleared WELL_BELOW_RATIO still
369
+ # reported a ceiling. Clearing the budget bar is necessary but not
370
+ # sufficient: D21 asks for several distinct usable attempts to approach
371
+ # the candidate, and two is a band, not several.
372
+ if cluster_nodes.length < SEVERAL_CLUSTER_THRESHOLD
373
+ return { detected: false, reason: :insufficient_cluster, value: nil,
374
+ node_count: distinct_nodes.length, attempt_count: attempt_count }
375
+ end
376
+
377
+ { detected: true, reason: :ok, value: candidate, node_count: distinct_nodes.length, attempt_count: attempt_count }
378
+ end
379
+ private_class_method :detect_ceiling
380
+
381
+ # --- formatting helpers ------------------------------------------------------
382
+
383
+ def av(value)
384
+ return UNAVAILABLE if value.nil? || value == :unavailable
385
+
386
+ value
387
+ end
388
+ private_class_method :av
389
+
390
+ def sort_ids(ids)
391
+ ids.sort_by do |id|
392
+ m = id.to_s.match(/\A([A-Za-z]+)(\d+)\z/)
393
+ m ? [m[1], m[2].to_i] : [id.to_s, 0]
394
+ end
395
+ end
396
+ private_class_method :sort_ids
397
+
398
+ def deep_freeze(obj)
399
+ case obj
400
+ when Hash
401
+ obj.each { |k, v| deep_freeze(k); deep_freeze(v) }
402
+ when Array
403
+ obj.each { |v| deep_freeze(v) }
404
+ end
405
+ obj.freeze
406
+ end
407
+ private_class_method :deep_freeze
408
+ end