@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.
Files changed (55) hide show
  1. package/bin/lib/context_budget.rb +453 -0
  2. package/bin/plastic-bench +78 -0
  3. package/hooks/hooks.json +12 -0
  4. package/hooks/message-display +81 -0
  5. package/hooks/savepoint +5 -5
  6. package/package.json +1 -1
  7. package/scripts/agent-report +8 -2
  8. package/scripts/append-ledger +16 -3
  9. package/scripts/dashboard.rb +39 -10
  10. package/scripts/day-summary +53 -0
  11. package/scripts/doctor.rb +163 -0
  12. package/scripts/end-intent +93 -0
  13. package/scripts/hook-capture +21 -8
  14. package/scripts/hook-close +3 -1
  15. package/scripts/hook-message-display +74 -0
  16. package/scripts/hook-record +12 -4
  17. package/scripts/hook-savepoint +45 -0
  18. package/scripts/hook-session-start +34 -1
  19. package/scripts/intent-screen +77 -0
  20. package/scripts/lib/arm.rb +26 -1
  21. package/scripts/lib/compact_instructions.rb +56 -0
  22. package/scripts/lib/day_summary.rb +211 -0
  23. package/scripts/lib/doctor_core.rb +52 -3
  24. package/scripts/lib/doctor_session_ledger.rb +52 -0
  25. package/scripts/lib/handoff.rb +184 -0
  26. package/scripts/lib/hook_registry.rb +14 -0
  27. package/scripts/lib/installer_core.rb +117 -11
  28. package/scripts/lib/intent_screen.rb +309 -0
  29. package/scripts/lib/intent_screen_ansi.rb +262 -0
  30. package/scripts/lib/message_display.rb +290 -0
  31. package/scripts/lib/report_screen.rb +671 -0
  32. package/scripts/lib/savepoint.rb +14 -0
  33. package/scripts/lib/screen_paint.rb +276 -0
  34. package/scripts/lib/session_close.rb +22 -2
  35. package/scripts/lib/session_git.rb +49 -18
  36. package/scripts/lib/session_ledger.rb +124 -0
  37. package/scripts/plastic-lock +8 -1
  38. package/scripts/read-config +3 -0
  39. package/scripts/report-screen +157 -0
  40. package/scripts/rollback.rb +6 -0
  41. package/scripts/savepoint-note +67 -0
  42. package/scripts/spawn-preamble +9 -2
  43. package/scripts/write-handoff +60 -0
  44. package/skills/auto/SKILL.md +15 -8
  45. package/skills/auto/references/human-report-contract.md +59 -53
  46. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  47. package/skills/intent-continuing/SKILL.md +31 -22
  48. package/skills/intent-continuing/references/boarding-matrix.md +5 -5
  49. package/skills/intent-continuing/references/context-management.md +1 -1
  50. package/skills/intent-ending/SKILL.md +8 -2
  51. package/skills/intent-executing/SKILL.md +6 -0
  52. package/templates/config.yml +5 -0
  53. package/templates/intent-screen.md +17 -0
  54. package/templates/outcome.md +14 -1
  55. 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