@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.
Files changed (41) hide show
  1. package/hooks/message-display +55 -2
  2. package/package.json +1 -1
  3. package/scripts/dashboard.rb +238 -8
  4. package/scripts/doctor.rb +291 -4
  5. package/scripts/lib/dashboard_screen.rb +40 -0
  6. package/scripts/lib/doctor_core.rb +97 -2
  7. package/scripts/lib/hook_replay.rb +211 -0
  8. package/scripts/lib/installer_core.rb +23 -3
  9. package/scripts/lib/message_display.rb +267 -47
  10. package/scripts/lib/report_screen.rb +837 -18
  11. package/scripts/lib/roadmap_queue.rb +19 -2
  12. package/scripts/lib/roadmap_savepoint.rb +36 -7
  13. package/scripts/lib/savepoint.rb +12 -0
  14. package/scripts/lib/screen_paint.rb +240 -11
  15. package/scripts/lib/screens/dashboard.rb +20 -0
  16. package/scripts/lib/screens/plan.rb +18 -0
  17. package/scripts/lib/screens/roadmap.rb +15 -0
  18. package/scripts/lib/verify_intent.rb +33 -0
  19. package/scripts/report-screen +41 -7
  20. package/scripts/savepoint-note +11 -9
  21. package/skills/auto/SKILL.md +9 -9
  22. package/skills/auto/references/human-report-contract.md +79 -8
  23. package/skills/dashboard/SKILL.md +13 -2
  24. package/skills/dashboard/templates/dashboard-global.md +1 -1
  25. package/skills/dashboard/templates/dashboard-project.md +2 -2
  26. package/skills/doctor/SKILL.md +10 -4
  27. package/skills/intent-continuing/SKILL.md +19 -21
  28. package/skills/intent-continuing/references/board-fill.md +9 -0
  29. package/skills/intent-ending/SKILL.md +6 -4
  30. package/skills/intent-executing/SKILL.md +2 -0
  31. package/skills/intent-speccing/SKILL.md +7 -4
  32. package/skills/roadmap/SKILL.md +9 -0
  33. package/skills/roadmap/references/file-format.md +10 -0
  34. package/templates/dashboard-screen.md +22 -0
  35. package/templates/display-fixture.md +21 -0
  36. package/templates/intent-screen.md +1 -1
  37. package/templates/report-plan.md +15 -0
  38. package/templates/report-roadmap-delivered.md +10 -0
  39. package/templates/report-roadmap-plan.md +9 -0
  40. package/templates/report-roadmap-state.md +9 -0
  41. package/templates/report-state.md +1 -1
@@ -0,0 +1,211 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "open3"
6
+ require "timeout"
7
+
8
+ # HookReplay (intent 331a, T1; promoted to a production lib in 331e) - streams
9
+ # text through a MessageDisplay launcher the way Claude Code streams an
10
+ # assistant reply, chunk by chunk, and returns every chunk's raw stdout.
11
+ #
12
+ # 331a's test/support/hook_replay.rb held this as test-only code; 331e's
13
+ # doctor `display_hook_paints` check (scripts/doctor.rb) needs the exact same
14
+ # mechanics to replay the INSTALLED launcher for real, so the logic lives
15
+ # here and test/support/hook_replay.rb now delegates to it (a require, not a
16
+ # duplicate). Never require this from scripts/lib/doctor_core.rb: that file
17
+ # is the SessionStart boot path (test/doctor_core_split_test.rb T2 pins its
18
+ # exact require set), and the paint check that needs this lib runs only from
19
+ # the full scripts/doctor.rb.
20
+ module HookReplay
21
+ module_function
22
+
23
+ # Streams `text` through `hook_path` in fixed-size chunks. `session_id`/
24
+ # `message_id` default to fixed values since nothing about a replay depends
25
+ # on the ambient session at all.
26
+ #
27
+ # `env` (intent 331e): extra child-process environment, merged over the
28
+ # PLASTIC_TMP entry every call already sets (a caller's own key wins). A
29
+ # `nil` value unsets that variable in the child (Process.spawn's own
30
+ # convention) is how a caller forces NO_COLOR off regardless of the ambient
31
+ # environment. Default `{}` keeps every existing caller's behavior
32
+ # unchanged: this is an extension, not a fork.
33
+ #
34
+ # `timeout` (intent 331e): when given, bounds EACH chunk's spawn to that
35
+ # many seconds. A bare `Timeout.timeout` around `Open3.capture3` does not
36
+ # reliably bound a genuinely hanging child: capture3's own wait still
37
+ # blocks on Process.waitpid for the child regardless of the raised
38
+ # Timeout::Error (the same gotcha scripts/hook-record works around), so a
39
+ # timeout here spawns directly and kills the child on expiry instead.
40
+ # Default `nil` keeps every existing caller on the original unbounded
41
+ # Open3.capture3 path.
42
+ def replay(hook_path:, tmp_root:, text:, chunk: 40, session_id: "s-replay", message_id: "replay",
43
+ env: {}, timeout: nil)
44
+ chunks = text.scan(/.{1,#{chunk}}/m)
45
+ chunks = [""] if chunks.empty?
46
+ full_env = { "PLASTIC_TMP" => tmp_root }.merge(env)
47
+
48
+ chunks.each_with_index.map do |delta, i|
49
+ payload = {
50
+ "session_id" => session_id, "message_id" => message_id, "index" => i,
51
+ "final" => i == chunks.length - 1, "delta" => delta, "cwd" => tmp_root,
52
+ "hook_event_name" => "MessageDisplay",
53
+ }
54
+ out, err, exitstatus = run_one(hook_path, payload, full_env, tmp_root, timeout)
55
+ { index: i, exitstatus: exitstatus, stdout: out, stderr: err, final: payload["final"] }
56
+ end
57
+ end
58
+
59
+ # replay_concurrent (intent 331a1) - streams `text` through `hook_path` the
60
+ # way `replay` does, but fires every chunk in its OWN thread, staggered by
61
+ # `gap_ms` (plus up to half a gap of jitter when `jitter` is true) rather
62
+ # than run sequentially. This is what reproduces the decision-race defect
63
+ # 331a1 fixes: Claude Code fires the per-chunk hook processes CONCURRENTLY
64
+ # in production, and `replay`'s strictly-sequential default never puts two
65
+ # chunks in flight at once, so it could never have reproduced the race in
66
+ # the first place.
67
+ #
68
+ # `gap_ms: 5` plus jitter is the default on purpose, not "fire everything
69
+ # at once": firing all 335 chunks of the live session capture with no
70
+ # stagger at all takes about 8 s of wall clock on this 8-core machine,
71
+ # because EACH chunk boots its own real Ruby process and the completions
72
+ # cluster at the tail once every core is saturated - a load real streaming
73
+ # never produces (a real stream delivers a chunk every few tens of
74
+ # milliseconds, one at a time). One Ruby process per streamed chunk is the
75
+ # actual throughput ceiling here, not something this method works around.
76
+ #
77
+ # `replay`'s own signature and sequential default are UNCHANGED by this
78
+ # method's existence (`scripts/doctor.rb:2571` calls `replay` directly and
79
+ # must keep working exactly as it does today) - this is a sibling method
80
+ # in the same module, never a replacement.
81
+ #
82
+ # Returns the SAME result shape `replay` returns (one Hash per chunk, keys
83
+ # index/exitstatus/stdout/stderr/final), ordered by index regardless of
84
+ # the order the threads actually finish in.
85
+ def replay_concurrent(hook_path:, tmp_root:, text:, chunk: 40, session_id: "s-replay",
86
+ message_id: "replay", env: {}, gap_ms: 5, jitter: true)
87
+ chunks = text.scan(/.{1,#{chunk}}/m)
88
+ chunks = [""] if chunks.empty?
89
+ full_env = { "PLASTIC_TMP" => tmp_root }.merge(env)
90
+ gap = gap_ms / 1000.0
91
+
92
+ results = Array.new(chunks.length)
93
+ threads = chunks.each_with_index.map do |delta, i|
94
+ payload = {
95
+ "session_id" => session_id, "message_id" => message_id, "index" => i,
96
+ "final" => i == chunks.length - 1, "delta" => delta, "cwd" => tmp_root,
97
+ "hook_event_name" => "MessageDisplay",
98
+ }
99
+ Thread.new do
100
+ begin
101
+ delay = i * gap
102
+ delay += (rand * gap / 2.0) if jitter
103
+ sleep(delay)
104
+ out, err, exitstatus = run_one(hook_path, payload, full_env, tmp_root, nil)
105
+ results[i] = { index: i, exitstatus: exitstatus, stdout: out, stderr: err, final: payload["final"] }
106
+ rescue StandardError => e
107
+ # A raise inside a thread body is invisible until join, and an
108
+ # unrescued one aborts `threads.each(&:join)` at the first dead
109
+ # thread: every later thread is then never joined and outlives the
110
+ # call, racing whatever the caller does next (typically removing
111
+ # the very tmp root those threads are still writing under). Report
112
+ # the failure as this chunk's own result instead, so the array is
113
+ # always complete, every thread is always joined, and a replay
114
+ # tells its caller what went wrong rather than throwing at it.
115
+ results[i] = { index: i, exitstatus: nil, stdout: "", stderr: e.message,
116
+ final: payload["final"] }
117
+ end
118
+ end
119
+ end
120
+ threads.each { |thread| thread.join }
121
+ results
122
+ end
123
+
124
+ # Indices of the chunks that reached the terminal as raw Markdown: a
125
+ # non-final chunk that emitted nothing at all, after the engaging chunk.
126
+ # A chunk "passed through" when its stdout is empty; a chunk was
127
+ # "blanked" (correctly buffered, not shown raw) when its stdout contains
128
+ # `"displayContent":""`. Only chunks with an index greater than the
129
+ # engaging chunk's own index count - the engaging chunk is the first one
130
+ # (at or after `start_index`) whose stdout is non-empty, and chunks
131
+ # before it already reached the terminal live, verbatim, through the
132
+ # ordinary passthrough path (they were never candidates for buffering at
133
+ # all, so an empty stdout from one of them is not this defect).
134
+ def passthrough_indices(outs, start_index: 0)
135
+ engaging = outs.find { |o| o[:index] >= start_index && !o[:stdout].to_s.empty? }
136
+ return [] unless engaging
137
+
138
+ outs.select { |o| o[:index] > engaging[:index] && o[:final] != true && o[:stdout].to_s.empty? }
139
+ .map { |o| o[:index] }
140
+ end
141
+
142
+ def run_one(hook_path, payload, full_env, tmp_root, timeout)
143
+ return capture(hook_path, payload, full_env) unless timeout
144
+
145
+ run_bounded(hook_path, payload, full_env, tmp_root, timeout)
146
+ end
147
+
148
+ def capture(hook_path, payload, full_env)
149
+ out, err, status = Open3.capture3(full_env, hook_path, stdin_data: JSON.generate(payload))
150
+ [out, err, status.exitstatus]
151
+ end
152
+
153
+ # Spawn directly (never Open3.capture3) so a timeout can actually kill the
154
+ # child, with stdin/stdout/stderr routed through scratch files under the
155
+ # caller's own tmp_root, and never pipes, so a stalled or oversized write can
156
+ # never deadlock the read side, and never anywhere outside tmp_root, so a
157
+ # bounded replay carries the same "writes only under the injected tmp
158
+ # root" guarantee as the unbounded path.
159
+ def run_bounded(hook_path, payload, full_env, tmp_root, timeout)
160
+ token = "#{Process.pid}-#{(Time.now.to_f * 1_000_000).to_i}-#{rand(1_000_000)}"
161
+ in_path = File.join(tmp_root, ".hook-replay-in-#{token}")
162
+ out_path = File.join(tmp_root, ".hook-replay-out-#{token}")
163
+ err_path = File.join(tmp_root, ".hook-replay-err-#{token}")
164
+ File.write(in_path, JSON.generate(payload))
165
+
166
+ pid = Process.spawn(full_env, hook_path, in: in_path, out: out_path, err: err_path)
167
+ exitstatus =
168
+ begin
169
+ Timeout.timeout(timeout) { Process.wait(pid) }
170
+ $?.exitstatus
171
+ rescue Timeout::Error
172
+ kill_and_reap(pid)
173
+ nil # nil exitstatus is the caller's signal that this chunk timed out
174
+ end
175
+
176
+ out = File.exist?(out_path) ? File.read(out_path) : ""
177
+ err = File.exist?(err_path) ? File.read(err_path) : ""
178
+ [out, err, exitstatus]
179
+ ensure
180
+ [in_path, out_path, err_path].each { |p| File.delete(p) if p && File.exist?(p) }
181
+ end
182
+
183
+ def kill_and_reap(pid)
184
+ Process.kill("KILL", pid)
185
+ rescue StandardError
186
+ nil
187
+ ensure
188
+ begin
189
+ Process.wait(pid)
190
+ rescue StandardError
191
+ nil
192
+ end
193
+ end
194
+
195
+ # The final chunk's parsed displayContent, or nil when it emitted nothing
196
+ # (no envelope at all: the message never engaged).
197
+ def final_display_content(outs)
198
+ final = outs.last
199
+ return nil if final[:stdout].to_s.empty?
200
+
201
+ JSON.parse(final[:stdout]).dig("hookSpecificOutput", "displayContent")
202
+ rescue JSON::ParserError
203
+ nil
204
+ end
205
+
206
+ # True when any chunk's spawn hit its timeout (run_bounded's nil-exitstatus
207
+ # signal). A replay made with no `timeout:` never reports true.
208
+ def timed_out?(outs)
209
+ outs.any? { |o| o[:exitstatus].nil? }
210
+ end
211
+ end
@@ -331,12 +331,26 @@ class InstallerCore
331
331
  end
332
332
  end
333
333
 
334
+ # Intent 331a (D6/R7): a screen kind file (scripts/lib/screens/<kind>.rb)
335
+ # must reach an installed ~/.plastic the same way a new template or hook
336
+ # does - glob-derived, so "add a file, not a diff" is actually true for an
337
+ # installed Plastic, not just an in-repo one. This repo ships none yet;
338
+ # the glob answers {} until one exists.
339
+ def screen_files
340
+ Dir.glob(File.join(package_root, "scripts", "lib", "screens", "*.rb")).each_with_object({}) do |path, acc|
341
+ next unless File.file?(path)
342
+
343
+ rel = File.join("scripts", "lib", "screens", File.basename(path))
344
+ acc[rel] = rel
345
+ end
346
+ end
347
+
334
348
  # Files copied into ~/.plastic on install/update. Every verb script + the shared lib
335
349
  # must be here so the installed ~/.plastic/scripts copy is self-complete (sync-guarded
336
- # by install_sync_test). The templates half is glob-derived (template_files above); the
337
- # rest stays a hand-written literal.
350
+ # by install_sync_test). The templates and screen-kind halves are glob-derived
351
+ # (template_files, screen_files above); the rest stays a hand-written literal.
338
352
  def core_files
339
- hand_registered_files.merge(template_files).merge(hook_files)
353
+ hand_registered_files.merge(template_files).merge(hook_files).merge(screen_files)
340
354
  end
341
355
 
342
356
  def hand_registered_files
@@ -423,6 +437,7 @@ class InstallerCore
423
437
  "scripts/exec-worktree" => "scripts/exec-worktree",
424
438
  "scripts/doctor.rb" => "scripts/doctor.rb",
425
439
  "scripts/lib/doctor_core.rb" => "scripts/lib/doctor_core.rb",
440
+ "scripts/lib/hook_replay.rb" => "scripts/lib/hook_replay.rb",
426
441
  "scripts/lib/rule_catalog.rb" => "scripts/lib/rule_catalog.rb",
427
442
  "scripts/lib/doctor_exclusions.rb" => "scripts/lib/doctor_exclusions.rb",
428
443
  "scripts/lib/doctor_session_ledger.rb" => "scripts/lib/doctor_session_ledger.rb",
@@ -454,6 +469,11 @@ class InstallerCore
454
469
  "scripts/lib/intent_screen_ansi.rb" => "scripts/lib/intent_screen_ansi.rb",
455
470
  "scripts/lib/screen_paint.rb" => "scripts/lib/screen_paint.rb",
456
471
  "scripts/lib/message_display.rb" => "scripts/lib/message_display.rb",
472
+ # Intent 331d (A1): scripts/dashboard.rb require_relatives this lib
473
+ # directly; templates/dashboard-screen.md and scripts/lib/screens/
474
+ # dashboard.rb are glob-derived (template_files, screen_files above)
475
+ # and need no entry here.
476
+ "scripts/lib/dashboard_screen.rb" => "scripts/lib/dashboard_screen.rb",
457
477
  "scripts/hook-message-display" => "scripts/hook-message-display",
458
478
  }
459
479
  end