@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.21

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 (127) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/bin/test +24 -4
  4. package/hooks/call-budget +4 -0
  5. package/hooks/hooks.json +24 -0
  6. package/hooks/message-display +134 -0
  7. package/hooks/statusline +4 -27
  8. package/package.json +2 -2
  9. package/scripts/agent-report +8 -2
  10. package/scripts/dashboard.rb +297 -18
  11. package/scripts/doctor.rb +652 -5
  12. package/scripts/end-intent +219 -0
  13. package/scripts/hook-call-budget +222 -0
  14. package/scripts/hook-capture +25 -113
  15. package/scripts/hook-message-display +81 -0
  16. package/scripts/hook-record +12 -4
  17. package/scripts/hook-session-start +45 -7
  18. package/scripts/index-projection +74 -0
  19. package/scripts/intent-screen +77 -0
  20. package/scripts/lib/action_graph_shim.rb +277 -0
  21. package/scripts/lib/arm.rb +26 -1
  22. package/scripts/lib/atomic_write.rb +31 -0
  23. package/scripts/lib/compact_instructions.rb +5 -5
  24. package/scripts/lib/core_integrity.rb +71 -0
  25. package/scripts/lib/dashboard_screen.rb +40 -0
  26. package/scripts/lib/day_summary.rb +7 -2
  27. package/scripts/lib/doctor_core.rb +102 -5
  28. package/scripts/lib/doctor_session_ledger.rb +52 -0
  29. package/scripts/lib/graph_edges.rb +137 -0
  30. package/scripts/lib/graph_file.rb +246 -0
  31. package/scripts/lib/graph_tree.rb +98 -0
  32. package/scripts/lib/guarded_append.rb +155 -0
  33. package/scripts/lib/hook_registry.rb +28 -2
  34. package/scripts/lib/hook_replay.rb +211 -0
  35. package/scripts/lib/index_projection.rb +201 -0
  36. package/scripts/lib/installer_core.rb +141 -6
  37. package/scripts/lib/intent_screen.rb +309 -0
  38. package/scripts/lib/intent_screen_ansi.rb +262 -0
  39. package/scripts/lib/message_display.rb +586 -0
  40. package/scripts/lib/meter_watch.rb +179 -0
  41. package/scripts/lib/node_file.rb +214 -0
  42. package/scripts/lib/node_ids.rb +99 -0
  43. package/scripts/lib/node_ledger.rb +377 -0
  44. package/scripts/lib/node_packet.rb +908 -0
  45. package/scripts/lib/node_return.rb +199 -0
  46. package/scripts/lib/node_worktree.rb +337 -0
  47. package/scripts/lib/outcome_report.rb +440 -0
  48. package/scripts/lib/packet_wrapper.rb +132 -0
  49. package/scripts/lib/ready_set.rb +462 -0
  50. package/scripts/lib/release_guard.rb +16 -0
  51. package/scripts/lib/report_screen.rb +1967 -0
  52. package/scripts/lib/roadmap_graph.rb +210 -0
  53. package/scripts/lib/roadmap_migration.rb +95 -0
  54. package/scripts/lib/roadmap_queue.rb +155 -5
  55. package/scripts/lib/roadmap_render.rb +150 -0
  56. package/scripts/lib/roadmap_savepoint.rb +62 -12
  57. package/scripts/lib/runner_absorb.rb +620 -0
  58. package/scripts/lib/runner_answer.rb +206 -0
  59. package/scripts/lib/runner_core.rb +194 -0
  60. package/scripts/lib/runner_dispatch.rb +506 -0
  61. package/scripts/lib/runner_policy.rb +173 -0
  62. package/scripts/lib/runner_proposals.rb +275 -0
  63. package/scripts/lib/runner_rewind.rb +201 -0
  64. package/scripts/lib/runner_sweep.rb +231 -0
  65. package/scripts/lib/savepoint.rb +149 -12
  66. package/scripts/lib/screen_paint.rb +555 -0
  67. package/scripts/lib/screens/dashboard.rb +20 -0
  68. package/scripts/lib/screens/plan.rb +18 -0
  69. package/scripts/lib/screens/roadmap.rb +15 -0
  70. package/scripts/lib/session_git.rb +49 -18
  71. package/scripts/lib/session_ledger.rb +128 -0
  72. package/scripts/lib/session_usage.rb +190 -0
  73. package/scripts/lib/verify_intent.rb +33 -0
  74. package/scripts/lib/work_graph_validator.rb +201 -0
  75. package/scripts/meter-watch +57 -0
  76. package/scripts/node-packet +92 -0
  77. package/scripts/node-transition +291 -0
  78. package/scripts/outcome-report +74 -0
  79. package/scripts/plastic-lock +8 -1
  80. package/scripts/read-config +3 -3
  81. package/scripts/ready-set +126 -0
  82. package/scripts/release-check +118 -0
  83. package/scripts/report-screen +281 -0
  84. package/scripts/roadmap-graph +119 -0
  85. package/scripts/roadmap-savepoint +7 -0
  86. package/scripts/runner +397 -0
  87. package/scripts/savepoint-note +69 -0
  88. package/scripts/session-usage +56 -0
  89. package/scripts/spawn-preamble +9 -2
  90. package/scripts/validate-work-graph +39 -0
  91. package/scripts/verify-intent +2 -1
  92. package/skills/auto/SKILL.md +41 -34
  93. package/skills/auto/references/human-report-contract.md +136 -54
  94. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  95. package/skills/dashboard/SKILL.md +13 -2
  96. package/skills/dashboard/templates/dashboard-global.md +1 -1
  97. package/skills/dashboard/templates/dashboard-project.md +2 -2
  98. package/skills/doctor/SKILL.md +10 -4
  99. package/skills/intent-continuing/SKILL.md +51 -41
  100. package/skills/intent-continuing/references/board-fill.md +9 -0
  101. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  102. package/skills/intent-continuing/references/context-management.md +1 -1
  103. package/skills/intent-ending/SKILL.md +36 -16
  104. package/skills/intent-executing/SKILL.md +21 -5
  105. package/skills/intent-executing/implementer-prompt.md +6 -1
  106. package/skills/intent-speccing/SKILL.md +7 -4
  107. package/skills/releasing/SKILL.md +39 -0
  108. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  109. package/skills/releasing/references/release-lines.md +1 -1
  110. package/skills/roadmap/SKILL.md +26 -0
  111. package/skills/roadmap/references/file-format.md +10 -0
  112. package/templates/config.yml +3 -3
  113. package/templates/dashboard-screen.md +22 -0
  114. package/templates/display-fixture.md +21 -0
  115. package/templates/graph.md +16 -0
  116. package/templates/intent-screen.md +17 -0
  117. package/templates/node-decision.md +11 -0
  118. package/templates/node-research.md +11 -0
  119. package/templates/node-verify.md +13 -0
  120. package/templates/node-work.md +22 -0
  121. package/templates/outcome.md +19 -1
  122. package/templates/report-plan.md +15 -0
  123. package/templates/report-roadmap-delivered.md +10 -0
  124. package/templates/report-roadmap-plan.md +9 -0
  125. package/templates/report-roadmap-state.md +9 -0
  126. package/templates/report-state.md +11 -0
  127. package/templates/roadmap.md +13 -0
@@ -0,0 +1,586 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require "json"
6
+ require_relative "screen_paint"
7
+
8
+ # MessageDisplay (intent 316a, O4/O5, round 3 concurrency fix) - the Claude
9
+ # Code MessageDisplay hook handler. One process per streamed chunk of every
10
+ # assistant message (D11), so it must be cheap and decide fast. Pure: every
11
+ # dependency (tmp_root, plastic_home, color, now, wait_ms, poll_ms, sleeper)
12
+ # is a constructor argument, never an ENV read, a Dir.pwd/Dir.home read, or
13
+ # the real Time.now/Kernel#sleep — the thin CLI (scripts/hook-message-display)
14
+ # is the one place allowed to read any of those.
15
+ #
16
+ # Claude adapter: Claude Code only; the core is harness-agnostic. (intent
17
+ # 316a1, D3 supersedes 316a's D6.) This is the sole caller that asks
18
+ # IntentScreenAnsi.render for `markdown_safe: true` (scripts/lib/
19
+ # intent_screen_ansi.rb) — see `finalize` below for why.
20
+ #
21
+ # A live run under a real pty (round 3) found that Claude Code fires the
22
+ # per-chunk hook processes CONCURRENTLY, not strictly in order. Chunk 0 is
23
+ # the one that recognizes the screen and creates the buffer (D13), and it can
24
+ # lose the race to chunks with a higher index: they would find no buffer yet
25
+ # and pass their raw Markdown straight through, producing a half plain /
26
+ # half styled screen. This class now survives that:
27
+ #
28
+ # - One file per chunk (index-named), written atomically (temp name in the
29
+ # same directory, then File.rename), so reassembly never depends on
30
+ # arrival order — only on the index each chunk already carries.
31
+ # - A decision file written BEFORE anything slow: chunk 0 writes SCREEN
32
+ # (the resolved intent dir + store root) the moment it engages, or
33
+ # NOSCREEN the moment it does not, so later chunks can decide without
34
+ # redoing any of chunk 0's work.
35
+ # - A later chunk asks a cheap, local question before ever waiting: could
36
+ # this delta plausibly be part of a screen (leading "|", "**Steps**", or
37
+ # blank)? An ordinary prose chunk arriving before SCREEN/NOSCREEN exists
38
+ # passes through at once, at zero cost. A chunk shaped like part of a
39
+ # screen polls for the decision, bounded (wait_ms/poll_ms), then fails
40
+ # open. The final chunk always waits for the decision, whatever its own
41
+ # shape, since it is the one that must not race — and it additionally
42
+ # waits (same budget) for every earlier chunk file to exist before it
43
+ # splices, returning whatever it does have rather than nothing when the
44
+ # budget runs out.
45
+ #
46
+ # Protocol (D13, preserved): chunk 0 still decides, once, before anything is
47
+ # buffered or blanked. D10 (any failure while finalizing returns the
48
+ # buffered original, never nil, never "") and D12 (color: false never
49
+ # buffers or blanks anything) are unchanged.
50
+ #
51
+ # Intent 331a: engagement is late-capable. A chunk carrying a screen opener
52
+ # engages the message from that chunk on, whatever its own index - not only
53
+ # chunk 0. Chunks before it pass through untouched (they already reached the
54
+ # terminal live, via the ordinary passthrough path). The engaging chunk
55
+ # returns the text before the opener as its displayContent and buffers the
56
+ # opener onward at its OWN index; SCREEN now carries that index (a decimal
57
+ # integer, not an empty marker) so the final chunk - a separate process in
58
+ # production - knows where to start waiting and splicing (D3/D6), and so
59
+ # NOSCREEN, no longer a final answer (D2), can be replaced once a later
60
+ # chunk engages. A fence line immediately wrapping the opener is dropped
61
+ # (D4): a lone fence in the engaging chunk's own prefix, and a lone closing
62
+ # fence right after the painted region in `finalize`. Neither ever reaches
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.
93
+ class MessageDisplay
94
+ # 317a (A4): engagement is grammar, not identity - any screen-family
95
+ # opener engages, with NO intent-id resolution (the roster and delay
96
+ # screens have none to resolve). ScreenPaint owns the full grammar. Used
97
+ # per LINE (331a), not only against the start of a whole delta: a chunk's
98
+ # own text is scanned line by line for the first line that opens a screen,
99
+ # wherever it falls.
100
+ ENGAGE_RE = /\A(?:##? )?[▶✔] /.freeze
101
+ # 331a (D4): a lone fence line, opening (optional info string) or closing
102
+ # (never one), by itself on its own line.
103
+ FENCE_OPEN_RE = /\A```[^\n]*\z/.freeze
104
+ FENCE_CLOSE_RE = /\A```\z/.freeze
105
+ BUFFER_DIR_NAME = "plastic-message-display"
106
+ BUFFER_MAX_AGE_SECONDS = 3600
107
+ SCREEN_FILE = "SCREEN"
108
+ NOSCREEN_FILE = "NOSCREEN"
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.
118
+ def initialize(tmp_root:, plastic_home:, color:, now:, wait_ms: 300, poll_ms: 20,
119
+ index_wait_ms: 20, max_wait_ms: 2000,
120
+ sleeper: ->(seconds) { sleep(seconds) }, trace: nil)
121
+ @tmp_root = tmp_root
122
+ @plastic_home = plastic_home
123
+ @color = color
124
+ @now = now
125
+ @wait_ms = wait_ms
126
+ @poll_ms = poll_ms
127
+ @index_wait_ms = index_wait_ms
128
+ @max_wait_ms = max_wait_ms
129
+ @sleeper = sleeper
130
+ @trace = trace
131
+ end
132
+
133
+ def handle(payload)
134
+ return nil unless @color
135
+ return nil unless payload.is_a?(Hash)
136
+
137
+ prune_old_buffers
138
+
139
+ message_id = payload["message_id"].to_s
140
+ session_id = payload["session_id"].to_s
141
+ delta = payload["delta"].to_s
142
+ final = payload["final"] == true
143
+ index = payload["index"]
144
+ cwd = payload["cwd"].to_s
145
+
146
+ return nil if message_id.empty? || session_id.empty?
147
+
148
+ dir = self.class.buffer_path(tmp_root: @tmp_root, session_id: session_id, message_id: message_id)
149
+
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
172
+ end
173
+
174
+ # The message directory both this class and the bash launcher (hooks/
175
+ # message-display) must agree on byte for byte (matrix 40): the launcher
176
+ # checks this exact path's existence to decide whether chunk > 0 of an
177
+ # engaged message gets handed to Ruby at all.
178
+ def self.buffer_path(tmp_root:, session_id:, message_id:)
179
+ File.join(tmp_root, BUFFER_DIR_NAME, session_id, message_id)
180
+ end
181
+
182
+ def self.chunk_path(tmp_root:, session_id:, message_id:, index:)
183
+ File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), index.to_s)
184
+ end
185
+
186
+ def self.screen_path(tmp_root:, session_id:, message_id:)
187
+ File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), SCREEN_FILE)
188
+ end
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
+
201
+ def self.noscreen_path(tmp_root:, session_id:, message_id:)
202
+ File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), NOSCREEN_FILE)
203
+ end
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
+
212
+ private
213
+
214
+ # Chunk 0 decides, synchronously, before anything else touches this
215
+ # message: does ITS OWN delta carry an opener anywhere (331a; used to be
216
+ # only at the very start)? No opener writes NOSCREEN so every later chunk
217
+ # can decide instantly rather than waiting out its own budget for a
218
+ # decision that will never arrive - but NOSCREEN is no longer final (D2):
219
+ # a later chunk carrying an opener still replaces it.
220
+ def handle_chunk_zero(dir, delta, _cwd, final)
221
+ split = split_at_opener(delta)
222
+ unless split
223
+ @trace_detail["decision"] = "noscreen"
224
+ write_noscreen(dir)
225
+ return nil
226
+ end
227
+
228
+ @trace_detail["decision"] = "engage"
229
+ engage(dir, 0, split, final)
230
+ end
231
+
232
+ # A later chunk (index > 0) tests its OWN delta for an opener FIRST,
233
+ # before consulting any existing decision (331a, D2): an opener engages
234
+ # the message whatever the current decision says, including when NOSCREEN
235
+ # is already on disk. Only once its own delta carries no opener does it
236
+ # fall back to the original decision-driven wait.
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.
249
+ split = split_at_opener(delta)
250
+ if split && !engaged?(dir)
251
+ @trace_detail["decision"] = "engage"
252
+ return engage(dir, index, split, final)
253
+ end
254
+
255
+ decision = wait_for_decision(dir, gate_delta: final ? nil : delta, index: index)
256
+ @trace_detail["decision"] = decision.to_s
257
+
258
+ return nil unless decision == :screen
259
+
260
+ write_chunk(dir, index, delta)
261
+ final ? finalize_final(dir, index) : ""
262
+ end
263
+
264
+ def engaged?(dir)
265
+ File.exist?(File.join(dir, SCREEN_FILE))
266
+ end
267
+
268
+ # Engages the message starting at THIS chunk (whatever its index): writes
269
+ # SCREEN carrying this chunk's index (replacing any NOSCREEN, D2/D6),
270
+ # buffers the opener onward at this chunk's own index, and returns the
271
+ # text before the opener (fence-stripped, D4) as the displayContent. When
272
+ # this chunk is also final, the prefix is prepended to whatever `finalize`
273
+ # produces (painted, or the buffered original on a fail-open) rather than
274
+ # dropped.
275
+ def engage(dir, index, split, final)
276
+ prefix, rest = split
277
+ prefix = strip_preceding_fence(prefix)
278
+ write_screen(dir, index)
279
+ remove_noscreen(dir)
280
+ write_chunk(dir, index, rest)
281
+
282
+ return prefix unless final
283
+
284
+ finalized = finalize_final(dir, index)
285
+ prefix.empty? ? finalized : "#{prefix}#{finalized}"
286
+ end
287
+
288
+ # Scans `delta` line by line for the first line that opens a screen
289
+ # (331a: an opener can fall anywhere in a chunk's own delta, not only at
290
+ # its start). Returns [prefix, rest] - the text before that line, and the
291
+ # line onward - or nil when no line in this delta engages.
292
+ def split_at_opener(delta)
293
+ lines = delta.each_line.to_a
294
+ idx = lines.index { |line| ENGAGE_RE.match?(line.sub(/\A[ \t]+/, "")) }
295
+ return nil unless idx
296
+
297
+ [lines[0...idx].join, lines[idx..].join]
298
+ end
299
+
300
+ # 331a (D4): a lone fence line immediately preceding the opener, inside
301
+ # THIS SAME chunk's own prefix, is dropped - it never reaches the screen
302
+ # (it would otherwise print directly above the painted block). A fence in
303
+ # an earlier chunk is never touched: it was already displayed, verbatim,
304
+ # by that earlier chunk's own return value.
305
+ def strip_preceding_fence(prefix)
306
+ lines = prefix.each_line.to_a
307
+ return prefix if lines.empty?
308
+ return prefix unless FENCE_OPEN_RE.match?(lines.last.strip)
309
+
310
+ lines[0...-1].join
311
+ end
312
+
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:)
325
+ decision = read_decision_now(dir)
326
+ return decision if decision
327
+
328
+ if pending?(dir)
329
+ return :noscreen if pending_stale?(dir, index)
330
+
331
+ return poll_for_decision(dir, index)
332
+ end
333
+
334
+ return :timeout if gate_delta && !maybe_screen?(gate_delta)
335
+
336
+ poll_for_decision(dir, index)
337
+ end
338
+
339
+ def poll_for_decision(dir, index)
340
+ max_polls_for_budget(index).times do
341
+ @sleeper.call(@poll_ms / 1000.0)
342
+ decision = read_decision_now(dir)
343
+ return decision if decision
344
+ end
345
+
346
+ :timeout
347
+ end
348
+
349
+ def read_decision_now(dir)
350
+ return :screen if File.exist?(File.join(dir, SCREEN_FILE))
351
+ return :noscreen if File.exist?(File.join(dir, NOSCREEN_FILE))
352
+
353
+ nil
354
+ end
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
+
374
+ # 331a (M5a): the start index crosses process boundaries through SCREEN's
375
+ # own content, never in-memory state - the final chunk is routinely a
376
+ # SEPARATE process from the one that engaged. An empty or missing file
377
+ # reads back as 0 (chunk 0 engaged, today's shape, so nothing that ever
378
+ # wrote an empty SCREEN breaks).
379
+ def read_start_index(dir)
380
+ path = File.join(dir, SCREEN_FILE)
381
+ return 0 unless File.exist?(path)
382
+
383
+ File.read(path).to_i
384
+ rescue StandardError
385
+ 0
386
+ end
387
+
388
+ # Cheap, local, no file I/O: could this chunk's own delta plausibly be
389
+ # part of an intent screen (ignoring leading whitespace)? Every chunk of
390
+ # every ordinary prose message answers no, at zero cost.
391
+ def maybe_screen?(delta)
392
+ stripped = delta.lstrip
393
+ stripped.empty? || stripped.start_with?("|") || stripped.start_with?("**")
394
+ end
395
+
396
+ # The final chunk additionally waits (same budget) for every chunk file
397
+ # from the start index onward to exist before it reassembles and splices
398
+ # (331a, D3/M5: from the start index, not from 0 - chunks before the
399
+ # engaging one were never buffered at all, so waiting for them would only
400
+ # ever burn the whole budget for files that will never appear). On
401
+ # timeout it proceeds anyway with whatever is there (matrix, lead's
402
+ # guard): never nil, never swallowed.
403
+ def finalize_final(dir, index)
404
+ start_index = read_start_index(dir)
405
+ wait_for_chunk_files(dir, start_index, index)
406
+
407
+ buffered = nil
408
+ begin
409
+ buffered = read_buffered_chunks(dir, start_index, index)
410
+ finalize(buffered, nil)
411
+ rescue StandardError
412
+ # A read failing inside the assignment above leaves `buffered` at its
413
+ # nil default (the assignment never completes), which the review pass
414
+ # caught: D8/D10 promise the buffered original on any finalize
415
+ # failure, never nil, once chunks were blanked. `||=` covers exactly
416
+ # that gap without touching the ordinary case (buffered already holds
417
+ # the real chunks read before `finalize` itself raised).
418
+ buffered ||= ""
419
+ ensure
420
+ FileUtils.rm_rf(dir)
421
+ end
422
+ end
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.
429
+ def wait_for_chunk_files(dir, start_index, index)
430
+ return if index <= start_index
431
+
432
+ needed = (start_index...index).map(&:to_s)
433
+ max_polls_for_budget(index).times do
434
+ return if needed.all? { |n| File.exist?(File.join(dir, n)) }
435
+
436
+ @sleeper.call(@poll_ms / 1000.0)
437
+ end
438
+ end
439
+
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)
449
+ return 0 unless @poll_ms.to_f.positive?
450
+
451
+ (budget_ms(index) / @poll_ms.to_f).ceil
452
+ end
453
+
454
+ # Whatever chunk files exist FROM THE START INDEX onward, in index order,
455
+ # concatenated -- gaps (a chunk that never arrived, or arrived too late)
456
+ # are skipped rather than blocking reassembly (lead's guard: never return
457
+ # nothing).
458
+ def read_buffered_chunks(dir, start_index, index)
459
+ (start_index..index).filter_map do |i|
460
+ path = File.join(dir, i.to_s)
461
+ File.exist?(path) ? File.read(path) : nil
462
+ end.join
463
+ end
464
+
465
+ # 317a (D1/B10): paint what was printed. The buffered message's screen
466
+ # region - located and bounded by ScreenPaint's own grammar - is re-laid in
467
+ # the ANSI vocabulary; prose before and after survives verbatim. A region
468
+ # the painter cannot parse returns the buffered original (A3: chunks were
469
+ # already blanked, so nil here would truncate the message to its final
470
+ # delta; nil is only for the never-engaged path in handle).
471
+ #
472
+ # markdown_safe: true (intent 316a1, D5) - Claude Code still Markdown-
473
+ # processes displayContent even inside a raw ANSI block, so the Claude
474
+ # adapter asks the harness-agnostic core to strip markdown noise. A harness
475
+ # whose display surface passes raw ANSI through untouched would ask for
476
+ # false instead.
477
+ def finalize(buffered, _decision)
478
+ lines = buffered.lines
479
+ start = lines.index { |l| ScreenPaint.classify(l) == :opener }
480
+ return buffered unless start
481
+
482
+ region_stop = ScreenPaint.region_end(lines, start)
483
+ painted = ScreenPaint.paint(lines[start...region_stop].join, color: true, markdown_safe: true)
484
+ trace_region(buffered, lines, start, region_stop, painted)
485
+ return buffered unless painted
486
+
487
+ # 331a (D4): a lone CLOSING fence immediately after the painted region is
488
+ # dropped - never the painting boundary itself (region_stop, used above,
489
+ # is untouched), only where the suffix starts. An unrelated fenced code
490
+ # block further down, with prose or a blank line between it and the
491
+ # region, is never adjacent, so it always survives verbatim (M8b); a
492
+ # closing fence never carries an info string, so an adjacent OPENING
493
+ # fence of a real code block (which usually does) is never mistaken for
494
+ # it either.
495
+ suffix_start = region_stop
496
+ suffix_start += 1 if suffix_start < lines.length && FENCE_CLOSE_RE.match?(lines[suffix_start].strip)
497
+
498
+ suffix = lines[suffix_start..].to_a.join.sub(/\A\n+/, "")
499
+ out = +"#{lines[0...start].join}#{painted.rstrip}\n"
500
+ out << "\n#{suffix}" unless suffix.empty?
501
+ out
502
+ end
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
+
524
+ def write_chunk(dir, index, delta)
525
+ atomic_write(File.join(dir, index.to_s), delta)
526
+ end
527
+
528
+ # SCREEN's content is the engaging chunk's own index, as a decimal integer
529
+ # (331a, D6) - read back by `read_start_index` so the final chunk (a
530
+ # separate process, in production) knows where to start waiting and
531
+ # splicing, and so `finalize_final` never touches chunks that were passed
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.
535
+ def write_screen(dir, index)
536
+ atomic_write(File.join(dir, SCREEN_FILE), "#{index}\n")
537
+ remove_pending(dir)
538
+ end
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.
542
+ def write_noscreen(dir)
543
+ atomic_write(File.join(dir, NOSCREEN_FILE), "")
544
+ remove_pending(dir)
545
+ end
546
+
547
+ # 331a (D2/D6): NOSCREEN is no longer a final answer - a later chunk that
548
+ # engages replaces it with SCREEN and, for safety, removes NOSCREEN so a
549
+ # stale marker can never be read back once the real decision exists.
550
+ def remove_noscreen(dir)
551
+ FileUtils.rm_f(File.join(dir, NOSCREEN_FILE))
552
+ end
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
+
563
+ def atomic_write(path, content)
564
+ FileUtils.mkdir_p(File.dirname(path))
565
+ tmp_path = "#{path}.tmp#{Process.pid}-#{rand(1_000_000)}"
566
+ File.write(tmp_path, content)
567
+ File.rename(tmp_path, path)
568
+ end
569
+
570
+
571
+
572
+ def prune_old_buffers
573
+ root = File.join(@tmp_root, BUFFER_DIR_NAME)
574
+ return unless File.directory?(root)
575
+
576
+ Dir.children(root).each do |session_dir|
577
+ full = File.join(root, session_dir)
578
+ next unless File.directory?(full)
579
+
580
+ age = @now.to_i - File.mtime(full).to_i
581
+ FileUtils.rm_rf(full) if age > BUFFER_MAX_AGE_SECONDS
582
+ end
583
+ rescue StandardError
584
+ nil
585
+ end
586
+ end