@zalom/plastic 2.0.0-alpha.14 → 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.
@@ -107,4 +107,28 @@ fi
107
107
 
108
108
  [ "$handoff" = 1 ] || exit 0
109
109
 
110
+ # 331a1 (D1): chunk 0 is about to hand off to Ruby, which takes on the
111
+ # order of 150 ms to boot before it ever writes SCREEN or NOSCREEN. Claude
112
+ # Code fires the per-chunk hook PROCESSES CONCURRENTLY (see the header
113
+ # comment above), so a later chunk landing inside that window used to find
114
+ # no message directory at all, fail this script's own cheap shape test,
115
+ # and pass through as raw Markdown - measured at 15 to 21 chunks of every
116
+ # screen, against a stream-realistic 5 ms stagger. Staking a PENDING
117
+ # marker here, the instant the handoff gate above passes, closes that
118
+ # window down to microseconds: a later chunk's own "does the directory
119
+ # exist" check (below, unchanged) now finds it almost at once, and
120
+ # MessageDisplay polls for the real decision instead of judging the
121
+ # chunk's own shape. mkdir is the one non-builtin on this whole path - it
122
+ # forks, unlike everything else in this file - but it runs ONLY here,
123
+ # after the handoff gate, so the ordinary (non-candidate) chunk that is
124
+ # the overwhelming common case still forks nothing (matrix L8). A failed
125
+ # mkdir or printf (an unwritable tmp root, a race with another process) is
126
+ # silently swallowed: it must never change this script's own exit status
127
+ # or skip the Ruby handoff below - Ruby's own polling already fails open
128
+ # (D5) when no decision ever appears.
129
+ if [ "$is_index_zero" = 1 ]; then
130
+ mkdir -p "$MSGDIR" 2>/dev/null
131
+ printf '' > "$MSGDIR/PENDING" 2>/dev/null
132
+ fi
133
+
110
134
  printf '%s' "$INPUT" | env -u RUBYOPT ruby "$SCRIPT_DIR/../scripts/hook-message-display"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "2.0.0-alpha.14",
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
 
@@ -56,6 +56,89 @@ module HookReplay
56
56
  end
57
57
  end
58
58
 
59
+ # replay_concurrent (intent 331a1) - streams `text` through `hook_path` the
60
+ # way `replay` does, but fires every chunk in its OWN thread, staggered by
61
+ # `gap_ms` (plus up to half a gap of jitter when `jitter` is true) rather
62
+ # than run sequentially. This is what reproduces the decision-race defect
63
+ # 331a1 fixes: Claude Code fires the per-chunk hook processes CONCURRENTLY
64
+ # in production, and `replay`'s strictly-sequential default never puts two
65
+ # chunks in flight at once, so it could never have reproduced the race in
66
+ # the first place.
67
+ #
68
+ # `gap_ms: 5` plus jitter is the default on purpose, not "fire everything
69
+ # at once": firing all 335 chunks of the live session capture with no
70
+ # stagger at all takes about 8 s of wall clock on this 8-core machine,
71
+ # because EACH chunk boots its own real Ruby process and the completions
72
+ # cluster at the tail once every core is saturated - a load real streaming
73
+ # never produces (a real stream delivers a chunk every few tens of
74
+ # milliseconds, one at a time). One Ruby process per streamed chunk is the
75
+ # actual throughput ceiling here, not something this method works around.
76
+ #
77
+ # `replay`'s own signature and sequential default are UNCHANGED by this
78
+ # method's existence (`scripts/doctor.rb:2571` calls `replay` directly and
79
+ # must keep working exactly as it does today) - this is a sibling method
80
+ # in the same module, never a replacement.
81
+ #
82
+ # Returns the SAME result shape `replay` returns (one Hash per chunk, keys
83
+ # index/exitstatus/stdout/stderr/final), ordered by index regardless of
84
+ # the order the threads actually finish in.
85
+ def replay_concurrent(hook_path:, tmp_root:, text:, chunk: 40, session_id: "s-replay",
86
+ message_id: "replay", env: {}, gap_ms: 5, jitter: true)
87
+ chunks = text.scan(/.{1,#{chunk}}/m)
88
+ chunks = [""] if chunks.empty?
89
+ full_env = { "PLASTIC_TMP" => tmp_root }.merge(env)
90
+ gap = gap_ms / 1000.0
91
+
92
+ results = Array.new(chunks.length)
93
+ threads = chunks.each_with_index.map do |delta, i|
94
+ payload = {
95
+ "session_id" => session_id, "message_id" => message_id, "index" => i,
96
+ "final" => i == chunks.length - 1, "delta" => delta, "cwd" => tmp_root,
97
+ "hook_event_name" => "MessageDisplay",
98
+ }
99
+ Thread.new do
100
+ begin
101
+ delay = i * gap
102
+ delay += (rand * gap / 2.0) if jitter
103
+ sleep(delay)
104
+ out, err, exitstatus = run_one(hook_path, payload, full_env, tmp_root, nil)
105
+ results[i] = { index: i, exitstatus: exitstatus, stdout: out, stderr: err, final: payload["final"] }
106
+ rescue StandardError => e
107
+ # A raise inside a thread body is invisible until join, and an
108
+ # unrescued one aborts `threads.each(&:join)` at the first dead
109
+ # thread: every later thread is then never joined and outlives the
110
+ # call, racing whatever the caller does next (typically removing
111
+ # the very tmp root those threads are still writing under). Report
112
+ # the failure as this chunk's own result instead, so the array is
113
+ # always complete, every thread is always joined, and a replay
114
+ # tells its caller what went wrong rather than throwing at it.
115
+ results[i] = { index: i, exitstatus: nil, stdout: "", stderr: e.message,
116
+ final: payload["final"] }
117
+ end
118
+ end
119
+ end
120
+ threads.each { |thread| thread.join }
121
+ results
122
+ end
123
+
124
+ # Indices of the chunks that reached the terminal as raw Markdown: a
125
+ # non-final chunk that emitted nothing at all, after the engaging chunk.
126
+ # A chunk "passed through" when its stdout is empty; a chunk was
127
+ # "blanked" (correctly buffered, not shown raw) when its stdout contains
128
+ # `"displayContent":""`. Only chunks with an index greater than the
129
+ # engaging chunk's own index count - the engaging chunk is the first one
130
+ # (at or after `start_index`) whose stdout is non-empty, and chunks
131
+ # before it already reached the terminal live, verbatim, through the
132
+ # ordinary passthrough path (they were never candidates for buffering at
133
+ # all, so an empty stdout from one of them is not this defect).
134
+ def passthrough_indices(outs, start_index: 0)
135
+ engaging = outs.find { |o| o[:index] >= start_index && !o[:stdout].to_s.empty? }
136
+ return [] unless engaging
137
+
138
+ outs.select { |o| o[:index] > engaging[:index] && o[:final] != true && o[:stdout].to_s.empty? }
139
+ .map { |o| o[:index] }
140
+ end
141
+
59
142
  def run_one(hook_path, payload, full_env, tmp_root, timeout)
60
143
  return capture(hook_path, payload, full_env) unless timeout
61
144
 
@@ -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
@@ -60,6 +61,35 @@ require_relative "screen_paint"
60
61
  # (D4): a lone fence in the engaging chunk's own prefix, and a lone closing
61
62
  # fence right after the painted region in `finalize`. Neither ever reaches
62
63
  # back into an earlier, already-displayed chunk.
64
+ #
65
+ # Intent 331a1: the decision marker (D1-D3). 331a's own comment above already
66
+ # names the concurrency; what it did not close is chunk 0's own boot time.
67
+ # Chunk 0's Ruby process takes on the order of 150 ms to boot before it ever
68
+ # writes SCREEN or NOSCREEN - long enough, under a fast real stream, for a
69
+ # dozen or more later chunks to be judged with nothing on disk at all, so
70
+ # every one of them fell back to the cheap shape test and, being ordinary
71
+ # non-table prose, passed straight through plain. The bash launcher (hooks/
72
+ # message-display) now stakes a PENDING file with builtins the instant
73
+ # chunk 0 is handed off, before Ruby ever starts, so a later chunk finds the
74
+ # message directory within microseconds instead of after Ruby's own boot.
75
+ # While PENDING exists, a later chunk polls for the real decision WHATEVER
76
+ # ITS OWN SHAPE looks like - `maybe_screen?` is not consulted at all, because
77
+ # a decision is certainly coming, and the cheap shape gate exists only for
78
+ # the "nothing at all exists yet, is a wait even worth paying for" case,
79
+ # which no longer applies once something IS on disk. A PENDING whose mtime
80
+ # is already older than THIS chunk's own poll budget reads as NOSCREEN (D2,
81
+ # fail open): chunk 0 must have died or hung, and waiting out a whole budget
82
+ # for a decision that is provably not coming would only delay every chunk
83
+ # behind it. That staleness check runs ONCE, before any polling, since a
84
+ # file's mtime never changes while this process looks at it. The poll
85
+ # budget itself scales with the chunk's own index (`budget_ms`, D3): base
86
+ # wait_ms plus index_wait_ms per index, capped at max_wait_ms, so the final
87
+ # chunk of a long streamed message (335 chunks, in the live capture that
88
+ # reproduced this) is allowed to wait for a decision that is certainly on
89
+ # its way, while chunk 1 of an ordinary short message still fails open
90
+ # quickly. Chunk 0 removes PENDING the moment it writes SCREEN or NOSCREEN
91
+ # (`write_screen`/`write_noscreen`), on both paths, so "a decision already
92
+ # exists" and "PENDING is still there" are never both true for long.
63
93
  class MessageDisplay
64
94
  # 317a (A4): engagement is grammar, not identity - any screen-family
65
95
  # opener engages, with NO intent-id resolution (the roster and delay
@@ -76,16 +106,28 @@ class MessageDisplay
76
106
  BUFFER_MAX_AGE_SECONDS = 3600
77
107
  SCREEN_FILE = "SCREEN"
78
108
  NOSCREEN_FILE = "NOSCREEN"
79
-
109
+ # 331a1 (D1): staked by the bash launcher, with builtins, the instant
110
+ # chunk 0 is handed off - before Ruby ever boots. Replaced by SCREEN or
111
+ # NOSCREEN (D2), never read by this class as a decision in its own right.
112
+ PENDING_FILE = "PENDING"
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.
80
118
  def initialize(tmp_root:, plastic_home:, color:, now:, wait_ms: 300, poll_ms: 20,
81
- sleeper: ->(seconds) { sleep(seconds) })
119
+ index_wait_ms: 20, max_wait_ms: 2000,
120
+ sleeper: ->(seconds) { sleep(seconds) }, trace: nil)
82
121
  @tmp_root = tmp_root
83
122
  @plastic_home = plastic_home
84
123
  @color = color
85
124
  @now = now
86
125
  @wait_ms = wait_ms
87
126
  @poll_ms = poll_ms
127
+ @index_wait_ms = index_wait_ms
128
+ @max_wait_ms = max_wait_ms
88
129
  @sleeper = sleeper
130
+ @trace = trace
89
131
  end
90
132
 
91
133
  def handle(payload)
@@ -105,11 +147,28 @@ class MessageDisplay
105
147
 
106
148
  dir = self.class.buffer_path(tmp_root: @tmp_root, session_id: session_id, message_id: message_id)
107
149
 
108
- if index == 0
109
- handle_chunk_zero(dir, delta, cwd, final)
110
- else
111
- handle_later_chunk(dir, index, delta, final)
112
- 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
113
172
  end
114
173
 
115
174
  # The message directory both this class and the bash launcher (hooks/
@@ -128,10 +187,28 @@ class MessageDisplay
128
187
  File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), SCREEN_FILE)
129
188
  end
130
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
+
131
201
  def self.noscreen_path(tmp_root:, session_id:, message_id:)
132
202
  File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), NOSCREEN_FILE)
133
203
  end
134
204
 
205
+ # 331a1 (matrix L1): the bash launcher (hooks/message-display) and this
206
+ # class must agree, byte for byte, on where PENDING lives - the same
207
+ # contract `buffer_path` already carries for SCREEN/NOSCREEN (matrix 40).
208
+ def self.pending_path(tmp_root:, session_id:, message_id:)
209
+ File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), PENDING_FILE)
210
+ end
211
+
135
212
  private
136
213
 
137
214
  # Chunk 0 decides, synchronously, before anything else touches this
@@ -143,10 +220,12 @@ class MessageDisplay
143
220
  def handle_chunk_zero(dir, delta, _cwd, final)
144
221
  split = split_at_opener(delta)
145
222
  unless split
223
+ @trace_detail["decision"] = "noscreen"
146
224
  write_noscreen(dir)
147
225
  return nil
148
226
  end
149
227
 
228
+ @trace_detail["decision"] = "engage"
150
229
  engage(dir, 0, split, final)
151
230
  end
152
231
 
@@ -156,10 +235,25 @@ class MessageDisplay
156
235
  # is already on disk. Only once its own delta carries no opener does it
157
236
  # fall back to the original decision-driven wait.
158
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.
159
249
  split = split_at_opener(delta)
160
- 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
161
254
 
162
- decision = wait_for_decision(dir, gate_delta: final ? nil : delta)
255
+ decision = wait_for_decision(dir, gate_delta: final ? nil : delta, index: index)
256
+ @trace_detail["decision"] = decision.to_s
163
257
 
164
258
  return nil unless decision == :screen
165
259
 
@@ -167,6 +261,10 @@ class MessageDisplay
167
261
  final ? finalize_final(dir, index) : ""
168
262
  end
169
263
 
264
+ def engaged?(dir)
265
+ File.exist?(File.join(dir, SCREEN_FILE))
266
+ end
267
+
170
268
  # Engages the message starting at THIS chunk (whatever its index): writes
171
269
  # SCREEN carrying this chunk's index (replacing any NOSCREEN, D2/D6),
172
270
  # buffers the opener onward at this chunk's own index, and returns the
@@ -212,17 +310,34 @@ class MessageDisplay
212
310
  lines[0...-1].join
213
311
  end
214
312
 
215
- # Checks for an existing decision first (free) and only pays the cheap
216
- # shape test, then the bounded poll, when neither SCREEN nor NOSCREEN is
217
- # there yet. `gate_delta: nil` (the final chunk) skips the shape test
218
- # entirely and always polls for the decision.
219
- def wait_for_decision(dir, gate_delta:)
313
+ # Checks for an existing decision first (free). Then, 331a1 (D1/D2), the
314
+ # whole fix: when PENDING exists, a decision is certainly coming, so this
315
+ # chunk polls for it WHATEVER ITS OWN SHAPE looks like - `maybe_screen?`
316
+ # is never even consulted on this branch - unless PENDING is already
317
+ # stale (older than this chunk's own budget), which reads as NOSCREEN at
318
+ # once, fail open, without ever polling. Only when there is no PENDING
319
+ # AT ALL (chunk 0 has not even been handed off to the bash launcher yet,
320
+ # or this replay never wrote one) does today's original behavior apply:
321
+ # the cheap shape test gates whether a bounded poll is worth paying for.
322
+ # `gate_delta: nil` (the final chunk) skips that shape test entirely and
323
+ # always polls.
324
+ def wait_for_decision(dir, gate_delta:, index:)
220
325
  decision = read_decision_now(dir)
221
326
  return decision if decision
222
327
 
328
+ if pending?(dir)
329
+ return :noscreen if pending_stale?(dir, index)
330
+
331
+ return poll_for_decision(dir, index)
332
+ end
333
+
223
334
  return :timeout if gate_delta && !maybe_screen?(gate_delta)
224
335
 
225
- max_polls_for_budget.times do
336
+ poll_for_decision(dir, index)
337
+ end
338
+
339
+ def poll_for_decision(dir, index)
340
+ max_polls_for_budget(index).times do
226
341
  @sleeper.call(@poll_ms / 1000.0)
227
342
  decision = read_decision_now(dir)
228
343
  return decision if decision
@@ -238,6 +353,24 @@ class MessageDisplay
238
353
  nil
239
354
  end
240
355
 
356
+ def pending?(dir)
357
+ File.exist?(File.join(dir, PENDING_FILE))
358
+ end
359
+
360
+ # Checked ONCE, before any polling - a file's mtime never changes while
361
+ # this process is looking at it, so re-checking inside the poll loop
362
+ # would only ever repeat the same answer. Any error reading the mtime
363
+ # (a race where PENDING vanished between `pending?` and here, most
364
+ # likely because the real decision just landed) is NOT staleness: it
365
+ # falls through to the ordinary poll, which will pick up that decision
366
+ # on its very next read.
367
+ def pending_stale?(dir, index)
368
+ age_ms = (@now.to_f - File.mtime(File.join(dir, PENDING_FILE)).to_f) * 1000
369
+ age_ms > budget_ms(index)
370
+ rescue StandardError
371
+ false
372
+ end
373
+
241
374
  # 331a (M5a): the start index crosses process boundaries through SCREEN's
242
375
  # own content, never in-memory state - the final chunk is routinely a
243
376
  # SEPARATE process from the one that engaged. An empty or missing file
@@ -288,21 +421,34 @@ class MessageDisplay
288
421
  end
289
422
  end
290
423
 
424
+ # 331a1 (D3): index-scaled too, same as the decision poll - the final
425
+ # chunk of a long streamed message (335 chunks, in the live capture that
426
+ # reproduced this) must be allowed to wait long enough for the earlier
427
+ # chunk files to land, not just the base wait_ms an ordinary short
428
+ # message gets by with.
291
429
  def wait_for_chunk_files(dir, start_index, index)
292
430
  return if index <= start_index
293
431
 
294
432
  needed = (start_index...index).map(&:to_s)
295
- max_polls_for_budget.times do
433
+ max_polls_for_budget(index).times do
296
434
  return if needed.all? { |n| File.exist?(File.join(dir, n)) }
297
435
 
298
436
  @sleeper.call(@poll_ms / 1000.0)
299
437
  end
300
438
  end
301
439
 
302
- def max_polls_for_budget
440
+ # 331a1 (D3): base wait_ms plus index_wait_ms per chunk index, capped at
441
+ # max_wait_ms - a chunk deep into a long streamed message is certainly
442
+ # going to see its decision eventually, so it is allowed to wait longer
443
+ # than chunk 1 of an ordinary short message.
444
+ def budget_ms(index)
445
+ [@wait_ms + @index_wait_ms * index.to_i, @max_wait_ms].min
446
+ end
447
+
448
+ def max_polls_for_budget(index)
303
449
  return 0 unless @poll_ms.to_f.positive?
304
450
 
305
- (@wait_ms / @poll_ms.to_f).ceil
451
+ (budget_ms(index) / @poll_ms.to_f).ceil
306
452
  end
307
453
 
308
454
  # Whatever chunk files exist FROM THE START INDEX onward, in index order,
@@ -335,6 +481,7 @@ class MessageDisplay
335
481
 
336
482
  region_stop = ScreenPaint.region_end(lines, start)
337
483
  painted = ScreenPaint.paint(lines[start...region_stop].join, color: true, markdown_safe: true)
484
+ trace_region(buffered, lines, start, region_stop, painted)
338
485
  return buffered unless painted
339
486
 
340
487
  # 331a (D4): a lone CLOSING fence immediately after the painted region is
@@ -354,6 +501,26 @@ class MessageDisplay
354
501
  out
355
502
  end
356
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
+
357
524
  def write_chunk(dir, index, delta)
358
525
  atomic_write(File.join(dir, index.to_s), delta)
359
526
  end
@@ -363,12 +530,18 @@ class MessageDisplay
363
530
  # separate process, in production) knows where to start waiting and
364
531
  # splicing, and so `finalize_final` never touches chunks that were passed
365
532
  # through untouched before engagement.
533
+ # 331a1 (D2): the decision REPLACES PENDING, on this path too, whichever
534
+ # chunk turns out to be the one that engages.
366
535
  def write_screen(dir, index)
367
536
  atomic_write(File.join(dir, SCREEN_FILE), "#{index}\n")
537
+ remove_pending(dir)
368
538
  end
369
539
 
540
+ # 331a1 (D2): same replacement on the NOSCREEN path, so a later chunk
541
+ # never finds both PENDING and NOSCREEN and has to choose between them.
370
542
  def write_noscreen(dir)
371
543
  atomic_write(File.join(dir, NOSCREEN_FILE), "")
544
+ remove_pending(dir)
372
545
  end
373
546
 
374
547
  # 331a (D2/D6): NOSCREEN is no longer a final answer - a later chunk that
@@ -378,6 +551,15 @@ class MessageDisplay
378
551
  FileUtils.rm_f(File.join(dir, NOSCREEN_FILE))
379
552
  end
380
553
 
554
+ # 331a1 (D2): removal must never raise - a decision was already written
555
+ # successfully by the time this runs, and a stray filesystem error here
556
+ # must never turn a successful decision into an unhandled exception.
557
+ def remove_pending(dir)
558
+ FileUtils.rm_f(File.join(dir, PENDING_FILE))
559
+ rescue StandardError
560
+ nil
561
+ end
562
+
381
563
  def atomic_write(path, content)
382
564
  FileUtils.mkdir_p(File.dirname(path))
383
565
  tmp_path = "#{path}.tmp#{Process.pid}-#{rand(1_000_000)}"
@@ -197,9 +197,25 @@ module ReportScreen
197
197
  budget = limit - (4 + 3 * (ncols - 1)) - overage
198
198
  widths = ScreenPaint.shrink_column_widths(widths, budget, bar_columns: bar_column, floors: floors)
199
199
 
200
+ # 331f1a (D1/D2, plan-review ruling): a separator row passes through byte-identical
201
+ # whenever its OWN unfitted input already fits the bound - rebuilding it from the
202
+ # shrunk widths (with the `[w, 3].max` floor below) is what made it assemble wider
203
+ # than any data row in the first place, landing it as the only row the backstop ever
204
+ # cut (or, when the rebuilt form happened to still fit, wider than its own "---"
205
+ # input, which D1 forbids just as much). Only when even the unmodified input cannot
206
+ # fit does the old rebuild-and-backstop path apply - the bound wins there, which is
207
+ # exactly what test_fit_screen_backstops_an_unshrinkable_row and
208
+ # test_unshrinkable_data_table_is_still_bounded pin. A blank "| | | |" scaffold
209
+ # reaching this branch is classified as a separator by the same regex, so it gets
210
+ # the identical pass-through rule.
200
211
  fitted_rows = raw_rows.each_with_index.map do |cells, ri|
201
212
  if is_sep[ri]
202
- "| #{widths.map { |w| "-" * [w, 3].max }.join(" | ")} |"
213
+ original = rows[ri]
214
+ if ScreenPaint.display_columns(original) <= limit
215
+ original
216
+ else
217
+ "| #{widths.map { |w| "-" * [w, 3].max }.join(" | ")} |"
218
+ end
203
219
  else
204
220
  rendered = cells.each_with_index.map do |c, ci|
205
221
  next c.to_s.strip if ci >= ncols
@@ -215,7 +231,8 @@ module ReportScreen
215
231
  # and the assembled row can still be over the limit; truncate the whole row on a word
216
232
  # boundary rather than let it survive past 115 - a data table's separator row included
217
233
  # (test_fit_screen_backstops_an_unshrinkable_row), unlike the field-table fitter's own
218
- # separator, which always passes through untouched (W2).
234
+ # separator, which always passes through untouched (W2). A separator already passed
235
+ # through byte-identical above never trips this (it already fits by construction).
219
236
  fitted_rows.map! { |r| ScreenPaint.display_columns(r) > limit ? truncate_on_word_boundary(r, limit) : r }
220
237
 
221
238
  "#{fitted_rows.join("\n")}\n"
@@ -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