@zalom/plastic 2.0.0-alpha.13 → 2.0.0-alpha.15
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/hooks/message-display +55 -2
- package/package.json +1 -1
- package/scripts/dashboard.rb +238 -8
- package/scripts/doctor.rb +291 -4
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/doctor_core.rb +97 -2
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/installer_core.rb +23 -3
- package/scripts/lib/message_display.rb +267 -47
- package/scripts/lib/report_screen.rb +837 -18
- package/scripts/lib/roadmap_queue.rb +19 -2
- package/scripts/lib/roadmap_savepoint.rb +36 -7
- package/scripts/lib/savepoint.rb +12 -0
- package/scripts/lib/screen_paint.rb +240 -11
- 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/verify_intent.rb +33 -0
- package/scripts/report-screen +41 -7
- package/scripts/savepoint-note +11 -9
- package/skills/auto/SKILL.md +9 -9
- package/skills/auto/references/human-report-contract.md +79 -8
- 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 +19 -21
- package/skills/intent-continuing/references/board-fill.md +9 -0
- package/skills/intent-ending/SKILL.md +6 -4
- package/skills/intent-executing/SKILL.md +2 -0
- package/skills/intent-speccing/SKILL.md +7 -4
- package/skills/roadmap/SKILL.md +9 -0
- package/skills/roadmap/references/file-format.md +10 -0
- package/templates/dashboard-screen.md +22 -0
- package/templates/display-fixture.md +21 -0
- package/templates/intent-screen.md +1 -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 +1 -1
|
@@ -46,17 +46,72 @@ require_relative "screen_paint"
|
|
|
46
46
|
# buffered or blanked. D10 (any failure while finalizing returns the
|
|
47
47
|
# buffered original, never nil, never "") and D12 (color: false never
|
|
48
48
|
# buffers or blanks anything) are unchanged.
|
|
49
|
+
#
|
|
50
|
+
# Intent 331a: engagement is late-capable. A chunk carrying a screen opener
|
|
51
|
+
# engages the message from that chunk on, whatever its own index - not only
|
|
52
|
+
# chunk 0. Chunks before it pass through untouched (they already reached the
|
|
53
|
+
# terminal live, via the ordinary passthrough path). The engaging chunk
|
|
54
|
+
# returns the text before the opener as its displayContent and buffers the
|
|
55
|
+
# opener onward at its OWN index; SCREEN now carries that index (a decimal
|
|
56
|
+
# integer, not an empty marker) so the final chunk - a separate process in
|
|
57
|
+
# production - knows where to start waiting and splicing (D3/D6), and so
|
|
58
|
+
# NOSCREEN, no longer a final answer (D2), can be replaced once a later
|
|
59
|
+
# chunk engages. A fence line immediately wrapping the opener is dropped
|
|
60
|
+
# (D4): a lone fence in the engaging chunk's own prefix, and a lone closing
|
|
61
|
+
# fence right after the painted region in `finalize`. Neither ever reaches
|
|
62
|
+
# back into an earlier, already-displayed chunk.
|
|
63
|
+
#
|
|
64
|
+
# Intent 331a1: the decision marker (D1-D3). 331a's own comment above already
|
|
65
|
+
# names the concurrency; what it did not close is chunk 0's own boot time.
|
|
66
|
+
# Chunk 0's Ruby process takes on the order of 150 ms to boot before it ever
|
|
67
|
+
# writes SCREEN or NOSCREEN - long enough, under a fast real stream, for a
|
|
68
|
+
# dozen or more later chunks to be judged with nothing on disk at all, so
|
|
69
|
+
# every one of them fell back to the cheap shape test and, being ordinary
|
|
70
|
+
# non-table prose, passed straight through plain. The bash launcher (hooks/
|
|
71
|
+
# message-display) now stakes a PENDING file with builtins the instant
|
|
72
|
+
# chunk 0 is handed off, before Ruby ever starts, so a later chunk finds the
|
|
73
|
+
# message directory within microseconds instead of after Ruby's own boot.
|
|
74
|
+
# While PENDING exists, a later chunk polls for the real decision WHATEVER
|
|
75
|
+
# ITS OWN SHAPE looks like - `maybe_screen?` is not consulted at all, because
|
|
76
|
+
# a decision is certainly coming, and the cheap shape gate exists only for
|
|
77
|
+
# the "nothing at all exists yet, is a wait even worth paying for" case,
|
|
78
|
+
# which no longer applies once something IS on disk. A PENDING whose mtime
|
|
79
|
+
# is already older than THIS chunk's own poll budget reads as NOSCREEN (D2,
|
|
80
|
+
# fail open): chunk 0 must have died or hung, and waiting out a whole budget
|
|
81
|
+
# for a decision that is provably not coming would only delay every chunk
|
|
82
|
+
# behind it. That staleness check runs ONCE, before any polling, since a
|
|
83
|
+
# file's mtime never changes while this process looks at it. The poll
|
|
84
|
+
# budget itself scales with the chunk's own index (`budget_ms`, D3): base
|
|
85
|
+
# wait_ms plus index_wait_ms per index, capped at max_wait_ms, so the final
|
|
86
|
+
# chunk of a long streamed message (335 chunks, in the live capture that
|
|
87
|
+
# reproduced this) is allowed to wait for a decision that is certainly on
|
|
88
|
+
# its way, while chunk 1 of an ordinary short message still fails open
|
|
89
|
+
# quickly. Chunk 0 removes PENDING the moment it writes SCREEN or NOSCREEN
|
|
90
|
+
# (`write_screen`/`write_noscreen`), on both paths, so "a decision already
|
|
91
|
+
# exists" and "PENDING is still there" are never both true for long.
|
|
49
92
|
class MessageDisplay
|
|
50
93
|
# 317a (A4): engagement is grammar, not identity - any screen-family
|
|
51
94
|
# opener engages, with NO intent-id resolution (the roster and delay
|
|
52
|
-
# screens have none to resolve). ScreenPaint owns the full grammar.
|
|
95
|
+
# screens have none to resolve). ScreenPaint owns the full grammar. Used
|
|
96
|
+
# per LINE (331a), not only against the start of a whole delta: a chunk's
|
|
97
|
+
# own text is scanned line by line for the first line that opens a screen,
|
|
98
|
+
# wherever it falls.
|
|
53
99
|
ENGAGE_RE = /\A(?:##? )?[▶✔] /.freeze
|
|
100
|
+
# 331a (D4): a lone fence line, opening (optional info string) or closing
|
|
101
|
+
# (never one), by itself on its own line.
|
|
102
|
+
FENCE_OPEN_RE = /\A```[^\n]*\z/.freeze
|
|
103
|
+
FENCE_CLOSE_RE = /\A```\z/.freeze
|
|
54
104
|
BUFFER_DIR_NAME = "plastic-message-display"
|
|
55
105
|
BUFFER_MAX_AGE_SECONDS = 3600
|
|
56
106
|
SCREEN_FILE = "SCREEN"
|
|
57
107
|
NOSCREEN_FILE = "NOSCREEN"
|
|
108
|
+
# 331a1 (D1): staked by the bash launcher, with builtins, the instant
|
|
109
|
+
# chunk 0 is handed off - before Ruby ever boots. Replaced by SCREEN or
|
|
110
|
+
# NOSCREEN (D2), never read by this class as a decision in its own right.
|
|
111
|
+
PENDING_FILE = "PENDING"
|
|
58
112
|
|
|
59
113
|
def initialize(tmp_root:, plastic_home:, color:, now:, wait_ms: 300, poll_ms: 20,
|
|
114
|
+
index_wait_ms: 20, max_wait_ms: 2000,
|
|
60
115
|
sleeper: ->(seconds) { sleep(seconds) })
|
|
61
116
|
@tmp_root = tmp_root
|
|
62
117
|
@plastic_home = plastic_home
|
|
@@ -64,6 +119,8 @@ class MessageDisplay
|
|
|
64
119
|
@now = now
|
|
65
120
|
@wait_ms = wait_ms
|
|
66
121
|
@poll_ms = poll_ms
|
|
122
|
+
@index_wait_ms = index_wait_ms
|
|
123
|
+
@max_wait_ms = max_wait_ms
|
|
67
124
|
@sleeper = sleeper
|
|
68
125
|
end
|
|
69
126
|
|
|
@@ -111,31 +168,41 @@ class MessageDisplay
|
|
|
111
168
|
File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), NOSCREEN_FILE)
|
|
112
169
|
end
|
|
113
170
|
|
|
171
|
+
# 331a1 (matrix L1): the bash launcher (hooks/message-display) and this
|
|
172
|
+
# class must agree, byte for byte, on where PENDING lives - the same
|
|
173
|
+
# contract `buffer_path` already carries for SCREEN/NOSCREEN (matrix 40).
|
|
174
|
+
def self.pending_path(tmp_root:, session_id:, message_id:)
|
|
175
|
+
File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), PENDING_FILE)
|
|
176
|
+
end
|
|
177
|
+
|
|
114
178
|
private
|
|
115
179
|
|
|
116
180
|
# Chunk 0 decides, synchronously, before anything else touches this
|
|
117
|
-
# message:
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
181
|
+
# message: does ITS OWN delta carry an opener anywhere (331a; used to be
|
|
182
|
+
# only at the very start)? No opener writes NOSCREEN so every later chunk
|
|
183
|
+
# can decide instantly rather than waiting out its own budget for a
|
|
184
|
+
# decision that will never arrive - but NOSCREEN is no longer final (D2):
|
|
185
|
+
# a later chunk carrying an opener still replaces it.
|
|
121
186
|
def handle_chunk_zero(dir, delta, _cwd, final)
|
|
122
|
-
|
|
123
|
-
unless
|
|
187
|
+
split = split_at_opener(delta)
|
|
188
|
+
unless split
|
|
124
189
|
write_noscreen(dir)
|
|
125
190
|
return nil
|
|
126
191
|
end
|
|
127
192
|
|
|
128
|
-
|
|
129
|
-
write_chunk(dir, 0, delta)
|
|
130
|
-
final ? finalize_final(dir, 0) : ""
|
|
193
|
+
engage(dir, 0, split, final)
|
|
131
194
|
end
|
|
132
195
|
|
|
133
|
-
# A later chunk (index > 0)
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
#
|
|
196
|
+
# A later chunk (index > 0) tests its OWN delta for an opener FIRST,
|
|
197
|
+
# before consulting any existing decision (331a, D2): an opener engages
|
|
198
|
+
# the message whatever the current decision says, including when NOSCREEN
|
|
199
|
+
# is already on disk. Only once its own delta carries no opener does it
|
|
200
|
+
# fall back to the original decision-driven wait.
|
|
137
201
|
def handle_later_chunk(dir, index, delta, final)
|
|
138
|
-
|
|
202
|
+
split = split_at_opener(delta)
|
|
203
|
+
return engage(dir, index, split, final) if split
|
|
204
|
+
|
|
205
|
+
decision = wait_for_decision(dir, gate_delta: final ? nil : delta, index: index)
|
|
139
206
|
|
|
140
207
|
return nil unless decision == :screen
|
|
141
208
|
|
|
@@ -143,17 +210,79 @@ class MessageDisplay
|
|
|
143
210
|
final ? finalize_final(dir, index) : ""
|
|
144
211
|
end
|
|
145
212
|
|
|
146
|
-
#
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
#
|
|
150
|
-
|
|
213
|
+
# Engages the message starting at THIS chunk (whatever its index): writes
|
|
214
|
+
# SCREEN carrying this chunk's index (replacing any NOSCREEN, D2/D6),
|
|
215
|
+
# buffers the opener onward at this chunk's own index, and returns the
|
|
216
|
+
# text before the opener (fence-stripped, D4) as the displayContent. When
|
|
217
|
+
# this chunk is also final, the prefix is prepended to whatever `finalize`
|
|
218
|
+
# produces (painted, or the buffered original on a fail-open) rather than
|
|
219
|
+
# dropped.
|
|
220
|
+
def engage(dir, index, split, final)
|
|
221
|
+
prefix, rest = split
|
|
222
|
+
prefix = strip_preceding_fence(prefix)
|
|
223
|
+
write_screen(dir, index)
|
|
224
|
+
remove_noscreen(dir)
|
|
225
|
+
write_chunk(dir, index, rest)
|
|
226
|
+
|
|
227
|
+
return prefix unless final
|
|
228
|
+
|
|
229
|
+
finalized = finalize_final(dir, index)
|
|
230
|
+
prefix.empty? ? finalized : "#{prefix}#{finalized}"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Scans `delta` line by line for the first line that opens a screen
|
|
234
|
+
# (331a: an opener can fall anywhere in a chunk's own delta, not only at
|
|
235
|
+
# its start). Returns [prefix, rest] - the text before that line, and the
|
|
236
|
+
# line onward - or nil when no line in this delta engages.
|
|
237
|
+
def split_at_opener(delta)
|
|
238
|
+
lines = delta.each_line.to_a
|
|
239
|
+
idx = lines.index { |line| ENGAGE_RE.match?(line.sub(/\A[ \t]+/, "")) }
|
|
240
|
+
return nil unless idx
|
|
241
|
+
|
|
242
|
+
[lines[0...idx].join, lines[idx..].join]
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# 331a (D4): a lone fence line immediately preceding the opener, inside
|
|
246
|
+
# THIS SAME chunk's own prefix, is dropped - it never reaches the screen
|
|
247
|
+
# (it would otherwise print directly above the painted block). A fence in
|
|
248
|
+
# an earlier chunk is never touched: it was already displayed, verbatim,
|
|
249
|
+
# by that earlier chunk's own return value.
|
|
250
|
+
def strip_preceding_fence(prefix)
|
|
251
|
+
lines = prefix.each_line.to_a
|
|
252
|
+
return prefix if lines.empty?
|
|
253
|
+
return prefix unless FENCE_OPEN_RE.match?(lines.last.strip)
|
|
254
|
+
|
|
255
|
+
lines[0...-1].join
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Checks for an existing decision first (free). Then, 331a1 (D1/D2), the
|
|
259
|
+
# whole fix: when PENDING exists, a decision is certainly coming, so this
|
|
260
|
+
# chunk polls for it WHATEVER ITS OWN SHAPE looks like - `maybe_screen?`
|
|
261
|
+
# is never even consulted on this branch - unless PENDING is already
|
|
262
|
+
# stale (older than this chunk's own budget), which reads as NOSCREEN at
|
|
263
|
+
# once, fail open, without ever polling. Only when there is no PENDING
|
|
264
|
+
# AT ALL (chunk 0 has not even been handed off to the bash launcher yet,
|
|
265
|
+
# or this replay never wrote one) does today's original behavior apply:
|
|
266
|
+
# the cheap shape test gates whether a bounded poll is worth paying for.
|
|
267
|
+
# `gate_delta: nil` (the final chunk) skips that shape test entirely and
|
|
268
|
+
# always polls.
|
|
269
|
+
def wait_for_decision(dir, gate_delta:, index:)
|
|
151
270
|
decision = read_decision_now(dir)
|
|
152
271
|
return decision if decision
|
|
153
272
|
|
|
273
|
+
if pending?(dir)
|
|
274
|
+
return :noscreen if pending_stale?(dir, index)
|
|
275
|
+
|
|
276
|
+
return poll_for_decision(dir, index)
|
|
277
|
+
end
|
|
278
|
+
|
|
154
279
|
return :timeout if gate_delta && !maybe_screen?(gate_delta)
|
|
155
280
|
|
|
156
|
-
|
|
281
|
+
poll_for_decision(dir, index)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def poll_for_decision(dir, index)
|
|
285
|
+
max_polls_for_budget(index).times do
|
|
157
286
|
@sleeper.call(@poll_ms / 1000.0)
|
|
158
287
|
decision = read_decision_now(dir)
|
|
159
288
|
return decision if decision
|
|
@@ -169,6 +298,38 @@ class MessageDisplay
|
|
|
169
298
|
nil
|
|
170
299
|
end
|
|
171
300
|
|
|
301
|
+
def pending?(dir)
|
|
302
|
+
File.exist?(File.join(dir, PENDING_FILE))
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Checked ONCE, before any polling - a file's mtime never changes while
|
|
306
|
+
# this process is looking at it, so re-checking inside the poll loop
|
|
307
|
+
# would only ever repeat the same answer. Any error reading the mtime
|
|
308
|
+
# (a race where PENDING vanished between `pending?` and here, most
|
|
309
|
+
# likely because the real decision just landed) is NOT staleness: it
|
|
310
|
+
# falls through to the ordinary poll, which will pick up that decision
|
|
311
|
+
# on its very next read.
|
|
312
|
+
def pending_stale?(dir, index)
|
|
313
|
+
age_ms = (@now.to_f - File.mtime(File.join(dir, PENDING_FILE)).to_f) * 1000
|
|
314
|
+
age_ms > budget_ms(index)
|
|
315
|
+
rescue StandardError
|
|
316
|
+
false
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# 331a (M5a): the start index crosses process boundaries through SCREEN's
|
|
320
|
+
# own content, never in-memory state - the final chunk is routinely a
|
|
321
|
+
# SEPARATE process from the one that engaged. An empty or missing file
|
|
322
|
+
# reads back as 0 (chunk 0 engaged, today's shape, so nothing that ever
|
|
323
|
+
# wrote an empty SCREEN breaks).
|
|
324
|
+
def read_start_index(dir)
|
|
325
|
+
path = File.join(dir, SCREEN_FILE)
|
|
326
|
+
return 0 unless File.exist?(path)
|
|
327
|
+
|
|
328
|
+
File.read(path).to_i
|
|
329
|
+
rescue StandardError
|
|
330
|
+
0
|
|
331
|
+
end
|
|
332
|
+
|
|
172
333
|
# Cheap, local, no file I/O: could this chunk's own delta plausibly be
|
|
173
334
|
# part of an intent screen (ignoring leading whitespace)? Every chunk of
|
|
174
335
|
# every ordinary prose message answers no, at zero cost.
|
|
@@ -177,46 +338,70 @@ class MessageDisplay
|
|
|
177
338
|
stripped.empty? || stripped.start_with?("|") || stripped.start_with?("**")
|
|
178
339
|
end
|
|
179
340
|
|
|
180
|
-
# The final chunk additionally waits (same budget) for every
|
|
181
|
-
#
|
|
182
|
-
#
|
|
183
|
-
#
|
|
341
|
+
# The final chunk additionally waits (same budget) for every chunk file
|
|
342
|
+
# from the start index onward to exist before it reassembles and splices
|
|
343
|
+
# (331a, D3/M5: from the start index, not from 0 - chunks before the
|
|
344
|
+
# engaging one were never buffered at all, so waiting for them would only
|
|
345
|
+
# ever burn the whole budget for files that will never appear). On
|
|
346
|
+
# timeout it proceeds anyway with whatever is there (matrix, lead's
|
|
347
|
+
# guard): never nil, never swallowed.
|
|
184
348
|
def finalize_final(dir, index)
|
|
185
|
-
|
|
349
|
+
start_index = read_start_index(dir)
|
|
350
|
+
wait_for_chunk_files(dir, start_index, index)
|
|
186
351
|
|
|
187
352
|
buffered = nil
|
|
188
353
|
begin
|
|
189
|
-
buffered = read_buffered_chunks(dir, index)
|
|
354
|
+
buffered = read_buffered_chunks(dir, start_index, index)
|
|
190
355
|
finalize(buffered, nil)
|
|
191
356
|
rescue StandardError
|
|
192
|
-
buffered
|
|
357
|
+
# A read failing inside the assignment above leaves `buffered` at its
|
|
358
|
+
# nil default (the assignment never completes), which the review pass
|
|
359
|
+
# caught: D8/D10 promise the buffered original on any finalize
|
|
360
|
+
# failure, never nil, once chunks were blanked. `||=` covers exactly
|
|
361
|
+
# that gap without touching the ordinary case (buffered already holds
|
|
362
|
+
# the real chunks read before `finalize` itself raised).
|
|
363
|
+
buffered ||= ""
|
|
193
364
|
ensure
|
|
194
365
|
FileUtils.rm_rf(dir)
|
|
195
366
|
end
|
|
196
367
|
end
|
|
197
368
|
|
|
198
|
-
|
|
199
|
-
|
|
369
|
+
# 331a1 (D3): index-scaled too, same as the decision poll - the final
|
|
370
|
+
# chunk of a long streamed message (335 chunks, in the live capture that
|
|
371
|
+
# reproduced this) must be allowed to wait long enough for the earlier
|
|
372
|
+
# chunk files to land, not just the base wait_ms an ordinary short
|
|
373
|
+
# message gets by with.
|
|
374
|
+
def wait_for_chunk_files(dir, start_index, index)
|
|
375
|
+
return if index <= start_index
|
|
200
376
|
|
|
201
|
-
needed = (
|
|
202
|
-
max_polls_for_budget.times do
|
|
377
|
+
needed = (start_index...index).map(&:to_s)
|
|
378
|
+
max_polls_for_budget(index).times do
|
|
203
379
|
return if needed.all? { |n| File.exist?(File.join(dir, n)) }
|
|
204
380
|
|
|
205
381
|
@sleeper.call(@poll_ms / 1000.0)
|
|
206
382
|
end
|
|
207
383
|
end
|
|
208
384
|
|
|
209
|
-
|
|
385
|
+
# 331a1 (D3): base wait_ms plus index_wait_ms per chunk index, capped at
|
|
386
|
+
# max_wait_ms - a chunk deep into a long streamed message is certainly
|
|
387
|
+
# going to see its decision eventually, so it is allowed to wait longer
|
|
388
|
+
# than chunk 1 of an ordinary short message.
|
|
389
|
+
def budget_ms(index)
|
|
390
|
+
[@wait_ms + @index_wait_ms * index.to_i, @max_wait_ms].min
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def max_polls_for_budget(index)
|
|
210
394
|
return 0 unless @poll_ms.to_f.positive?
|
|
211
395
|
|
|
212
|
-
(
|
|
396
|
+
(budget_ms(index) / @poll_ms.to_f).ceil
|
|
213
397
|
end
|
|
214
398
|
|
|
215
|
-
# Whatever chunk files exist, in index order,
|
|
216
|
-
# chunk that never arrived, or arrived too late)
|
|
217
|
-
# blocking reassembly (lead's guard: never return
|
|
218
|
-
|
|
219
|
-
|
|
399
|
+
# Whatever chunk files exist FROM THE START INDEX onward, in index order,
|
|
400
|
+
# concatenated -- gaps (a chunk that never arrived, or arrived too late)
|
|
401
|
+
# are skipped rather than blocking reassembly (lead's guard: never return
|
|
402
|
+
# nothing).
|
|
403
|
+
def read_buffered_chunks(dir, start_index, index)
|
|
404
|
+
(start_index..index).filter_map do |i|
|
|
220
405
|
path = File.join(dir, i.to_s)
|
|
221
406
|
File.exist?(path) ? File.read(path) : nil
|
|
222
407
|
end.join
|
|
@@ -239,11 +424,22 @@ class MessageDisplay
|
|
|
239
424
|
start = lines.index { |l| ScreenPaint.classify(l) == :opener }
|
|
240
425
|
return buffered unless start
|
|
241
426
|
|
|
242
|
-
|
|
243
|
-
painted = ScreenPaint.paint(lines[start...
|
|
427
|
+
region_stop = ScreenPaint.region_end(lines, start)
|
|
428
|
+
painted = ScreenPaint.paint(lines[start...region_stop].join, color: true, markdown_safe: true)
|
|
244
429
|
return buffered unless painted
|
|
245
430
|
|
|
246
|
-
|
|
431
|
+
# 331a (D4): a lone CLOSING fence immediately after the painted region is
|
|
432
|
+
# dropped - never the painting boundary itself (region_stop, used above,
|
|
433
|
+
# is untouched), only where the suffix starts. An unrelated fenced code
|
|
434
|
+
# block further down, with prose or a blank line between it and the
|
|
435
|
+
# region, is never adjacent, so it always survives verbatim (M8b); a
|
|
436
|
+
# closing fence never carries an info string, so an adjacent OPENING
|
|
437
|
+
# fence of a real code block (which usually does) is never mistaken for
|
|
438
|
+
# it either.
|
|
439
|
+
suffix_start = region_stop
|
|
440
|
+
suffix_start += 1 if suffix_start < lines.length && FENCE_CLOSE_RE.match?(lines[suffix_start].strip)
|
|
441
|
+
|
|
442
|
+
suffix = lines[suffix_start..].to_a.join.sub(/\A\n+/, "")
|
|
247
443
|
out = +"#{lines[0...start].join}#{painted.rstrip}\n"
|
|
248
444
|
out << "\n#{suffix}" unless suffix.empty?
|
|
249
445
|
out
|
|
@@ -253,15 +449,39 @@ class MessageDisplay
|
|
|
253
449
|
atomic_write(File.join(dir, index.to_s), delta)
|
|
254
450
|
end
|
|
255
451
|
|
|
256
|
-
#
|
|
257
|
-
#
|
|
258
|
-
#
|
|
259
|
-
|
|
260
|
-
|
|
452
|
+
# SCREEN's content is the engaging chunk's own index, as a decimal integer
|
|
453
|
+
# (331a, D6) - read back by `read_start_index` so the final chunk (a
|
|
454
|
+
# separate process, in production) knows where to start waiting and
|
|
455
|
+
# splicing, and so `finalize_final` never touches chunks that were passed
|
|
456
|
+
# through untouched before engagement.
|
|
457
|
+
# 331a1 (D2): the decision REPLACES PENDING, on this path too, whichever
|
|
458
|
+
# chunk turns out to be the one that engages.
|
|
459
|
+
def write_screen(dir, index)
|
|
460
|
+
atomic_write(File.join(dir, SCREEN_FILE), "#{index}\n")
|
|
461
|
+
remove_pending(dir)
|
|
261
462
|
end
|
|
262
463
|
|
|
464
|
+
# 331a1 (D2): same replacement on the NOSCREEN path, so a later chunk
|
|
465
|
+
# never finds both PENDING and NOSCREEN and has to choose between them.
|
|
263
466
|
def write_noscreen(dir)
|
|
264
467
|
atomic_write(File.join(dir, NOSCREEN_FILE), "")
|
|
468
|
+
remove_pending(dir)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# 331a (D2/D6): NOSCREEN is no longer a final answer - a later chunk that
|
|
472
|
+
# engages replaces it with SCREEN and, for safety, removes NOSCREEN so a
|
|
473
|
+
# stale marker can never be read back once the real decision exists.
|
|
474
|
+
def remove_noscreen(dir)
|
|
475
|
+
FileUtils.rm_f(File.join(dir, NOSCREEN_FILE))
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# 331a1 (D2): removal must never raise - a decision was already written
|
|
479
|
+
# successfully by the time this runs, and a stray filesystem error here
|
|
480
|
+
# must never turn a successful decision into an unhandled exception.
|
|
481
|
+
def remove_pending(dir)
|
|
482
|
+
FileUtils.rm_f(File.join(dir, PENDING_FILE))
|
|
483
|
+
rescue StandardError
|
|
484
|
+
nil
|
|
265
485
|
end
|
|
266
486
|
|
|
267
487
|
def atomic_write(path, content)
|