@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.
Files changed (41) hide show
  1. package/hooks/message-display +31 -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 +128 -0
  8. package/scripts/lib/installer_core.rb +23 -3
  9. package/scripts/lib/message_display.rb +151 -37
  10. package/scripts/lib/report_screen.rb +820 -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,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
- content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
95
- JSON.parse(content)
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 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
@@ -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: 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.
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
- stripped = delta.sub(/\A[ \t]+/, "")
123
- unless ENGAGE_RE.match?(stripped)
144
+ split = split_at_opener(delta)
145
+ unless split
124
146
  write_noscreen(dir)
125
147
  return nil
126
148
  end
127
149
 
128
- write_screen(dir)
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) 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.
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 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.
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
- wait_for_chunk_files(dir, index)
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 <= 0
291
+ def wait_for_chunk_files(dir, start_index, index)
292
+ return if index <= start_index
200
293
 
201
- needed = (0...index).map(&:to_s)
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, 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|
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
- stop = ScreenPaint.region_end(lines, start)
243
- painted = ScreenPaint.paint(lines[start...stop].join, color: true, markdown_safe: true)
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
- suffix = lines[stop..].to_a.join.sub(/\A\n+/, "")
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
- # 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), "")
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)}"