@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
|
@@ -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
|
|
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
|