@zalom/plastic 2.0.0-alpha.1 → 2.0.0-alpha.11
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/bin/lib/context_budget.rb +453 -0
- package/bin/plastic-bench +78 -0
- package/hooks/hooks.json +12 -0
- package/hooks/message-display +81 -0
- package/hooks/savepoint +5 -5
- package/package.json +1 -1
- package/scripts/agent-report +8 -2
- package/scripts/append-ledger +16 -3
- package/scripts/dashboard.rb +39 -10
- package/scripts/day-summary +53 -0
- package/scripts/doctor.rb +163 -0
- package/scripts/end-intent +93 -0
- package/scripts/hook-capture +21 -8
- package/scripts/hook-close +3 -1
- package/scripts/hook-message-display +74 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-savepoint +45 -0
- package/scripts/hook-session-start +34 -1
- package/scripts/intent-screen +77 -0
- package/scripts/lib/arm.rb +26 -1
- package/scripts/lib/compact_instructions.rb +56 -0
- package/scripts/lib/day_summary.rb +211 -0
- package/scripts/lib/doctor_core.rb +52 -3
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/handoff.rb +184 -0
- package/scripts/lib/hook_registry.rb +14 -0
- package/scripts/lib/installer_core.rb +117 -11
- package/scripts/lib/intent_screen.rb +309 -0
- package/scripts/lib/intent_screen_ansi.rb +262 -0
- package/scripts/lib/message_display.rb +290 -0
- package/scripts/lib/report_screen.rb +671 -0
- package/scripts/lib/savepoint.rb +14 -0
- package/scripts/lib/screen_paint.rb +276 -0
- package/scripts/lib/session_close.rb +22 -2
- package/scripts/lib/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +124 -0
- package/scripts/plastic-lock +8 -1
- package/scripts/read-config +3 -0
- package/scripts/report-screen +157 -0
- package/scripts/rollback.rb +6 -0
- package/scripts/savepoint-note +67 -0
- package/scripts/spawn-preamble +9 -2
- package/scripts/write-handoff +60 -0
- package/skills/auto/SKILL.md +15 -8
- package/skills/auto/references/human-report-contract.md +59 -53
- package/skills/conventions/references/locks-and-worktrees.md +12 -0
- package/skills/intent-continuing/SKILL.md +31 -22
- package/skills/intent-continuing/references/boarding-matrix.md +5 -5
- package/skills/intent-continuing/references/context-management.md +1 -1
- package/skills/intent-ending/SKILL.md +8 -2
- package/skills/intent-executing/SKILL.md +6 -0
- package/templates/config.yml +5 -0
- package/templates/intent-screen.md +17 -0
- package/templates/outcome.md +14 -1
- package/templates/report-state.md +11 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "screen_paint"
|
|
6
|
+
|
|
7
|
+
# MessageDisplay (intent 316a, O4/O5, round 3 concurrency fix) - the Claude
|
|
8
|
+
# Code MessageDisplay hook handler. One process per streamed chunk of every
|
|
9
|
+
# assistant message (D11), so it must be cheap and decide fast. Pure: every
|
|
10
|
+
# dependency (tmp_root, plastic_home, color, now, wait_ms, poll_ms, sleeper)
|
|
11
|
+
# is a constructor argument, never an ENV read, a Dir.pwd/Dir.home read, or
|
|
12
|
+
# the real Time.now/Kernel#sleep — the thin CLI (scripts/hook-message-display)
|
|
13
|
+
# is the one place allowed to read any of those.
|
|
14
|
+
#
|
|
15
|
+
# Claude adapter: Claude Code only; the core is harness-agnostic. (intent
|
|
16
|
+
# 316a1, D3 supersedes 316a's D6.) This is the sole caller that asks
|
|
17
|
+
# IntentScreenAnsi.render for `markdown_safe: true` (scripts/lib/
|
|
18
|
+
# intent_screen_ansi.rb) — see `finalize` below for why.
|
|
19
|
+
#
|
|
20
|
+
# A live run under a real pty (round 3) found that Claude Code fires the
|
|
21
|
+
# per-chunk hook processes CONCURRENTLY, not strictly in order. Chunk 0 is
|
|
22
|
+
# the one that recognizes the screen and creates the buffer (D13), and it can
|
|
23
|
+
# lose the race to chunks with a higher index: they would find no buffer yet
|
|
24
|
+
# and pass their raw Markdown straight through, producing a half plain /
|
|
25
|
+
# half styled screen. This class now survives that:
|
|
26
|
+
#
|
|
27
|
+
# - One file per chunk (index-named), written atomically (temp name in the
|
|
28
|
+
# same directory, then File.rename), so reassembly never depends on
|
|
29
|
+
# arrival order — only on the index each chunk already carries.
|
|
30
|
+
# - A decision file written BEFORE anything slow: chunk 0 writes SCREEN
|
|
31
|
+
# (the resolved intent dir + store root) the moment it engages, or
|
|
32
|
+
# NOSCREEN the moment it does not, so later chunks can decide without
|
|
33
|
+
# redoing any of chunk 0's work.
|
|
34
|
+
# - A later chunk asks a cheap, local question before ever waiting: could
|
|
35
|
+
# this delta plausibly be part of a screen (leading "|", "**Steps**", or
|
|
36
|
+
# blank)? An ordinary prose chunk arriving before SCREEN/NOSCREEN exists
|
|
37
|
+
# passes through at once, at zero cost. A chunk shaped like part of a
|
|
38
|
+
# screen polls for the decision, bounded (wait_ms/poll_ms), then fails
|
|
39
|
+
# open. The final chunk always waits for the decision, whatever its own
|
|
40
|
+
# shape, since it is the one that must not race — and it additionally
|
|
41
|
+
# waits (same budget) for every earlier chunk file to exist before it
|
|
42
|
+
# splices, returning whatever it does have rather than nothing when the
|
|
43
|
+
# budget runs out.
|
|
44
|
+
#
|
|
45
|
+
# Protocol (D13, preserved): chunk 0 still decides, once, before anything is
|
|
46
|
+
# buffered or blanked. D10 (any failure while finalizing returns the
|
|
47
|
+
# buffered original, never nil, never "") and D12 (color: false never
|
|
48
|
+
# buffers or blanks anything) are unchanged.
|
|
49
|
+
class MessageDisplay
|
|
50
|
+
# 317a (A4): engagement is grammar, not identity - any screen-family
|
|
51
|
+
# opener engages, with NO intent-id resolution (the roster and delay
|
|
52
|
+
# screens have none to resolve). ScreenPaint owns the full grammar.
|
|
53
|
+
ENGAGE_RE = /\A(?:##? )?[▶✔] /.freeze
|
|
54
|
+
BUFFER_DIR_NAME = "plastic-message-display"
|
|
55
|
+
BUFFER_MAX_AGE_SECONDS = 3600
|
|
56
|
+
SCREEN_FILE = "SCREEN"
|
|
57
|
+
NOSCREEN_FILE = "NOSCREEN"
|
|
58
|
+
|
|
59
|
+
def initialize(tmp_root:, plastic_home:, color:, now:, wait_ms: 300, poll_ms: 20,
|
|
60
|
+
sleeper: ->(seconds) { sleep(seconds) })
|
|
61
|
+
@tmp_root = tmp_root
|
|
62
|
+
@plastic_home = plastic_home
|
|
63
|
+
@color = color
|
|
64
|
+
@now = now
|
|
65
|
+
@wait_ms = wait_ms
|
|
66
|
+
@poll_ms = poll_ms
|
|
67
|
+
@sleeper = sleeper
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def handle(payload)
|
|
71
|
+
return nil unless @color
|
|
72
|
+
return nil unless payload.is_a?(Hash)
|
|
73
|
+
|
|
74
|
+
prune_old_buffers
|
|
75
|
+
|
|
76
|
+
message_id = payload["message_id"].to_s
|
|
77
|
+
session_id = payload["session_id"].to_s
|
|
78
|
+
delta = payload["delta"].to_s
|
|
79
|
+
final = payload["final"] == true
|
|
80
|
+
index = payload["index"]
|
|
81
|
+
cwd = payload["cwd"].to_s
|
|
82
|
+
|
|
83
|
+
return nil if message_id.empty? || session_id.empty?
|
|
84
|
+
|
|
85
|
+
dir = self.class.buffer_path(tmp_root: @tmp_root, session_id: session_id, message_id: message_id)
|
|
86
|
+
|
|
87
|
+
if index == 0
|
|
88
|
+
handle_chunk_zero(dir, delta, cwd, final)
|
|
89
|
+
else
|
|
90
|
+
handle_later_chunk(dir, index, delta, final)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The message directory both this class and the bash launcher (hooks/
|
|
95
|
+
# message-display) must agree on byte for byte (matrix 40): the launcher
|
|
96
|
+
# checks this exact path's existence to decide whether chunk > 0 of an
|
|
97
|
+
# engaged message gets handed to Ruby at all.
|
|
98
|
+
def self.buffer_path(tmp_root:, session_id:, message_id:)
|
|
99
|
+
File.join(tmp_root, BUFFER_DIR_NAME, session_id, message_id)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.chunk_path(tmp_root:, session_id:, message_id:, index:)
|
|
103
|
+
File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), index.to_s)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.screen_path(tmp_root:, session_id:, message_id:)
|
|
107
|
+
File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), SCREEN_FILE)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def self.noscreen_path(tmp_root:, session_id:, message_id:)
|
|
111
|
+
File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), NOSCREEN_FILE)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
# Chunk 0 decides, synchronously, before anything else touches this
|
|
117
|
+
# message: recognize the marker (after leading whitespace only) AND
|
|
118
|
+
# resolve the id, both before anything is buffered or blanked (F4). Either
|
|
119
|
+
# failure writes NOSCREEN so every later chunk can decide instantly rather
|
|
120
|
+
# than waiting out its own budget for a decision that will never arrive.
|
|
121
|
+
def handle_chunk_zero(dir, delta, _cwd, final)
|
|
122
|
+
stripped = delta.sub(/\A[ \t]+/, "")
|
|
123
|
+
unless ENGAGE_RE.match?(stripped)
|
|
124
|
+
write_noscreen(dir)
|
|
125
|
+
return nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
write_screen(dir)
|
|
129
|
+
write_chunk(dir, 0, delta)
|
|
130
|
+
final ? finalize_final(dir, 0) : ""
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# A later chunk (index > 0) never redoes chunk 0's work: it only asks
|
|
134
|
+
# whether a decision already exists, waiting for one (bounded) when it
|
|
135
|
+
# does not and the chunk looks like it could matter. The final chunk
|
|
136
|
+
# always waits for the decision regardless of its own shape.
|
|
137
|
+
def handle_later_chunk(dir, index, delta, final)
|
|
138
|
+
decision = wait_for_decision(dir, gate_delta: final ? nil : delta)
|
|
139
|
+
|
|
140
|
+
return nil unless decision == :screen
|
|
141
|
+
|
|
142
|
+
write_chunk(dir, index, delta)
|
|
143
|
+
final ? finalize_final(dir, index) : ""
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Checks for an existing decision first (free) and only pays the cheap
|
|
147
|
+
# shape test, then the bounded poll, when neither SCREEN nor NOSCREEN is
|
|
148
|
+
# there yet. `gate_delta: nil` (the final chunk) skips the shape test
|
|
149
|
+
# entirely and always polls for the decision.
|
|
150
|
+
def wait_for_decision(dir, gate_delta:)
|
|
151
|
+
decision = read_decision_now(dir)
|
|
152
|
+
return decision if decision
|
|
153
|
+
|
|
154
|
+
return :timeout if gate_delta && !maybe_screen?(gate_delta)
|
|
155
|
+
|
|
156
|
+
max_polls_for_budget.times do
|
|
157
|
+
@sleeper.call(@poll_ms / 1000.0)
|
|
158
|
+
decision = read_decision_now(dir)
|
|
159
|
+
return decision if decision
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
:timeout
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def read_decision_now(dir)
|
|
166
|
+
return :screen if File.exist?(File.join(dir, SCREEN_FILE))
|
|
167
|
+
return :noscreen if File.exist?(File.join(dir, NOSCREEN_FILE))
|
|
168
|
+
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Cheap, local, no file I/O: could this chunk's own delta plausibly be
|
|
173
|
+
# part of an intent screen (ignoring leading whitespace)? Every chunk of
|
|
174
|
+
# every ordinary prose message answers no, at zero cost.
|
|
175
|
+
def maybe_screen?(delta)
|
|
176
|
+
stripped = delta.lstrip
|
|
177
|
+
stripped.empty? || stripped.start_with?("|") || stripped.start_with?("**")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# The final chunk additionally waits (same budget) for every earlier chunk
|
|
181
|
+
# file to exist before it reassembles and splices. On timeout it proceeds
|
|
182
|
+
# anyway with whatever is there (matrix, lead's guard): never nil, never
|
|
183
|
+
# swallowed.
|
|
184
|
+
def finalize_final(dir, index)
|
|
185
|
+
wait_for_chunk_files(dir, index)
|
|
186
|
+
|
|
187
|
+
buffered = nil
|
|
188
|
+
begin
|
|
189
|
+
buffered = read_buffered_chunks(dir, index)
|
|
190
|
+
finalize(buffered, nil)
|
|
191
|
+
rescue StandardError
|
|
192
|
+
buffered
|
|
193
|
+
ensure
|
|
194
|
+
FileUtils.rm_rf(dir)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def wait_for_chunk_files(dir, index)
|
|
199
|
+
return if index <= 0
|
|
200
|
+
|
|
201
|
+
needed = (0...index).map(&:to_s)
|
|
202
|
+
max_polls_for_budget.times do
|
|
203
|
+
return if needed.all? { |n| File.exist?(File.join(dir, n)) }
|
|
204
|
+
|
|
205
|
+
@sleeper.call(@poll_ms / 1000.0)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def max_polls_for_budget
|
|
210
|
+
return 0 unless @poll_ms.to_f.positive?
|
|
211
|
+
|
|
212
|
+
(@wait_ms / @poll_ms.to_f).ceil
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Whatever chunk files exist, in index order, concatenated -- gaps (a
|
|
216
|
+
# chunk that never arrived, or arrived too late) are skipped rather than
|
|
217
|
+
# blocking reassembly (lead's guard: never return nothing).
|
|
218
|
+
def read_buffered_chunks(dir, index)
|
|
219
|
+
(0..index).filter_map do |i|
|
|
220
|
+
path = File.join(dir, i.to_s)
|
|
221
|
+
File.exist?(path) ? File.read(path) : nil
|
|
222
|
+
end.join
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# 317a (D1/B10): paint what was printed. The buffered message's screen
|
|
226
|
+
# region - located and bounded by ScreenPaint's own grammar - is re-laid in
|
|
227
|
+
# the ANSI vocabulary; prose before and after survives verbatim. A region
|
|
228
|
+
# the painter cannot parse returns the buffered original (A3: chunks were
|
|
229
|
+
# already blanked, so nil here would truncate the message to its final
|
|
230
|
+
# delta; nil is only for the never-engaged path in handle).
|
|
231
|
+
#
|
|
232
|
+
# markdown_safe: true (intent 316a1, D5) - Claude Code still Markdown-
|
|
233
|
+
# processes displayContent even inside a raw ANSI block, so the Claude
|
|
234
|
+
# adapter asks the harness-agnostic core to strip markdown noise. A harness
|
|
235
|
+
# whose display surface passes raw ANSI through untouched would ask for
|
|
236
|
+
# false instead.
|
|
237
|
+
def finalize(buffered, _decision)
|
|
238
|
+
lines = buffered.lines
|
|
239
|
+
start = lines.index { |l| ScreenPaint.classify(l) == :opener }
|
|
240
|
+
return buffered unless start
|
|
241
|
+
|
|
242
|
+
stop = ScreenPaint.region_end(lines, start)
|
|
243
|
+
painted = ScreenPaint.paint(lines[start...stop].join, color: true, markdown_safe: true)
|
|
244
|
+
return buffered unless painted
|
|
245
|
+
|
|
246
|
+
suffix = lines[stop..].to_a.join.sub(/\A\n+/, "")
|
|
247
|
+
out = +"#{lines[0...start].join}#{painted.rstrip}\n"
|
|
248
|
+
out << "\n#{suffix}" unless suffix.empty?
|
|
249
|
+
out
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def write_chunk(dir, index, delta)
|
|
253
|
+
atomic_write(File.join(dir, index.to_s), delta)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# IntentScreen/IntentScreenAnsi's store_root: is the TIER root (what HOLDS
|
|
257
|
+
# store/ — e.g. .../projects/<slug> or plastic_home itself), never the
|
|
258
|
+
# store/ directory itself; resolve_intent_dir's `root:` is already that.
|
|
259
|
+
def write_screen(dir)
|
|
260
|
+
atomic_write(File.join(dir, SCREEN_FILE), "")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def write_noscreen(dir)
|
|
264
|
+
atomic_write(File.join(dir, NOSCREEN_FILE), "")
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def atomic_write(path, content)
|
|
268
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
269
|
+
tmp_path = "#{path}.tmp#{Process.pid}-#{rand(1_000_000)}"
|
|
270
|
+
File.write(tmp_path, content)
|
|
271
|
+
File.rename(tmp_path, path)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def prune_old_buffers
|
|
277
|
+
root = File.join(@tmp_root, BUFFER_DIR_NAME)
|
|
278
|
+
return unless File.directory?(root)
|
|
279
|
+
|
|
280
|
+
Dir.children(root).each do |session_dir|
|
|
281
|
+
full = File.join(root, session_dir)
|
|
282
|
+
next unless File.directory?(full)
|
|
283
|
+
|
|
284
|
+
age = @now.to_i - File.mtime(full).to_i
|
|
285
|
+
FileUtils.rm_rf(full) if age > BUFFER_MAX_AGE_SECONDS
|
|
286
|
+
end
|
|
287
|
+
rescue StandardError
|
|
288
|
+
nil
|
|
289
|
+
end
|
|
290
|
+
end
|