@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.
- package/hooks/hooks.json +12 -0
- package/hooks/message-display +71 -0
- package/package.json +1 -1
- package/scripts/dashboard.rb +39 -10
- package/scripts/doctor.rb +163 -0
- package/scripts/hook-capture +19 -7
- package/scripts/hook-message-display +65 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-session-start +23 -1
- package/scripts/intent-screen +25 -4
- package/scripts/lib/doctor_core.rb +4 -3
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/hook_registry.rb +12 -0
- package/scripts/lib/installer_core.rb +26 -1
- package/scripts/lib/intent_screen.rb +79 -17
- package/scripts/lib/intent_screen_ansi.rb +190 -0
- package/scripts/lib/message_display.rb +371 -0
- package/scripts/lib/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +124 -0
- package/skills/intent-continuing/SKILL.md +5 -1
- package/templates/intent-screen.md +0 -5
|
@@ -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`,
|
|
211
|
-
#
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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.**
|
|
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,
|