@zalom/plastic 2.0.0-alpha.13 → 2.0.0-alpha.14
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 +31 -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 +128 -0
- package/scripts/lib/installer_core.rb +23 -3
- package/scripts/lib/message_display.rb +151 -37
- package/scripts/lib/report_screen.rb +820 -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,40 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "report_screen"
|
|
5
|
+
|
|
6
|
+
# DashboardScreen (intent 331d) - the presentation half of the dashboard
|
|
7
|
+
# screen. scripts/dashboard.rb's screen_fields sources every fact (Active, In
|
|
8
|
+
# delivery, Delivered, Roadmap, Sessions, Changed, the two capped row lists)
|
|
9
|
+
# through the same helpers report-screen and DaySummary already use, turning
|
|
10
|
+
# a missing source into "not recorded" or "none" before it ever reaches
|
|
11
|
+
# here; this module carries no data-sourcing logic of its own, only layout,
|
|
12
|
+
# exactly like ReportScreen.render_state and IntentScreen.render do for
|
|
13
|
+
# their own screens.
|
|
14
|
+
module DashboardScreen
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
TEMPLATE_PATH = File.expand_path("../../templates/dashboard-screen.md", __dir__)
|
|
18
|
+
|
|
19
|
+
def render(fields, template: nil)
|
|
20
|
+
out = (template || File.read(TEMPLATE_PATH)).dup
|
|
21
|
+
out = out.gsub("{{scope}}", fields.fetch(:scope).to_s)
|
|
22
|
+
out = out.gsub("{{active}}", fields.fetch(:active).to_s)
|
|
23
|
+
out = out.gsub("{{in_delivery}}", fields.fetch(:in_delivery).to_s)
|
|
24
|
+
out = out.gsub("{{delivered}}", fields.fetch(:delivered).to_s)
|
|
25
|
+
out = out.gsub("{{roadmap}}", fields.fetch(:roadmap).to_s)
|
|
26
|
+
out = out.gsub("{{sessions}}", fields.fetch(:sessions).to_s)
|
|
27
|
+
out = out.gsub("{{changed}}", fields.fetch(:changed).to_s)
|
|
28
|
+
out = out.gsub("{{where_we_are.rows}}", where_we_are_rows(fields.fetch(:where_we_are, [])))
|
|
29
|
+
out = out.gsub("{{where_we_go_next.rows}}", where_we_go_next_rows(fields.fetch(:where_we_go_next, [])))
|
|
30
|
+
ReportScreen.fit_screen(out.gsub(/\n{3,}/, "\n\n"))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def where_we_are_rows(rows)
|
|
34
|
+
rows.map { |r| "| #{r[:graph_id]} | #{r[:intent]} | #{r[:stage]} | #{r[:progress]} | #{r[:lead]} |" }.join("\n")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def where_we_go_next_rows(rows)
|
|
38
|
+
rows.map { |r| "| #{r[:rank]} | #{r[:graph_id]} | #{r[:intent]} | #{r[:reason]} |" }.join("\n")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -91,8 +91,19 @@ class Doctor
|
|
|
91
91
|
|
|
92
92
|
JSON.parse(File.read(path))
|
|
93
93
|
rescue JSON::ParserError
|
|
94
|
-
|
|
95
|
-
JSON
|
|
94
|
+
# The comment/trailing-comma-stripped retry below can itself raise
|
|
95
|
+
# JSON::ParserError on genuinely malformed content (a truncated file, or
|
|
96
|
+
# plain garbage). A nested begin/rescue is required here because a
|
|
97
|
+
# method-level `rescue` clause never catches an exception raised from
|
|
98
|
+
# INSIDE a sibling rescue clause's own body (only from the main body).
|
|
99
|
+
# Without this nesting a malformed settings.json crashes doctor instead
|
|
100
|
+
# of reporting a clean fail (intent 331e, F5).
|
|
101
|
+
begin
|
|
102
|
+
content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
|
|
103
|
+
JSON.parse(content)
|
|
104
|
+
rescue
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
96
107
|
rescue
|
|
97
108
|
nil
|
|
98
109
|
end
|
|
@@ -1245,10 +1256,94 @@ class Doctor
|
|
|
1245
1256
|
all_checks += check_manifest_sync(agent_key)
|
|
1246
1257
|
all_checks += check_registered_project_paths
|
|
1247
1258
|
all_checks += check_global_store_available
|
|
1259
|
+
all_checks += check_display_registration(agent_key)
|
|
1248
1260
|
|
|
1249
1261
|
summarize(all_checks, agent_key, binary: true)
|
|
1250
1262
|
end
|
|
1251
1263
|
|
|
1264
|
+
# The single `hooks/<name>` launcher basename for the MessageDisplay event,
|
|
1265
|
+
# derived from HookRegistry rather than hand-kept (intent 331e), so a
|
|
1266
|
+
# future rename of the hook stays in one place. Shared by
|
|
1267
|
+
# check_display_registration (below, boot path) and scripts/doctor.rb's
|
|
1268
|
+
# check_display_paints (full run), which resolves the SAME name under the
|
|
1269
|
+
# agent_dir it was given.
|
|
1270
|
+
def display_hook_launcher_name
|
|
1271
|
+
group = HookRegistry.events["MessageDisplay"].first
|
|
1272
|
+
"plastic-#{group['hooks'].first['name']}"
|
|
1273
|
+
end
|
|
1274
|
+
|
|
1275
|
+
DISPLAY_HOOK_FIX_HINT = "Re-run the Plastic installer to repair the hook registration: " \
|
|
1276
|
+
"npx @zalom/plastic@<channel> install --reinstall --claude " \
|
|
1277
|
+
"(plastic-install --repair)".freeze
|
|
1278
|
+
|
|
1279
|
+
# display_hook_registered (intent 331e, D1, category "display"): the Claude
|
|
1280
|
+
# settings carry the plastic-message-display command, on-disk, executable.
|
|
1281
|
+
# Boot-path safe: resolves everything from the injected `agents` hash and
|
|
1282
|
+
# `plastic_home`, never Dir.home or a real ~/.claude (E18). This is the same
|
|
1283
|
+
# discipline check_claude_registration already follows.
|
|
1284
|
+
#
|
|
1285
|
+
# D3: a harness Doctor knows carries no display hook (Codex, Hermes) is a
|
|
1286
|
+
# pass, not a fail, worded "plain by contract" like the paint check's own
|
|
1287
|
+
# skip (scripts/doctor.rb's check_display_paints).
|
|
1288
|
+
def check_display_registration(agent_key)
|
|
1289
|
+
config = agents[agent_key]
|
|
1290
|
+
unless agent_key == "claude"
|
|
1291
|
+
return [check(
|
|
1292
|
+
category: "display", name: "display_hook_registered", status: "pass",
|
|
1293
|
+
message: "#{config[:name]} is plain by contract; no MessageDisplay hook to register"
|
|
1294
|
+
)]
|
|
1295
|
+
end
|
|
1296
|
+
|
|
1297
|
+
agent_dir = config[:dir]
|
|
1298
|
+
settings_path = File.join(agent_dir, "settings.json")
|
|
1299
|
+
settings = read_json_safe(settings_path)
|
|
1300
|
+
|
|
1301
|
+
if settings.nil?
|
|
1302
|
+
return [check(
|
|
1303
|
+
category: "display", name: "display_hook_registered", status: "fail",
|
|
1304
|
+
message: "Cannot read #{tilde(settings_path)}: file missing or invalid",
|
|
1305
|
+
fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
|
|
1306
|
+
)]
|
|
1307
|
+
end
|
|
1308
|
+
|
|
1309
|
+
hooks = settings["hooks"].is_a?(Hash) ? settings["hooks"] : {}
|
|
1310
|
+
commands = event_commands(hooks["MessageDisplay"])
|
|
1311
|
+
launcher_name = display_hook_launcher_name
|
|
1312
|
+
|
|
1313
|
+
registered = commands.any? { |cmd| HookRegistry.command_basenames(cmd).include?(launcher_name) }
|
|
1314
|
+
|
|
1315
|
+
unless registered
|
|
1316
|
+
return [check(
|
|
1317
|
+
category: "display", name: "display_hook_registered", status: "fail",
|
|
1318
|
+
message: "No MessageDisplay hook registered in #{tilde(settings_path)}",
|
|
1319
|
+
fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
|
|
1320
|
+
)]
|
|
1321
|
+
end
|
|
1322
|
+
|
|
1323
|
+
launcher_path = File.join(agent_dir, "hooks", launcher_name)
|
|
1324
|
+
|
|
1325
|
+
unless File.exist?(launcher_path)
|
|
1326
|
+
return [check(
|
|
1327
|
+
category: "display", name: "display_hook_registered", status: "fail",
|
|
1328
|
+
message: "MessageDisplay is registered but #{tilde(launcher_path)} does not exist",
|
|
1329
|
+
fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
|
|
1330
|
+
)]
|
|
1331
|
+
end
|
|
1332
|
+
|
|
1333
|
+
unless File.executable?(launcher_path)
|
|
1334
|
+
return [check(
|
|
1335
|
+
category: "display", name: "display_hook_registered", status: "fail",
|
|
1336
|
+
message: "#{tilde(launcher_path)} exists but is not executable",
|
|
1337
|
+
fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
|
|
1338
|
+
)]
|
|
1339
|
+
end
|
|
1340
|
+
|
|
1341
|
+
[check(
|
|
1342
|
+
category: "display", name: "display_hook_registered", status: "pass",
|
|
1343
|
+
message: "MessageDisplay hook registered and #{tilde(launcher_path)} is executable"
|
|
1344
|
+
)]
|
|
1345
|
+
end
|
|
1346
|
+
|
|
1252
1347
|
def check_registered_project_paths
|
|
1253
1348
|
checks = []
|
|
1254
1349
|
|
|
@@ -0,0 +1,128 @@
|
|
|
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
|
+
def run_one(hook_path, payload, full_env, tmp_root, timeout)
|
|
60
|
+
return capture(hook_path, payload, full_env) unless timeout
|
|
61
|
+
|
|
62
|
+
run_bounded(hook_path, payload, full_env, tmp_root, timeout)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def capture(hook_path, payload, full_env)
|
|
66
|
+
out, err, status = Open3.capture3(full_env, hook_path, stdin_data: JSON.generate(payload))
|
|
67
|
+
[out, err, status.exitstatus]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Spawn directly (never Open3.capture3) so a timeout can actually kill the
|
|
71
|
+
# child, with stdin/stdout/stderr routed through scratch files under the
|
|
72
|
+
# caller's own tmp_root, and never pipes, so a stalled or oversized write can
|
|
73
|
+
# never deadlock the read side, and never anywhere outside tmp_root, so a
|
|
74
|
+
# bounded replay carries the same "writes only under the injected tmp
|
|
75
|
+
# root" guarantee as the unbounded path.
|
|
76
|
+
def run_bounded(hook_path, payload, full_env, tmp_root, timeout)
|
|
77
|
+
token = "#{Process.pid}-#{(Time.now.to_f * 1_000_000).to_i}-#{rand(1_000_000)}"
|
|
78
|
+
in_path = File.join(tmp_root, ".hook-replay-in-#{token}")
|
|
79
|
+
out_path = File.join(tmp_root, ".hook-replay-out-#{token}")
|
|
80
|
+
err_path = File.join(tmp_root, ".hook-replay-err-#{token}")
|
|
81
|
+
File.write(in_path, JSON.generate(payload))
|
|
82
|
+
|
|
83
|
+
pid = Process.spawn(full_env, hook_path, in: in_path, out: out_path, err: err_path)
|
|
84
|
+
exitstatus =
|
|
85
|
+
begin
|
|
86
|
+
Timeout.timeout(timeout) { Process.wait(pid) }
|
|
87
|
+
$?.exitstatus
|
|
88
|
+
rescue Timeout::Error
|
|
89
|
+
kill_and_reap(pid)
|
|
90
|
+
nil # nil exitstatus is the caller's signal that this chunk timed out
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
out = File.exist?(out_path) ? File.read(out_path) : ""
|
|
94
|
+
err = File.exist?(err_path) ? File.read(err_path) : ""
|
|
95
|
+
[out, err, exitstatus]
|
|
96
|
+
ensure
|
|
97
|
+
[in_path, out_path, err_path].each { |p| File.delete(p) if p && File.exist?(p) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def kill_and_reap(pid)
|
|
101
|
+
Process.kill("KILL", pid)
|
|
102
|
+
rescue StandardError
|
|
103
|
+
nil
|
|
104
|
+
ensure
|
|
105
|
+
begin
|
|
106
|
+
Process.wait(pid)
|
|
107
|
+
rescue StandardError
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The final chunk's parsed displayContent, or nil when it emitted nothing
|
|
113
|
+
# (no envelope at all: the message never engaged).
|
|
114
|
+
def final_display_content(outs)
|
|
115
|
+
final = outs.last
|
|
116
|
+
return nil if final[:stdout].to_s.empty?
|
|
117
|
+
|
|
118
|
+
JSON.parse(final[:stdout]).dig("hookSpecificOutput", "displayContent")
|
|
119
|
+
rescue JSON::ParserError
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# True when any chunk's spawn hit its timeout (run_bounded's nil-exitstatus
|
|
124
|
+
# signal). A replay made with no `timeout:` never reports true.
|
|
125
|
+
def timed_out?(outs)
|
|
126
|
+
outs.any? { |o| o[:exitstatus].nil? }
|
|
127
|
+
end
|
|
128
|
+
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
|
|
@@ -46,11 +46,32 @@ 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.
|
|
49
63
|
class MessageDisplay
|
|
50
64
|
# 317a (A4): engagement is grammar, not identity - any screen-family
|
|
51
65
|
# opener engages, with NO intent-id resolution (the roster and delay
|
|
52
|
-
# screens have none to resolve). ScreenPaint owns the full grammar.
|
|
66
|
+
# screens have none to resolve). ScreenPaint owns the full grammar. Used
|
|
67
|
+
# per LINE (331a), not only against the start of a whole delta: a chunk's
|
|
68
|
+
# own text is scanned line by line for the first line that opens a screen,
|
|
69
|
+
# wherever it falls.
|
|
53
70
|
ENGAGE_RE = /\A(?:##? )?[▶✔] /.freeze
|
|
71
|
+
# 331a (D4): a lone fence line, opening (optional info string) or closing
|
|
72
|
+
# (never one), by itself on its own line.
|
|
73
|
+
FENCE_OPEN_RE = /\A```[^\n]*\z/.freeze
|
|
74
|
+
FENCE_CLOSE_RE = /\A```\z/.freeze
|
|
54
75
|
BUFFER_DIR_NAME = "plastic-message-display"
|
|
55
76
|
BUFFER_MAX_AGE_SECONDS = 3600
|
|
56
77
|
SCREEN_FILE = "SCREEN"
|
|
@@ -114,27 +135,30 @@ class MessageDisplay
|
|
|
114
135
|
private
|
|
115
136
|
|
|
116
137
|
# Chunk 0 decides, synchronously, before anything else touches this
|
|
117
|
-
# message:
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
138
|
+
# message: does ITS OWN delta carry an opener anywhere (331a; used to be
|
|
139
|
+
# only at the very start)? No opener writes NOSCREEN so every later chunk
|
|
140
|
+
# can decide instantly rather than waiting out its own budget for a
|
|
141
|
+
# decision that will never arrive - but NOSCREEN is no longer final (D2):
|
|
142
|
+
# a later chunk carrying an opener still replaces it.
|
|
121
143
|
def handle_chunk_zero(dir, delta, _cwd, final)
|
|
122
|
-
|
|
123
|
-
unless
|
|
144
|
+
split = split_at_opener(delta)
|
|
145
|
+
unless split
|
|
124
146
|
write_noscreen(dir)
|
|
125
147
|
return nil
|
|
126
148
|
end
|
|
127
149
|
|
|
128
|
-
|
|
129
|
-
write_chunk(dir, 0, delta)
|
|
130
|
-
final ? finalize_final(dir, 0) : ""
|
|
150
|
+
engage(dir, 0, split, final)
|
|
131
151
|
end
|
|
132
152
|
|
|
133
|
-
# A later chunk (index > 0)
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
#
|
|
153
|
+
# A later chunk (index > 0) tests its OWN delta for an opener FIRST,
|
|
154
|
+
# before consulting any existing decision (331a, D2): an opener engages
|
|
155
|
+
# the message whatever the current decision says, including when NOSCREEN
|
|
156
|
+
# is already on disk. Only once its own delta carries no opener does it
|
|
157
|
+
# fall back to the original decision-driven wait.
|
|
137
158
|
def handle_later_chunk(dir, index, delta, final)
|
|
159
|
+
split = split_at_opener(delta)
|
|
160
|
+
return engage(dir, index, split, final) if split
|
|
161
|
+
|
|
138
162
|
decision = wait_for_decision(dir, gate_delta: final ? nil : delta)
|
|
139
163
|
|
|
140
164
|
return nil unless decision == :screen
|
|
@@ -143,6 +167,51 @@ class MessageDisplay
|
|
|
143
167
|
final ? finalize_final(dir, index) : ""
|
|
144
168
|
end
|
|
145
169
|
|
|
170
|
+
# Engages the message starting at THIS chunk (whatever its index): writes
|
|
171
|
+
# SCREEN carrying this chunk's index (replacing any NOSCREEN, D2/D6),
|
|
172
|
+
# buffers the opener onward at this chunk's own index, and returns the
|
|
173
|
+
# text before the opener (fence-stripped, D4) as the displayContent. When
|
|
174
|
+
# this chunk is also final, the prefix is prepended to whatever `finalize`
|
|
175
|
+
# produces (painted, or the buffered original on a fail-open) rather than
|
|
176
|
+
# dropped.
|
|
177
|
+
def engage(dir, index, split, final)
|
|
178
|
+
prefix, rest = split
|
|
179
|
+
prefix = strip_preceding_fence(prefix)
|
|
180
|
+
write_screen(dir, index)
|
|
181
|
+
remove_noscreen(dir)
|
|
182
|
+
write_chunk(dir, index, rest)
|
|
183
|
+
|
|
184
|
+
return prefix unless final
|
|
185
|
+
|
|
186
|
+
finalized = finalize_final(dir, index)
|
|
187
|
+
prefix.empty? ? finalized : "#{prefix}#{finalized}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Scans `delta` line by line for the first line that opens a screen
|
|
191
|
+
# (331a: an opener can fall anywhere in a chunk's own delta, not only at
|
|
192
|
+
# its start). Returns [prefix, rest] - the text before that line, and the
|
|
193
|
+
# line onward - or nil when no line in this delta engages.
|
|
194
|
+
def split_at_opener(delta)
|
|
195
|
+
lines = delta.each_line.to_a
|
|
196
|
+
idx = lines.index { |line| ENGAGE_RE.match?(line.sub(/\A[ \t]+/, "")) }
|
|
197
|
+
return nil unless idx
|
|
198
|
+
|
|
199
|
+
[lines[0...idx].join, lines[idx..].join]
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# 331a (D4): a lone fence line immediately preceding the opener, inside
|
|
203
|
+
# THIS SAME chunk's own prefix, is dropped - it never reaches the screen
|
|
204
|
+
# (it would otherwise print directly above the painted block). A fence in
|
|
205
|
+
# an earlier chunk is never touched: it was already displayed, verbatim,
|
|
206
|
+
# by that earlier chunk's own return value.
|
|
207
|
+
def strip_preceding_fence(prefix)
|
|
208
|
+
lines = prefix.each_line.to_a
|
|
209
|
+
return prefix if lines.empty?
|
|
210
|
+
return prefix unless FENCE_OPEN_RE.match?(lines.last.strip)
|
|
211
|
+
|
|
212
|
+
lines[0...-1].join
|
|
213
|
+
end
|
|
214
|
+
|
|
146
215
|
# Checks for an existing decision first (free) and only pays the cheap
|
|
147
216
|
# shape test, then the bounded poll, when neither SCREEN nor NOSCREEN is
|
|
148
217
|
# there yet. `gate_delta: nil` (the final chunk) skips the shape test
|
|
@@ -169,6 +238,20 @@ class MessageDisplay
|
|
|
169
238
|
nil
|
|
170
239
|
end
|
|
171
240
|
|
|
241
|
+
# 331a (M5a): the start index crosses process boundaries through SCREEN's
|
|
242
|
+
# own content, never in-memory state - the final chunk is routinely a
|
|
243
|
+
# SEPARATE process from the one that engaged. An empty or missing file
|
|
244
|
+
# reads back as 0 (chunk 0 engaged, today's shape, so nothing that ever
|
|
245
|
+
# wrote an empty SCREEN breaks).
|
|
246
|
+
def read_start_index(dir)
|
|
247
|
+
path = File.join(dir, SCREEN_FILE)
|
|
248
|
+
return 0 unless File.exist?(path)
|
|
249
|
+
|
|
250
|
+
File.read(path).to_i
|
|
251
|
+
rescue StandardError
|
|
252
|
+
0
|
|
253
|
+
end
|
|
254
|
+
|
|
172
255
|
# Cheap, local, no file I/O: could this chunk's own delta plausibly be
|
|
173
256
|
# part of an intent screen (ignoring leading whitespace)? Every chunk of
|
|
174
257
|
# every ordinary prose message answers no, at zero cost.
|
|
@@ -177,28 +260,38 @@ class MessageDisplay
|
|
|
177
260
|
stripped.empty? || stripped.start_with?("|") || stripped.start_with?("**")
|
|
178
261
|
end
|
|
179
262
|
|
|
180
|
-
# The final chunk additionally waits (same budget) for every
|
|
181
|
-
#
|
|
182
|
-
#
|
|
183
|
-
#
|
|
263
|
+
# The final chunk additionally waits (same budget) for every chunk file
|
|
264
|
+
# from the start index onward to exist before it reassembles and splices
|
|
265
|
+
# (331a, D3/M5: from the start index, not from 0 - chunks before the
|
|
266
|
+
# engaging one were never buffered at all, so waiting for them would only
|
|
267
|
+
# ever burn the whole budget for files that will never appear). On
|
|
268
|
+
# timeout it proceeds anyway with whatever is there (matrix, lead's
|
|
269
|
+
# guard): never nil, never swallowed.
|
|
184
270
|
def finalize_final(dir, index)
|
|
185
|
-
|
|
271
|
+
start_index = read_start_index(dir)
|
|
272
|
+
wait_for_chunk_files(dir, start_index, index)
|
|
186
273
|
|
|
187
274
|
buffered = nil
|
|
188
275
|
begin
|
|
189
|
-
buffered = read_buffered_chunks(dir, index)
|
|
276
|
+
buffered = read_buffered_chunks(dir, start_index, index)
|
|
190
277
|
finalize(buffered, nil)
|
|
191
278
|
rescue StandardError
|
|
192
|
-
buffered
|
|
279
|
+
# A read failing inside the assignment above leaves `buffered` at its
|
|
280
|
+
# nil default (the assignment never completes), which the review pass
|
|
281
|
+
# caught: D8/D10 promise the buffered original on any finalize
|
|
282
|
+
# failure, never nil, once chunks were blanked. `||=` covers exactly
|
|
283
|
+
# that gap without touching the ordinary case (buffered already holds
|
|
284
|
+
# the real chunks read before `finalize` itself raised).
|
|
285
|
+
buffered ||= ""
|
|
193
286
|
ensure
|
|
194
287
|
FileUtils.rm_rf(dir)
|
|
195
288
|
end
|
|
196
289
|
end
|
|
197
290
|
|
|
198
|
-
def wait_for_chunk_files(dir, index)
|
|
199
|
-
return if index <=
|
|
291
|
+
def wait_for_chunk_files(dir, start_index, index)
|
|
292
|
+
return if index <= start_index
|
|
200
293
|
|
|
201
|
-
needed = (
|
|
294
|
+
needed = (start_index...index).map(&:to_s)
|
|
202
295
|
max_polls_for_budget.times do
|
|
203
296
|
return if needed.all? { |n| File.exist?(File.join(dir, n)) }
|
|
204
297
|
|
|
@@ -212,11 +305,12 @@ class MessageDisplay
|
|
|
212
305
|
(@wait_ms / @poll_ms.to_f).ceil
|
|
213
306
|
end
|
|
214
307
|
|
|
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
|
-
|
|
308
|
+
# Whatever chunk files exist FROM THE START INDEX onward, in index order,
|
|
309
|
+
# concatenated -- gaps (a chunk that never arrived, or arrived too late)
|
|
310
|
+
# are skipped rather than blocking reassembly (lead's guard: never return
|
|
311
|
+
# nothing).
|
|
312
|
+
def read_buffered_chunks(dir, start_index, index)
|
|
313
|
+
(start_index..index).filter_map do |i|
|
|
220
314
|
path = File.join(dir, i.to_s)
|
|
221
315
|
File.exist?(path) ? File.read(path) : nil
|
|
222
316
|
end.join
|
|
@@ -239,11 +333,22 @@ class MessageDisplay
|
|
|
239
333
|
start = lines.index { |l| ScreenPaint.classify(l) == :opener }
|
|
240
334
|
return buffered unless start
|
|
241
335
|
|
|
242
|
-
|
|
243
|
-
painted = ScreenPaint.paint(lines[start...
|
|
336
|
+
region_stop = ScreenPaint.region_end(lines, start)
|
|
337
|
+
painted = ScreenPaint.paint(lines[start...region_stop].join, color: true, markdown_safe: true)
|
|
244
338
|
return buffered unless painted
|
|
245
339
|
|
|
246
|
-
|
|
340
|
+
# 331a (D4): a lone CLOSING fence immediately after the painted region is
|
|
341
|
+
# dropped - never the painting boundary itself (region_stop, used above,
|
|
342
|
+
# is untouched), only where the suffix starts. An unrelated fenced code
|
|
343
|
+
# block further down, with prose or a blank line between it and the
|
|
344
|
+
# region, is never adjacent, so it always survives verbatim (M8b); a
|
|
345
|
+
# closing fence never carries an info string, so an adjacent OPENING
|
|
346
|
+
# fence of a real code block (which usually does) is never mistaken for
|
|
347
|
+
# it either.
|
|
348
|
+
suffix_start = region_stop
|
|
349
|
+
suffix_start += 1 if suffix_start < lines.length && FENCE_CLOSE_RE.match?(lines[suffix_start].strip)
|
|
350
|
+
|
|
351
|
+
suffix = lines[suffix_start..].to_a.join.sub(/\A\n+/, "")
|
|
247
352
|
out = +"#{lines[0...start].join}#{painted.rstrip}\n"
|
|
248
353
|
out << "\n#{suffix}" unless suffix.empty?
|
|
249
354
|
out
|
|
@@ -253,17 +358,26 @@ class MessageDisplay
|
|
|
253
358
|
atomic_write(File.join(dir, index.to_s), delta)
|
|
254
359
|
end
|
|
255
360
|
|
|
256
|
-
#
|
|
257
|
-
#
|
|
258
|
-
#
|
|
259
|
-
|
|
260
|
-
|
|
361
|
+
# SCREEN's content is the engaging chunk's own index, as a decimal integer
|
|
362
|
+
# (331a, D6) - read back by `read_start_index` so the final chunk (a
|
|
363
|
+
# separate process, in production) knows where to start waiting and
|
|
364
|
+
# splicing, and so `finalize_final` never touches chunks that were passed
|
|
365
|
+
# through untouched before engagement.
|
|
366
|
+
def write_screen(dir, index)
|
|
367
|
+
atomic_write(File.join(dir, SCREEN_FILE), "#{index}\n")
|
|
261
368
|
end
|
|
262
369
|
|
|
263
370
|
def write_noscreen(dir)
|
|
264
371
|
atomic_write(File.join(dir, NOSCREEN_FILE), "")
|
|
265
372
|
end
|
|
266
373
|
|
|
374
|
+
# 331a (D2/D6): NOSCREEN is no longer a final answer - a later chunk that
|
|
375
|
+
# engages replaces it with SCREEN and, for safety, removes NOSCREEN so a
|
|
376
|
+
# stale marker can never be read back once the real decision exists.
|
|
377
|
+
def remove_noscreen(dir)
|
|
378
|
+
FileUtils.rm_f(File.join(dir, NOSCREEN_FILE))
|
|
379
|
+
end
|
|
380
|
+
|
|
267
381
|
def atomic_write(path, content)
|
|
268
382
|
FileUtils.mkdir_p(File.dirname(path))
|
|
269
383
|
tmp_path = "#{path}.tmp#{Process.pid}-#{rand(1_000_000)}"
|