@zalom/plastic 2.0.0-alpha.15 → 2.0.0-alpha.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "2.0.0-alpha.15",
3
+ "version": "2.0.0-alpha.16",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,11 +51,18 @@ begin
51
51
  tmp_root = ENV["PLASTIC_TMP"].to_s.empty? ? Dir.tmpdir : ENV["PLASTIC_TMP"]
52
52
  plastic_home = File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
53
53
 
54
+ # PLASTIC_HOOK_TRACE=<file> (331a1): opt-in, off by default, one JSON
55
+ # object appended per chunk. This script is the only place allowed to
56
+ # read the environment; MessageDisplay takes the sink as an argument.
57
+ trace_path = ENV["PLASTIC_HOOK_TRACE"].to_s
58
+ trace = trace_path.empty? ? nil : MessageDisplay.file_trace(trace_path)
59
+
54
60
  handler = MessageDisplay.new(
55
61
  tmp_root: tmp_root,
56
62
  plastic_home: plastic_home,
57
63
  color: color_enabled?(plastic_home),
58
64
  now: Time.now,
65
+ trace: trace,
59
66
  )
60
67
  result = handler.handle(payload)
61
68
 
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "fileutils"
5
+ require "json"
5
6
  require_relative "screen_paint"
6
7
 
7
8
  # MessageDisplay (intent 316a, O4/O5, round 3 concurrency fix) - the Claude
@@ -110,9 +111,13 @@ class MessageDisplay
110
111
  # NOSCREEN (D2), never read by this class as a decision in its own right.
111
112
  PENDING_FILE = "PENDING"
112
113
 
114
+ # `trace` (331a1): an optional callable taking one Hash per chunk. The class
115
+ # stays pure - it never reads PLASTIC_HOOK_TRACE, never opens a file of its
116
+ # own; the CLI reads the variable and injects `file_trace`. nil, the
117
+ # default, costs the common path one `unless` and nothing else.
113
118
  def initialize(tmp_root:, plastic_home:, color:, now:, wait_ms: 300, poll_ms: 20,
114
119
  index_wait_ms: 20, max_wait_ms: 2000,
115
- sleeper: ->(seconds) { sleep(seconds) })
120
+ sleeper: ->(seconds) { sleep(seconds) }, trace: nil)
116
121
  @tmp_root = tmp_root
117
122
  @plastic_home = plastic_home
118
123
  @color = color
@@ -122,6 +127,7 @@ class MessageDisplay
122
127
  @index_wait_ms = index_wait_ms
123
128
  @max_wait_ms = max_wait_ms
124
129
  @sleeper = sleeper
130
+ @trace = trace
125
131
  end
126
132
 
127
133
  def handle(payload)
@@ -141,11 +147,28 @@ class MessageDisplay
141
147
 
142
148
  dir = self.class.buffer_path(tmp_root: @tmp_root, session_id: session_id, message_id: message_id)
143
149
 
144
- if index == 0
145
- handle_chunk_zero(dir, delta, cwd, final)
146
- else
147
- handle_later_chunk(dir, index, delta, final)
148
- end
150
+ @trace_detail = {}
151
+ result =
152
+ if index == 0
153
+ handle_chunk_zero(dir, delta, cwd, final)
154
+ else
155
+ handle_later_chunk(dir, index, delta, final)
156
+ end
157
+
158
+ emit_trace(index, final, result)
159
+ result
160
+ end
161
+
162
+ # One row per chunk, only when a sink was injected. Never raises: a trace
163
+ # is a diagnostic and must not be able to change what the hook returns.
164
+ def emit_trace(index, final, result)
165
+ return unless @trace
166
+
167
+ row = { "index" => index, "final" => final,
168
+ "displayed_bytes" => result.is_a?(String) ? result.bytesize : nil }
169
+ @trace.call(row.merge(@trace_detail.to_h))
170
+ rescue StandardError
171
+ nil
149
172
  end
150
173
 
151
174
  # The message directory both this class and the bash launcher (hooks/
@@ -164,6 +187,17 @@ class MessageDisplay
164
187
  File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), SCREEN_FILE)
165
188
  end
166
189
 
190
+ # A trace sink that appends one JSON object per chunk to `path`. Every
191
+ # failure is swallowed: an unwritable path must never turn a diagnostic
192
+ # into a broken hook (D5, fail open).
193
+ def self.file_trace(path)
194
+ lambda do |row|
195
+ File.open(path, "a") { |f| f.puts(JSON.generate(row)) }
196
+ rescue StandardError
197
+ nil
198
+ end
199
+ end
200
+
167
201
  def self.noscreen_path(tmp_root:, session_id:, message_id:)
168
202
  File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), NOSCREEN_FILE)
169
203
  end
@@ -186,10 +220,12 @@ class MessageDisplay
186
220
  def handle_chunk_zero(dir, delta, _cwd, final)
187
221
  split = split_at_opener(delta)
188
222
  unless split
223
+ @trace_detail["decision"] = "noscreen"
189
224
  write_noscreen(dir)
190
225
  return nil
191
226
  end
192
227
 
228
+ @trace_detail["decision"] = "engage"
193
229
  engage(dir, 0, split, final)
194
230
  end
195
231
 
@@ -199,10 +235,25 @@ class MessageDisplay
199
235
  # is already on disk. Only once its own delta carries no opener does it
200
236
  # fall back to the original decision-driven wait.
201
237
  def handle_later_chunk(dir, index, delta, final)
238
+ # 331a1: an opener engages only a message that is NOT already a screen.
239
+ # 331a's late engagement exists for the prose-first reply, where chunk 0
240
+ # wrote NOSCREEN and a later chunk carries the title; it must not fire
241
+ # again once SCREEN is on disk. A reply can hold several screens back to
242
+ # back - the roster is a table then ten cards, the session report is many
243
+ # delivered screens - and re-engaging on each one returned that chunk's
244
+ # own prefix as raw Markdown and rewrote the start index, so the final
245
+ # chunk spliced from the LAST opener and everything before it reached the
246
+ # terminal unpainted. Inside an engaged message a later opener is simply
247
+ # content: it is buffered like any other line and the painter, which
248
+ # already understands a run of screens, lays all of them out.
202
249
  split = split_at_opener(delta)
203
- return engage(dir, index, split, final) if split
250
+ if split && !engaged?(dir)
251
+ @trace_detail["decision"] = "engage"
252
+ return engage(dir, index, split, final)
253
+ end
204
254
 
205
255
  decision = wait_for_decision(dir, gate_delta: final ? nil : delta, index: index)
256
+ @trace_detail["decision"] = decision.to_s
206
257
 
207
258
  return nil unless decision == :screen
208
259
 
@@ -210,6 +261,10 @@ class MessageDisplay
210
261
  final ? finalize_final(dir, index) : ""
211
262
  end
212
263
 
264
+ def engaged?(dir)
265
+ File.exist?(File.join(dir, SCREEN_FILE))
266
+ end
267
+
213
268
  # Engages the message starting at THIS chunk (whatever its index): writes
214
269
  # SCREEN carrying this chunk's index (replacing any NOSCREEN, D2/D6),
215
270
  # buffers the opener onward at this chunk's own index, and returns the
@@ -426,6 +481,7 @@ class MessageDisplay
426
481
 
427
482
  region_stop = ScreenPaint.region_end(lines, start)
428
483
  painted = ScreenPaint.paint(lines[start...region_stop].join, color: true, markdown_safe: true)
484
+ trace_region(buffered, lines, start, region_stop, painted)
429
485
  return buffered unless painted
430
486
 
431
487
  # 331a (D4): a lone CLOSING fence immediately after the painted region is
@@ -445,6 +501,26 @@ class MessageDisplay
445
501
  out
446
502
  end
447
503
 
504
+ # What the final chunk actually painted, for the opt-in trace: how much was
505
+ # buffered, where the region ran, and the first line the grammar rejected -
506
+ # the one fact a terminal capture cannot give you. Computed only when a
507
+ # sink was injected, so an ordinary run pays nothing.
508
+ def trace_region(buffered, lines, start, region_stop, painted)
509
+ return unless @trace
510
+
511
+ rejected = ScreenPaint.first_rejected(lines, start)
512
+ @trace_detail.merge!(
513
+ "buffered_bytes" => buffered.bytesize,
514
+ "buffered_lines" => lines.length,
515
+ "region_start" => start,
516
+ "region_stop" => region_stop,
517
+ "painted" => !painted.nil?,
518
+ "first_rejected_line" => rejected && { "index" => rejected[0], "text" => rejected[1][0, 200] },
519
+ )
520
+ rescue StandardError
521
+ nil
522
+ end
523
+
448
524
  def write_chunk(dir, index, delta)
449
525
  atomic_write(File.join(dir, index.to_s), delta)
450
526
  end
@@ -38,6 +38,19 @@ module ScreenPaint
38
38
  STEP_LINE_RE = /\A(S\d+)\s+\[ (open|done) \]\s+(.*)\z/.freeze
39
39
  TIMELINE_RE = /\A(\d\d:\d\d)\s{2}(\S+)\s{2}(.*)\z/.freeze
40
40
  COUNT_LINE_RE = /\A\d+ open( · .*)?\z/.freeze
41
+ # 331a1: the session verb prints this note between the delivered screens and
42
+ # the roster (report_screen.rb, render_session). It is our own output, so the
43
+ # painter must not reject it - the live hook trace caught the region stopping
44
+ # dead on it, with the whole roster below reaching the terminal as plain
45
+ # Markdown. Pinned to the exact shape rather than "any sentence": region_end
46
+ # exists to stop at the model's own prose, and a loose rule would swallow it.
47
+ SKIP_NOTE_RE = /\A\d+ completed intents? skipped: .+\z/.freeze
48
+ # 331a2 (D5, S3): render_session's own rescue card when a directory's delivered screen
49
+ # raises (report_screen.rb:1466-1468) - our own output, not model prose. The census (331a2)
50
+ # proved it classified :unknown, orphaning the roster below it in a real reply. `:meta`, not
51
+ # a new kind: it is a one-line grey note under a title and paint's `:meta` arm reads only
52
+ # `line.strip`, with no positional dependency.
53
+ RESCUE_CARD_RE = /\A## \S+ · could not render \(.*\)\z/.freeze
41
54
  BOLD_LEAD_RE = /\A\*\*([^*]+)\*\*(.*)\z/.freeze
42
55
 
43
56
  # Intent 317a1 (D3, D4, D5): the data-table palette. Kind and note columns
@@ -195,11 +208,12 @@ module ScreenPaint
195
208
  return :table if text.lstrip.start_with?("|")
196
209
  return :bold if BOLD_LEAD_RE.match?(stripped) && text == stripped
197
210
  return :meta if idx && opener_idx && idx == opener_idx + 1 && stripped.include?(" · ")
211
+ return :meta if RESCUE_CARD_RE.match?(stripped)
198
212
  return :indented if text.start_with?(" ")
199
213
  return :field if FIELD_LINE_RE.match?(text)
200
214
  return :step if STEP_LINE_RE.match?(text)
201
215
  return :timeline if TIMELINE_RE.match?(text)
202
- return :count if COUNT_LINE_RE.match?(stripped)
216
+ return :count if COUNT_LINE_RE.match?(stripped) || SKIP_NOTE_RE.match?(stripped)
203
217
  return :closer if ["None", "not recorded", "No intents in delivery.", "No intents delivered in this session."].include?(stripped)
204
218
  :unknown
205
219
  end
@@ -207,11 +221,22 @@ module ScreenPaint
207
221
  # Where the screen region ends inside a larger message (B10): walk from the
208
222
  # opener while every line classifies; the first unknown line - ordinary
209
223
  # prose, a prose bullet - is the boundary. Never consumes past the screen.
224
+ #
225
+ # 331a1: `opener_idx` tracks the NEAREST preceding opener, not the first
226
+ # one in the message. A reply can carry several screens back to back - the
227
+ # roster is a table then ten cards, the session report is many delivered
228
+ # screens - and `classify`'s positional rule is "the line right after a
229
+ # title", which means the title above it. Testing every line against the
230
+ # message's first opener made the timestamp under the SECOND screen
231
+ # :unknown, and the region stopped there: 30 lines of a 350-line session
232
+ # report painted, the rest reaching the terminal as plain Markdown.
210
233
  def region_end(lines, start_idx)
211
234
  i = start_idx + 1
235
+ opener_idx = start_idx
212
236
  while i < lines.length
213
- kind = classify(lines[i], idx: i, opener_idx: start_idx)
237
+ kind = classify(lines[i], idx: i, opener_idx: opener_idx)
214
238
  break if kind == :unknown
239
+ opener_idx = i if kind == :opener
215
240
  # A bare "**Section**" head belongs to the screen only when what follows
216
241
  # is still grammar; "**What this means**" over prose bullets is the
217
242
  # model's own commentary and stays outside, unsplit (B10).
@@ -225,6 +250,22 @@ module ScreenPaint
225
250
  i
226
251
  end
227
252
 
253
+ # The first line the grammar rejects, walking from the opener under the same
254
+ # nearest-opener rule region_end uses. Returns [index, line] or nil when the
255
+ # whole run classifies. 331a1: this is what the opt-in hook trace reports,
256
+ # so a live run says which line stopped the region instead of leaving it to
257
+ # be guessed from a terminal capture.
258
+ def first_rejected(lines, start_idx)
259
+ opener_idx = start_idx
260
+ ((start_idx + 1)...lines.length).each do |i|
261
+ kind = classify(lines[i], idx: i, opener_idx: opener_idx)
262
+ return [i, lines[i].to_s.rstrip] if kind == :unknown
263
+
264
+ opener_idx = i if kind == :opener
265
+ end
266
+ nil
267
+ end
268
+
228
269
  def bare_bold?(line)
229
270
  m = BOLD_LEAD_RE.match(line.strip)
230
271
  m && m[2].to_s.strip.empty?
@@ -271,8 +312,14 @@ module ScreenPaint
271
312
  table.clear
272
313
  end
273
314
 
315
+ # 331a1: the same nearest-opener rule region_end uses - a reply carrying
316
+ # several screens must classify each one's meta line against its own
317
+ # title, not against the first title in the message.
318
+ opener_idx = first_idx
319
+
274
320
  lines.each_with_index do |line, idx|
275
- kind = classify(line, idx: idx, opener_idx: first_idx)
321
+ kind = classify(line, idx: idx, opener_idx: opener_idx)
322
+ opener_idx = idx if kind == :opener
276
323
  # Intent 317a1 (O6, D8): the run counter tracks consecutive :indented
277
324
  # lines so only the SECOND and later lines under a heading like
278
325
  # "**Asked**" grey as a note; a :table line (which `next`s below) must