@zalom/plastic 2.0.0-alpha.4 → 2.0.0-alpha.6

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.
@@ -0,0 +1,371 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require_relative "intent_screen"
6
+ require_relative "intent_screen_ansi"
7
+ require_relative "store_discovery"
8
+ require_relative "store_provisioning"
9
+
10
+ # MessageDisplay (intent 316a, O4/O5, round 3 concurrency fix) - the Claude
11
+ # Code MessageDisplay hook handler. One process per streamed chunk of every
12
+ # assistant message (D11), so it must be cheap and decide fast. Pure: every
13
+ # dependency (tmp_root, plastic_home, color, now, wait_ms, poll_ms, sleeper)
14
+ # is a constructor argument, never an ENV read, a Dir.pwd/Dir.home read, or
15
+ # the real Time.now/Kernel#sleep — the thin CLI (scripts/hook-message-display)
16
+ # is the one place allowed to read any of those.
17
+ #
18
+ # A live run under a real pty (round 3) found that Claude Code fires the
19
+ # per-chunk hook processes CONCURRENTLY, not strictly in order. Chunk 0 is
20
+ # the one that recognizes the screen and creates the buffer (D13), and it can
21
+ # lose the race to chunks with a higher index: they would find no buffer yet
22
+ # and pass their raw Markdown straight through, producing a half plain /
23
+ # half styled screen. This class now survives that:
24
+ #
25
+ # - One file per chunk (index-named), written atomically (temp name in the
26
+ # same directory, then File.rename), so reassembly never depends on
27
+ # arrival order — only on the index each chunk already carries.
28
+ # - A decision file written BEFORE anything slow: chunk 0 writes SCREEN
29
+ # (the resolved intent dir + store root) the moment it engages, or
30
+ # NOSCREEN the moment it does not, so later chunks can decide without
31
+ # redoing any of chunk 0's work.
32
+ # - A later chunk asks a cheap, local question before ever waiting: could
33
+ # this delta plausibly be part of a screen (leading "|", "**Steps**", or
34
+ # blank)? An ordinary prose chunk arriving before SCREEN/NOSCREEN exists
35
+ # passes through at once, at zero cost. A chunk shaped like part of a
36
+ # screen polls for the decision, bounded (wait_ms/poll_ms), then fails
37
+ # open. The final chunk always waits for the decision, whatever its own
38
+ # shape, since it is the one that must not race — and it additionally
39
+ # waits (same budget) for every earlier chunk file to exist before it
40
+ # splices, returning whatever it does have rather than nothing when the
41
+ # budget runs out.
42
+ #
43
+ # Protocol (D13, preserved): chunk 0 still decides, once, before anything is
44
+ # buffered or blanked. D10 (any failure while finalizing returns the
45
+ # buffered original, never nil, never "") and D12 (color: false never
46
+ # buffers or blanks anything) are unchanged.
47
+ class MessageDisplay
48
+ MARKER_RE = /\A## ▶ (\S+) · /.freeze
49
+ BUFFER_DIR_NAME = "plastic-message-display"
50
+ BUFFER_MAX_AGE_SECONDS = 3600
51
+ SCREEN_FILE = "SCREEN"
52
+ NOSCREEN_FILE = "NOSCREEN"
53
+
54
+ def initialize(tmp_root:, plastic_home:, color:, now:, wait_ms: 300, poll_ms: 20,
55
+ sleeper: ->(seconds) { sleep(seconds) })
56
+ @tmp_root = tmp_root
57
+ @plastic_home = plastic_home
58
+ @color = color
59
+ @now = now
60
+ @wait_ms = wait_ms
61
+ @poll_ms = poll_ms
62
+ @sleeper = sleeper
63
+ end
64
+
65
+ def handle(payload)
66
+ return nil unless @color
67
+ return nil unless payload.is_a?(Hash)
68
+
69
+ prune_old_buffers
70
+
71
+ message_id = payload["message_id"].to_s
72
+ session_id = payload["session_id"].to_s
73
+ delta = payload["delta"].to_s
74
+ final = payload["final"] == true
75
+ index = payload["index"]
76
+ cwd = payload["cwd"].to_s
77
+
78
+ return nil if message_id.empty? || session_id.empty?
79
+
80
+ dir = self.class.buffer_path(tmp_root: @tmp_root, session_id: session_id, message_id: message_id)
81
+
82
+ if index == 0
83
+ handle_chunk_zero(dir, delta, cwd, final)
84
+ else
85
+ handle_later_chunk(dir, index, delta, final)
86
+ end
87
+ end
88
+
89
+ # The message directory both this class and the bash launcher (hooks/
90
+ # message-display) must agree on byte for byte (matrix 40): the launcher
91
+ # checks this exact path's existence to decide whether chunk > 0 of an
92
+ # engaged message gets handed to Ruby at all.
93
+ def self.buffer_path(tmp_root:, session_id:, message_id:)
94
+ File.join(tmp_root, BUFFER_DIR_NAME, session_id, message_id)
95
+ end
96
+
97
+ def self.chunk_path(tmp_root:, session_id:, message_id:, index:)
98
+ File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), index.to_s)
99
+ end
100
+
101
+ def self.screen_path(tmp_root:, session_id:, message_id:)
102
+ File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), SCREEN_FILE)
103
+ end
104
+
105
+ def self.noscreen_path(tmp_root:, session_id:, message_id:)
106
+ File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), NOSCREEN_FILE)
107
+ end
108
+
109
+ private
110
+
111
+ # Chunk 0 decides, synchronously, before anything else touches this
112
+ # message: recognize the marker (after leading whitespace only) AND
113
+ # resolve the id, both before anything is buffered or blanked (F4). Either
114
+ # failure writes NOSCREEN so every later chunk can decide instantly rather
115
+ # than waiting out its own budget for a decision that will never arrive.
116
+ def handle_chunk_zero(dir, delta, cwd, final)
117
+ stripped = delta.sub(/\A[ \t]+/, "")
118
+ m = stripped.match(MARKER_RE)
119
+ resolved = m && resolve_intent_dir(m[1], cwd)
120
+
121
+ unless resolved
122
+ write_noscreen(dir)
123
+ return nil
124
+ end
125
+
126
+ write_screen(dir, resolved)
127
+ write_chunk(dir, 0, delta)
128
+ final ? finalize_final(dir, 0) : ""
129
+ end
130
+
131
+ # A later chunk (index > 0) never redoes chunk 0's work: it only asks
132
+ # whether a decision already exists, waiting for one (bounded) when it
133
+ # does not and the chunk looks like it could matter. The final chunk
134
+ # always waits for the decision regardless of its own shape.
135
+ def handle_later_chunk(dir, index, delta, final)
136
+ decision = wait_for_decision(dir, gate_delta: final ? nil : delta)
137
+
138
+ return nil unless decision == :screen
139
+
140
+ write_chunk(dir, index, delta)
141
+ final ? finalize_final(dir, index) : ""
142
+ end
143
+
144
+ # Checks for an existing decision first (free) and only pays the cheap
145
+ # shape test, then the bounded poll, when neither SCREEN nor NOSCREEN is
146
+ # there yet. `gate_delta: nil` (the final chunk) skips the shape test
147
+ # entirely and always polls for the decision.
148
+ def wait_for_decision(dir, gate_delta:)
149
+ decision = read_decision_now(dir)
150
+ return decision if decision
151
+
152
+ return :timeout if gate_delta && !maybe_screen?(gate_delta)
153
+
154
+ max_polls_for_budget.times do
155
+ @sleeper.call(@poll_ms / 1000.0)
156
+ decision = read_decision_now(dir)
157
+ return decision if decision
158
+ end
159
+
160
+ :timeout
161
+ end
162
+
163
+ def read_decision_now(dir)
164
+ return :screen if File.exist?(File.join(dir, SCREEN_FILE))
165
+ return :noscreen if File.exist?(File.join(dir, NOSCREEN_FILE))
166
+
167
+ nil
168
+ end
169
+
170
+ # Cheap, local, no file I/O: could this chunk's own delta plausibly be
171
+ # part of an intent screen (ignoring leading whitespace)? Every chunk of
172
+ # every ordinary prose message answers no, at zero cost.
173
+ def maybe_screen?(delta)
174
+ stripped = delta.lstrip
175
+ stripped.empty? || stripped.start_with?("|") || stripped.start_with?("**Steps**")
176
+ end
177
+
178
+ # The final chunk additionally waits (same budget) for every earlier chunk
179
+ # file to exist before it reassembles and splices. On timeout it proceeds
180
+ # anyway with whatever is there (matrix, lead's guard): never nil, never
181
+ # swallowed.
182
+ def finalize_final(dir, index)
183
+ wait_for_chunk_files(dir, index)
184
+
185
+ buffered = nil
186
+ begin
187
+ buffered = read_buffered_chunks(dir, index)
188
+ decision = read_screen_decision(dir)
189
+ finalize(buffered, decision)
190
+ rescue StandardError
191
+ buffered
192
+ ensure
193
+ FileUtils.rm_rf(dir)
194
+ end
195
+ end
196
+
197
+ def wait_for_chunk_files(dir, index)
198
+ return if index <= 0
199
+
200
+ needed = (0...index).map(&:to_s)
201
+ max_polls_for_budget.times do
202
+ return if needed.all? { |n| File.exist?(File.join(dir, n)) }
203
+
204
+ @sleeper.call(@poll_ms / 1000.0)
205
+ end
206
+ end
207
+
208
+ def max_polls_for_budget
209
+ return 0 unless @poll_ms.to_f.positive?
210
+
211
+ (@wait_ms / @poll_ms.to_f).ceil
212
+ end
213
+
214
+ # Whatever chunk files exist, in index order, concatenated -- gaps (a
215
+ # chunk that never arrived, or arrived too late) are skipped rather than
216
+ # blocking reassembly (lead's guard: never return nothing).
217
+ def read_buffered_chunks(dir, index)
218
+ (0..index).filter_map do |i|
219
+ path = File.join(dir, i.to_s)
220
+ File.exist?(path) ? File.read(path) : nil
221
+ end.join
222
+ end
223
+
224
+ def read_screen_decision(dir)
225
+ content = File.read(File.join(dir, SCREEN_FILE))
226
+ intent_dir, store_root = content.split("\n")
227
+ { intent_dir: intent_dir, store_root: store_root }
228
+ end
229
+
230
+ def finalize(buffered, decision)
231
+ intent_dir = decision[:intent_dir]
232
+ store_root = decision[:store_root]
233
+ ansi = IntentScreenAnsi.render(intent_dir: intent_dir, store_root: store_root, color: true)
234
+ plain = IntentScreen.render(intent_dir: intent_dir, store_root: store_root, template: File.read(template_path))
235
+ splice(buffered, plain, ansi)
236
+ end
237
+
238
+ def template_path
239
+ File.expand_path("../../templates/intent-screen.md", __dir__)
240
+ end
241
+
242
+ def write_chunk(dir, index, delta)
243
+ atomic_write(File.join(dir, index.to_s), delta)
244
+ end
245
+
246
+ # IntentScreen/IntentScreenAnsi's store_root: is the TIER root (what HOLDS
247
+ # store/ — e.g. .../projects/<slug> or plastic_home itself), never the
248
+ # store/ directory itself; resolve_intent_dir's `root:` is already that.
249
+ def write_screen(dir, resolved)
250
+ atomic_write(File.join(dir, SCREEN_FILE), "#{resolved[:intent_dir]}\n#{resolved[:root]}\n")
251
+ end
252
+
253
+ def write_noscreen(dir)
254
+ atomic_write(File.join(dir, NOSCREEN_FILE), "")
255
+ end
256
+
257
+ def atomic_write(path, content)
258
+ FileUtils.mkdir_p(File.dirname(path))
259
+ tmp_path = "#{path}.tmp#{Process.pid}-#{rand(1_000_000)}"
260
+ File.write(tmp_path, content)
261
+ File.rename(tmp_path, path)
262
+ end
263
+
264
+ # D16: replace the plain render's own text wherever it sits in the buffered
265
+ # message, keeping everything after it verbatim. Falls back to a line-based
266
+ # boundary (the "## ▶ " line through the last line starting with "|") only
267
+ # when the buffered text does not start with the plain render exactly (the
268
+ # model reformatted something, or a chunk gap broke the exact match) — the
269
+ # fallback also has to work for a checklist-less intent, whose only Steps
270
+ # row is "| | | no steps yet |".
271
+ def splice(buffered, plain, ansi)
272
+ suffix =
273
+ if buffered.start_with?(plain)
274
+ buffered[plain.length..]
275
+ else
276
+ line_based_suffix(buffered, plain)
277
+ end
278
+ return buffered if suffix.nil?
279
+
280
+ "#{ansi.rstrip}\n\n#{suffix}"
281
+ end
282
+
283
+ # Bounded fallback (matrix, lead's B1): walk forward from the "## ▶ " line
284
+ # only through the screen's OWN contiguous run of blank lines, "|"-prefixed
285
+ # table rows and the "**Steps**" heading, and stop at the first line that is
286
+ # none of those. The boundary is the last "|" line seen before that stop —
287
+ # never the last "|" line anywhere in the message. Scanning to the end
288
+ # unbounded (the old behavior) swallows any prose the model wrote between
289
+ # the screen and an unrelated Markdown table further down (a real hazard:
290
+ # Plastic replies carry tables often).
291
+ def line_based_suffix(buffered, plain)
292
+ lines = buffered.lines
293
+ start_idx = lines.index { |l| l.start_with?("## ▶ ") }
294
+ return nil unless start_idx
295
+
296
+ last_pipe_idx = nil
297
+ i = start_idx + 1
298
+ while i < lines.length
299
+ line = lines[i]
300
+ stripped = line.strip
301
+ break unless stripped.empty? || line.start_with?("|") || stripped == "**Steps**"
302
+
303
+ last_pipe_idx = i if line.start_with?("|")
304
+ i += 1
305
+ end
306
+ return nil unless last_pipe_idx
307
+
308
+ # Guard: never let the bounded scan consume more lines than the freshly
309
+ # rendered plain screen itself has. If it would, something about the
310
+ # buffered text does not match the shape splice() expects at all — pass
311
+ # the original through rather than risk eating real prose.
312
+ consumed = last_pipe_idx + 1 - start_idx
313
+ return nil if consumed > plain.lines.length
314
+
315
+ lines[(last_pipe_idx + 1)..].join
316
+ end
317
+
318
+ # O5: candidates are every discovered store holding a "<id>--*" directory.
319
+ # A single candidate resolves outright (no ambiguity to break). With two or
320
+ # more, the store whose project root is a path prefix of the payload's cwd
321
+ # decides; if that narrows to anything other than exactly one, pass through
322
+ # rather than guess (matrix 36).
323
+ #
324
+ # "cwd is a path prefix" is checked against the project's REAL checkout
325
+ # path (projects.yml's own `path:`, e.g. ~/apps/personal/plastic) — never
326
+ # against StoreDiscovery's `root` (~/.plastic/projects/<slug>, which only
327
+ # holds INDEX.md and store/). Those are two different directories; a real
328
+ # session's cwd lives under the former, never the latter. The global store
329
+ # has no such checkout path, so it never wins by cwd — only by being the
330
+ # sole candidate.
331
+ def resolve_intent_dir(id, cwd)
332
+ pattern = "#{glob_escape(id)}--*"
333
+ candidates = StoreDiscovery.discover(@plastic_home)[:stores].filter_map do |s|
334
+ dir = Dir.glob(File.join(s[:store], pattern)).find { |d| File.directory?(d) }
335
+ dir && { slug: s[:slug], root: s[:root], intent_dir: dir }
336
+ end
337
+ return nil if candidates.empty?
338
+ return candidates.first if candidates.length == 1
339
+
340
+ registered = StoreProvisioning.load_projects(@plastic_home)
341
+ cwd_matches = candidates.select do |c|
342
+ real_path = registered.dig(c[:slug], "path")
343
+ real_path && (cwd == real_path || cwd.start_with?("#{real_path}#{File::SEPARATOR}"))
344
+ end
345
+ return cwd_matches.first if cwd_matches.length == 1
346
+
347
+ nil
348
+ end
349
+
350
+ # A recognized id should just be [A-Za-z0-9]+, but the id comes out of the
351
+ # assistant's own streamed text, not a trusted schema — escape glob
352
+ # metacharacters rather than assume it is well-formed.
353
+ def glob_escape(str)
354
+ str.gsub(/([*?\[\]{}])/) { "\\#{Regexp.last_match(1)}" }
355
+ end
356
+
357
+ def prune_old_buffers
358
+ root = File.join(@tmp_root, BUFFER_DIR_NAME)
359
+ return unless File.directory?(root)
360
+
361
+ Dir.children(root).each do |session_dir|
362
+ full = File.join(root, session_dir)
363
+ next unless File.directory?(full)
364
+
365
+ age = @now.to_i - File.mtime(full).to_i
366
+ FileUtils.rm_rf(full) if age > BUFFER_MAX_AGE_SECONDS
367
+ end
368
+ rescue StandardError
369
+ nil
370
+ end
371
+ end
@@ -207,11 +207,37 @@ module SessionGit
207
207
 
208
208
  # --- commit message ------------------------------------------------------------
209
209
 
210
- # The first line of `summary`, truncated to MAX_SUBJECT_LENGTH characters,
211
- # with no trailer (spec D5).
210
+ # The first line of `summary`, cut at the last word boundary at or before
211
+ # MAX_SUBJECT_LENGTH characters, falling back to the hard slice when no
212
+ # boundary exists at or before the limit (spec D6, amends spec 300 D5's
213
+ # unconditional `first_line[0, MAX_SUBJECT_LENGTH]`, which cut mid-word).
214
+ # A subject at or under the limit is returned unchanged. Post-execution
215
+ # review item 7: when the character immediately after the 72-char prefix
216
+ # is ITSELF a space, the prefix already ends exactly on a word boundary and
217
+ # needs no trimming at all -- checked before consulting `rindex`, because
218
+ # an earlier internal space inside the 72-char prefix would otherwise make
219
+ # `rindex` walk back past a whole trailing word that fit perfectly.
212
220
  def subject_for(summary)
213
221
  first_line = summary.to_s.split(/\r?\n/, 2).first.to_s.strip
214
- first_line[0, MAX_SUBJECT_LENGTH]
222
+ return first_line if first_line.length <= MAX_SUBJECT_LENGTH
223
+
224
+ cut = first_line[0, MAX_SUBJECT_LENGTH]
225
+ return cut if first_line[MAX_SUBJECT_LENGTH] == " "
226
+
227
+ boundary = cut.rindex(" ")
228
+ boundary ? cut[0, boundary] : cut
229
+ end
230
+
231
+ # The commit body for `summary`/`subject` (spec D6): the full summary when
232
+ # `subject` is a cut-down copy of it, nil when the subject already carries
233
+ # the summary whole (no redundant body on a short, single-line summary).
234
+ # Post-execution review item 4: compares the STRIPPED raw summary, not the
235
+ # raw summary verbatim -- a summary with only trailing/leading whitespace
236
+ # around an otherwise-identical subject must not repeat the same sentence
237
+ # twice as a redundant body.
238
+ def body_for(summary, subject)
239
+ raw = summary.to_s
240
+ raw.strip == subject ? nil : raw
215
241
  end
216
242
 
217
243
  # --- git primitives (all use -C, never cwd) -------------------------------------
@@ -273,9 +299,11 @@ module SessionGit
273
299
  parts.each_cons(2).any? { |a, b| a == ".claude" && b == "worktrees" }
274
300
  end
275
301
 
276
- def stage_and_commit(dir, subject, runner:)
302
+ def stage_and_commit(dir, subject, runner:, body: nil)
277
303
  runner.run("-C", dir, "add", "-A")
278
- runner.run("-C", dir, "commit", "-m", subject)
304
+ args = ["-C", dir, "commit", "-m", subject]
305
+ args += ["-m", body] if body
306
+ runner.run(*args)
279
307
  end
280
308
 
281
309
  def short_sha(dir, runner:)
@@ -310,14 +338,16 @@ module SessionGit
310
338
 
311
339
  flow, flow_notes = load_flow(cwd: cwd, repo: repo, plastic_home: plastic_home, runner: runner)
312
340
  subject = subject_for(summary)
341
+ body = body_for(summary, subject)
313
342
 
314
343
  result =
315
344
  if flow["mode"] == "pull_request"
316
- commit_pull_request(repo: repo, subject: subject, day: day, session: session,
345
+ commit_pull_request(repo: repo, subject: subject, body: body, day: day, session: session,
317
346
  store: effective_store, flow: flow, branch_now: branch_now,
318
347
  runner: runner, gh_runner: gh_runner)
319
348
  else
320
- commit_direct(repo: repo, subject: subject, day: day, flow: flow, branch_now: branch_now, runner: runner)
349
+ commit_direct(repo: repo, subject: subject, body: body, day: day, flow: flow, branch_now: branch_now,
350
+ runner: runner)
321
351
  end
322
352
 
323
353
  return result if flow_notes.empty?
@@ -331,7 +361,7 @@ module SessionGit
331
361
 
332
362
  # --- direct mode (spec D3) --------------------------------------------------------
333
363
 
334
- def commit_direct(repo:, subject:, day:, flow:, branch_now:, runner:)
364
+ def commit_direct(repo:, subject:, day:, flow:, branch_now:, runner:, body: nil)
335
365
  return note("nothing to commit") unless dirty?(repo, runner: runner)
336
366
  return note("summary is empty after truncation: no commit") if blank?(subject)
337
367
 
@@ -351,10 +381,10 @@ module SessionGit
351
381
  end
352
382
 
353
383
  if branch_now == base || branch_now == session_branch
354
- commit_on_session_branch(repo: repo, subject: subject, base: base,
384
+ commit_on_session_branch(repo: repo, subject: subject, base: base, body: body,
355
385
  session_branch: session_branch, branch_now: branch_now, runner: runner)
356
386
  else
357
- commit_on_other_branch(repo: repo, subject: subject, branch_now: branch_now, runner: runner)
387
+ commit_on_other_branch(repo: repo, subject: subject, body: body, branch_now: branch_now, runner: runner)
358
388
  end
359
389
  end
360
390
 
@@ -367,7 +397,7 @@ module SessionGit
367
397
  # session branch. `current_branch` is re-read after the switch and used
368
398
  # for the commit message instead of trusting the branch this method
369
399
  # intended to reach.
370
- def commit_on_session_branch(repo:, subject:, base:, session_branch:, branch_now:, runner:)
400
+ def commit_on_session_branch(repo:, subject:, base:, session_branch:, branch_now:, runner:, body: nil)
371
401
  unless branch_exists?(repo, session_branch, runner: runner)
372
402
  create = runner.run("-C", repo, "branch", session_branch, base.to_s)
373
403
  return note("could not create session branch #{session_branch}: #{diagnose(create)}") unless create.success?
@@ -383,11 +413,12 @@ module SessionGit
383
413
  return note("expected to be on #{session_branch} but the checkout is on #{actual_branch.inspect}")
384
414
  end
385
415
 
386
- commit_and_push(dir: repo, push_dir: repo, subject: subject, from: actual_branch, base: base, runner: runner)
416
+ commit_and_push(dir: repo, push_dir: repo, subject: subject, body: body, from: actual_branch, base: base,
417
+ runner: runner)
387
418
  end
388
419
 
389
- def commit_on_other_branch(repo:, subject:, branch_now:, runner:)
390
- res = stage_and_commit(repo, subject, runner: runner)
420
+ def commit_on_other_branch(repo:, subject:, branch_now:, runner:, body: nil)
421
+ res = stage_and_commit(repo, subject, runner: runner, body: body)
391
422
  return note("commit rejected by commit-msg hook: #{diagnose(res)}") unless res.success?
392
423
 
393
424
  sha = short_sha(repo, runner: runner)
@@ -398,8 +429,8 @@ module SessionGit
398
429
  # shared tail. A non-fast-forward push (spec D3, "base moved ahead
399
430
  # independently") stays a Note: the commit itself already landed on the
400
431
  # session branch.
401
- def commit_and_push(dir:, push_dir:, subject:, from:, base:, runner:)
402
- res = stage_and_commit(dir, subject, runner: runner)
432
+ def commit_and_push(dir:, push_dir:, subject:, from:, base:, runner:, body: nil)
433
+ res = stage_and_commit(dir, subject, runner: runner, body: body)
403
434
  return note("commit rejected by commit-msg hook: #{diagnose(res)}") unless res.success?
404
435
 
405
436
  sha = short_sha(dir, runner: runner)
@@ -414,7 +445,7 @@ module SessionGit
414
445
 
415
446
  # --- pull request mode (spec D4) ------------------------------------------------
416
447
 
417
- def commit_pull_request(repo:, subject:, day:, session:, store:, flow:, branch_now:, runner:, gh_runner:)
448
+ def commit_pull_request(repo:, subject:, day:, session:, store:, flow:, branch_now:, runner:, gh_runner:, body: nil)
418
449
  return note("nothing to commit") unless dirty?(repo, runner: runner)
419
450
  return note("summary is empty after truncation: no commit") if blank?(subject)
420
451
 
@@ -439,7 +470,7 @@ module SessionGit
439
470
  return note("could not check out branch #{branch}: #{diagnose(switch)}") unless switch.success?
440
471
  end
441
472
 
442
- res = stage_and_commit(repo, subject, runner: runner)
473
+ res = stage_and_commit(repo, subject, runner: runner, body: body)
443
474
  outcome =
444
475
  if res.success?
445
476
  pull_request_outcome(repo: repo, subject: subject, branch: branch, base: base, gh_runner: gh_runner, runner: runner)
@@ -186,6 +186,130 @@ module SessionLedger
186
186
  "#{collapsed[0, 197]}..."
187
187
  end
188
188
 
189
+ # --- capture_worthy? (spec D2, D7; supersedes 298 D2(c)) -------------------
190
+
191
+ # One complete top-level harness envelope tag block: "<name ...>...</name>".
192
+ # Non-greedy (.*?) so sibling blocks are each matched on their own rather
193
+ # than one match spanning from the first block's opening tag all the way to
194
+ # the LAST block's closing tag (post-execution review item 3: an envelope
195
+ # on both sides of real work, "<system-reminder>...</system-reminder>\nfix
196
+ # the parser\n<task-notification>...</task-notification>", must not be
197
+ # read as one giant envelope swallowing the work in the middle).
198
+ ENVELOPE_BLOCK_RE = /<([A-Za-z][\w-]*)(?:\s[^>]*)?>.*?<\/\1>/m
199
+ private_constant :ENVELOPE_BLOCK_RE
200
+
201
+ # The whole prompt, case- and whitespace-insensitively, and nothing else
202
+ # (rule 3, D2): a trigger word inside a longer real instruction ("continue
203
+ # the dashboard fix and then release") must not match this.
204
+ BARE_TRIGGERS = %w[continue auto].freeze
205
+ private_constant :BARE_TRIGGERS
206
+
207
+ # Words whose presence marks a prompt as actionable work (rule 4's escape
208
+ # hatch, D2's accept bias). Deliberately excludes common nouns that also
209
+ # read as everyday verbs in casual remarks (e.g. "release", "ship", "plan"):
210
+ # including them would make ordinary conversation about a past release or
211
+ # plan look like a work request. Matched with an optional inflection suffix
212
+ # (post-execution review BLOCKER): the bare stems alone missed "fixed",
213
+ # "updated", "added", "reviewed", "implemented" -- exactly the past-tense
214
+ # and -ing forms real work summaries use.
215
+ WORK_MARKER_WORDS = %w[
216
+ fix add remove delete update upgrade implement write build create refactor
217
+ debug investigate review test deploy commit merge revert rename configure
218
+ install migrate document generate draft resolve help need want make change
219
+ setup
220
+ ].freeze
221
+ private_constant :WORK_MARKER_WORDS
222
+
223
+ WORK_MARKER_PHRASES = [
224
+ "can you", "could you", "would you", "let's", "let us", "set up", "look into", "figure out",
225
+ ].freeze
226
+ private_constant :WORK_MARKER_PHRASES
227
+
228
+ WORK_MARKER_RE = /\b(?:#{WORK_MARKER_WORDS.join("|")})(?:s|d|ed|ing)?\b/i
229
+ private_constant :WORK_MARKER_RE
230
+
231
+ # A first word that reads as an interrogative opener, checked case-
232
+ # insensitively against the prompt's first whitespace-separated token.
233
+ # Post-execution review BLOCKER: trimmed from the original, wider list
234
+ # (which also carried "how", "when", "where", "was", "were", "do", "did",
235
+ # "will", "shall", "should") down to words that open a genuine QUESTION at
236
+ # least as often as an ordinary command or request. Measured against 43
237
+ # invented and 27 real day-ledger prompts: the dropped words open ordinary
238
+ # work requests ("do the release now...", "when you are done, tag the
239
+ # release...", "will you push that branch...", "should I bump the version
240
+ # files...") far more often than they open a bare question worth rejecting.
241
+ QUESTION_STARTERS = %w[
242
+ what why who whom whose which is are am does can could would
243
+ ].freeze
244
+ private_constant :QUESTION_STARTERS
245
+
246
+ # A narrow set of retrospective-remark shapes ("that release went smoother
247
+ # than the last one"): comparative or evaluative observations about how
248
+ # something already went. Deliberately narrow (D2's accept bias): a broad
249
+ # "any declarative sentence with no recognized verb" rule would also catch
250
+ # ordinary work summaries like "harness text wins the pending line", which
251
+ # must stay accepted.
252
+ REMARK_PATTERNS = [
253
+ /\bwent\s+\w+\s+than\b/i,
254
+ /\bwent\s+(?:well|badly|smoothly|great|poorly|terribly)\b/i,
255
+ ].freeze
256
+ private_constant :REMARK_PATTERNS
257
+
258
+ # True iff nothing but harness envelope tag block(s) -- and whitespace --
259
+ # remain once every complete top-level block is stripped out. A prompt
260
+ # that is one envelope alone, or several envelopes with no other content,
261
+ # matches; a prompt carrying real work anywhere outside an envelope (before,
262
+ # after, or between several of them) does not.
263
+ def whole_prompt_envelope?(stripped)
264
+ stripped.gsub(ENVELOPE_BLOCK_RE, "").strip.empty?
265
+ end
266
+
267
+ def bare_trigger?(stripped)
268
+ BARE_TRIGGERS.include?(stripped.downcase)
269
+ end
270
+
271
+ def work_marker?(text)
272
+ return true if WORK_MARKER_RE.match?(text)
273
+
274
+ downcased = text.downcase
275
+ WORK_MARKER_PHRASES.any? { |p| downcased.include?(p) }
276
+ end
277
+
278
+ def interrogative?(stripped)
279
+ return true if stripped.end_with?("?")
280
+
281
+ first_word = stripped.split(/\s+/).first.to_s.downcase.gsub(/[^a-z]/, "")
282
+ QUESTION_STARTERS.include?(first_word)
283
+ end
284
+
285
+ def bare_remark?(stripped)
286
+ REMARK_PATTERNS.any? { |re| re.match?(stripped) }
287
+ end
288
+
289
+ # Internal helpers only: #capture_worthy? is the sole public contract
290
+ # (post-execution review item 9).
291
+ private_class_method :whole_prompt_envelope?, :bare_trigger?, :work_marker?, :interrogative?, :bare_remark?
292
+
293
+ # Whether `prompt` earns a pending checklist line (spec D2). Bias is
294
+ # ACCEPT: this rejects only on four named rules -- the 10-char floor (on
295
+ # its own collapsed copy), a whole-prompt harness envelope, a bare
296
+ # continue/auto trigger, and an interrogative or bare-remark prompt
297
+ # carrying no work marker -- and accepts everything else, including a
298
+ # work-shaped question and an envelope followed by real work. Takes the
299
+ # RAW prompt (not the sanitized/truncated line text) so rule 2 sees the
300
+ # prompt's true first character and multi-line shape.
301
+ def capture_worthy?(prompt)
302
+ raw = prompt.to_s
303
+ return false if sanitize_summary(raw).length < 10
304
+
305
+ stripped = raw.strip
306
+ return false if whole_prompt_envelope?(stripped)
307
+ return false if bare_trigger?(stripped)
308
+ return false if !work_marker?(stripped) && (interrogative?(stripped) || bare_remark?(stripped))
309
+
310
+ true
311
+ end
312
+
189
313
  # One LF-terminated checklist line, byte exact per spec D5. The state
190
314
  # marker is fixed width across all three states, which is what lets a later
191
315
  # promote or tick be a one-byte write at a known offset.
@@ -103,7 +103,11 @@ For a live intent's directory:
103
103
  the next thing the stage needs (see the matrix). The newest `## Insights` entry supplies
104
104
  the human-readable context; an entry marked `(autonomous)` means an auto team was
105
105
  delivering it, so say so and offer to hand back to `plastic-auto`.
106
- 5. **Print the intent screen, then continue at that stage.** Run
106
+ 5. **Print the intent screen as the first thing in the reply, then continue at that stage.**
107
+ The screen must open the message with nothing before it. On Claude Code, a fail-open
108
+ `MessageDisplay` hook recognizes a reply that opens this way and substitutes a styled ANSI
109
+ rendering for it there; the transcript and every other harness keep exactly this plain
110
+ form, and nothing about how the screen is printed here ever changes. Run
107
111
  `ruby ~/.plastic/scripts/intent-screen <intent_dir>` and print its output as it is: the
108
112
  title, the field table, and the Steps table come from the record, never by eye. Under it
109
113
  write **What this means** as two to four bullets in plain words (what the intent is for,
@@ -10,13 +10,8 @@
10
10
  | **Next** | {{next}} | {{next.note}} |
11
11
  | **Insight** | {{insight}} | {{insight.note}} |
12
12
 
13
- **What this means**
14
- {{meaning}}
15
-
16
13
  **Steps**
17
14
 
18
15
  | Step | Status | What |
19
16
  | --- | --- | --- |
20
17
  {{steps.rows}}
21
-
22
- {{close}}