@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.
- package/agents/plastic-enforcer.md +3 -1
- package/agents/plastic-executor.md +9 -3
- package/bin/test +24 -4
- package/hooks/call-budget +4 -0
- package/hooks/hooks.json +24 -0
- package/hooks/message-display +134 -0
- package/hooks/statusline +4 -27
- package/package.json +2 -2
- package/scripts/agent-report +8 -2
- package/scripts/dashboard.rb +297 -18
- package/scripts/doctor.rb +652 -5
- package/scripts/end-intent +219 -0
- package/scripts/hook-call-budget +222 -0
- package/scripts/hook-capture +25 -113
- package/scripts/hook-message-display +81 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-session-start +45 -7
- package/scripts/index-projection +74 -0
- package/scripts/intent-screen +77 -0
- package/scripts/lib/action_graph_shim.rb +277 -0
- package/scripts/lib/arm.rb +26 -1
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/compact_instructions.rb +5 -5
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/day_summary.rb +7 -2
- package/scripts/lib/doctor_core.rb +102 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +137 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/hook_registry.rb +28 -2
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +141 -6
- package/scripts/lib/intent_screen.rb +309 -0
- package/scripts/lib/intent_screen_ansi.rb +262 -0
- package/scripts/lib/message_display.rb +586 -0
- package/scripts/lib/meter_watch.rb +179 -0
- package/scripts/lib/node_file.rb +214 -0
- package/scripts/lib/node_ids.rb +99 -0
- package/scripts/lib/node_ledger.rb +377 -0
- package/scripts/lib/node_packet.rb +908 -0
- package/scripts/lib/node_return.rb +199 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/outcome_report.rb +440 -0
- package/scripts/lib/packet_wrapper.rb +132 -0
- package/scripts/lib/ready_set.rb +462 -0
- package/scripts/lib/release_guard.rb +16 -0
- package/scripts/lib/report_screen.rb +1967 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +155 -5
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/roadmap_savepoint.rb +62 -12
- package/scripts/lib/runner_absorb.rb +620 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +506 -0
- package/scripts/lib/runner_policy.rb +173 -0
- package/scripts/lib/runner_proposals.rb +275 -0
- package/scripts/lib/runner_rewind.rb +201 -0
- package/scripts/lib/runner_sweep.rb +231 -0
- package/scripts/lib/savepoint.rb +149 -12
- package/scripts/lib/screen_paint.rb +555 -0
- package/scripts/lib/screens/dashboard.rb +20 -0
- package/scripts/lib/screens/plan.rb +18 -0
- package/scripts/lib/screens/roadmap.rb +15 -0
- package/scripts/lib/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +128 -0
- package/scripts/lib/session_usage.rb +190 -0
- package/scripts/lib/verify_intent.rb +33 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/meter-watch +57 -0
- package/scripts/node-packet +92 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/plastic-lock +8 -1
- package/scripts/read-config +3 -3
- package/scripts/ready-set +126 -0
- package/scripts/release-check +118 -0
- package/scripts/report-screen +281 -0
- package/scripts/roadmap-graph +119 -0
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/runner +397 -0
- package/scripts/savepoint-note +69 -0
- package/scripts/session-usage +56 -0
- package/scripts/spawn-preamble +9 -2
- package/scripts/validate-work-graph +39 -0
- package/scripts/verify-intent +2 -1
- package/skills/auto/SKILL.md +41 -34
- package/skills/auto/references/human-report-contract.md +136 -54
- package/skills/conventions/references/locks-and-worktrees.md +12 -0
- package/skills/dashboard/SKILL.md +13 -2
- package/skills/dashboard/templates/dashboard-global.md +1 -1
- package/skills/dashboard/templates/dashboard-project.md +2 -2
- package/skills/doctor/SKILL.md +10 -4
- package/skills/intent-continuing/SKILL.md +51 -41
- package/skills/intent-continuing/references/board-fill.md +9 -0
- package/skills/intent-continuing/references/boarding-matrix.md +6 -5
- package/skills/intent-continuing/references/context-management.md +1 -1
- package/skills/intent-ending/SKILL.md +36 -16
- package/skills/intent-executing/SKILL.md +21 -5
- package/skills/intent-executing/implementer-prompt.md +6 -1
- package/skills/intent-speccing/SKILL.md +7 -4
- package/skills/releasing/SKILL.md +39 -0
- package/skills/releasing/references/promotion-and-tagging.md +10 -6
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/roadmap/SKILL.md +26 -0
- package/skills/roadmap/references/file-format.md +10 -0
- package/templates/config.yml +3 -3
- package/templates/dashboard-screen.md +22 -0
- package/templates/display-fixture.md +21 -0
- package/templates/graph.md +16 -0
- package/templates/intent-screen.md +17 -0
- package/templates/node-decision.md +11 -0
- package/templates/node-research.md +11 -0
- package/templates/node-verify.md +13 -0
- package/templates/node-work.md +22 -0
- package/templates/outcome.md +19 -1
- package/templates/report-plan.md +15 -0
- package/templates/report-roadmap-delivered.md +10 -0
- package/templates/report-roadmap-plan.md +9 -0
- package/templates/report-roadmap-state.md +9 -0
- package/templates/report-state.md +11 -0
- 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
|