@zalom/plastic 2.0.0-alpha.15 → 2.0.0-alpha.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "2.0.0-alpha.15",
3
+ "version": "2.0.0-alpha.17",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,11 +51,18 @@ begin
51
51
  tmp_root = ENV["PLASTIC_TMP"].to_s.empty? ? Dir.tmpdir : ENV["PLASTIC_TMP"]
52
52
  plastic_home = File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
53
53
 
54
+ # PLASTIC_HOOK_TRACE=<file> (331a1): opt-in, off by default, one JSON
55
+ # object appended per chunk. This script is the only place allowed to
56
+ # read the environment; MessageDisplay takes the sink as an argument.
57
+ trace_path = ENV["PLASTIC_HOOK_TRACE"].to_s
58
+ trace = trace_path.empty? ? nil : MessageDisplay.file_trace(trace_path)
59
+
54
60
  handler = MessageDisplay.new(
55
61
  tmp_root: tmp_root,
56
62
  plastic_home: plastic_home,
57
63
  color: color_enabled?(plastic_home),
58
64
  now: Time.now,
65
+ trace: trace,
59
66
  )
60
67
  result = handler.handle(payload)
61
68
 
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "fileutils"
5
+ require "json"
5
6
  require_relative "screen_paint"
6
7
 
7
8
  # MessageDisplay (intent 316a, O4/O5, round 3 concurrency fix) - the Claude
@@ -110,9 +111,13 @@ class MessageDisplay
110
111
  # NOSCREEN (D2), never read by this class as a decision in its own right.
111
112
  PENDING_FILE = "PENDING"
112
113
 
114
+ # `trace` (331a1): an optional callable taking one Hash per chunk. The class
115
+ # stays pure - it never reads PLASTIC_HOOK_TRACE, never opens a file of its
116
+ # own; the CLI reads the variable and injects `file_trace`. nil, the
117
+ # default, costs the common path one `unless` and nothing else.
113
118
  def initialize(tmp_root:, plastic_home:, color:, now:, wait_ms: 300, poll_ms: 20,
114
119
  index_wait_ms: 20, max_wait_ms: 2000,
115
- sleeper: ->(seconds) { sleep(seconds) })
120
+ sleeper: ->(seconds) { sleep(seconds) }, trace: nil)
116
121
  @tmp_root = tmp_root
117
122
  @plastic_home = plastic_home
118
123
  @color = color
@@ -122,6 +127,7 @@ class MessageDisplay
122
127
  @index_wait_ms = index_wait_ms
123
128
  @max_wait_ms = max_wait_ms
124
129
  @sleeper = sleeper
130
+ @trace = trace
125
131
  end
126
132
 
127
133
  def handle(payload)
@@ -141,11 +147,28 @@ class MessageDisplay
141
147
 
142
148
  dir = self.class.buffer_path(tmp_root: @tmp_root, session_id: session_id, message_id: message_id)
143
149
 
144
- if index == 0
145
- handle_chunk_zero(dir, delta, cwd, final)
146
- else
147
- handle_later_chunk(dir, index, delta, final)
148
- end
150
+ @trace_detail = {}
151
+ result =
152
+ if index == 0
153
+ handle_chunk_zero(dir, delta, cwd, final)
154
+ else
155
+ handle_later_chunk(dir, index, delta, final)
156
+ end
157
+
158
+ emit_trace(index, final, result)
159
+ result
160
+ end
161
+
162
+ # One row per chunk, only when a sink was injected. Never raises: a trace
163
+ # is a diagnostic and must not be able to change what the hook returns.
164
+ def emit_trace(index, final, result)
165
+ return unless @trace
166
+
167
+ row = { "index" => index, "final" => final,
168
+ "displayed_bytes" => result.is_a?(String) ? result.bytesize : nil }
169
+ @trace.call(row.merge(@trace_detail.to_h))
170
+ rescue StandardError
171
+ nil
149
172
  end
150
173
 
151
174
  # The message directory both this class and the bash launcher (hooks/
@@ -164,6 +187,17 @@ class MessageDisplay
164
187
  File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), SCREEN_FILE)
165
188
  end
166
189
 
190
+ # A trace sink that appends one JSON object per chunk to `path`. Every
191
+ # failure is swallowed: an unwritable path must never turn a diagnostic
192
+ # into a broken hook (D5, fail open).
193
+ def self.file_trace(path)
194
+ lambda do |row|
195
+ File.open(path, "a") { |f| f.puts(JSON.generate(row)) }
196
+ rescue StandardError
197
+ nil
198
+ end
199
+ end
200
+
167
201
  def self.noscreen_path(tmp_root:, session_id:, message_id:)
168
202
  File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), NOSCREEN_FILE)
169
203
  end
@@ -186,10 +220,12 @@ class MessageDisplay
186
220
  def handle_chunk_zero(dir, delta, _cwd, final)
187
221
  split = split_at_opener(delta)
188
222
  unless split
223
+ @trace_detail["decision"] = "noscreen"
189
224
  write_noscreen(dir)
190
225
  return nil
191
226
  end
192
227
 
228
+ @trace_detail["decision"] = "engage"
193
229
  engage(dir, 0, split, final)
194
230
  end
195
231
 
@@ -199,10 +235,25 @@ class MessageDisplay
199
235
  # is already on disk. Only once its own delta carries no opener does it
200
236
  # fall back to the original decision-driven wait.
201
237
  def handle_later_chunk(dir, index, delta, final)
238
+ # 331a1: an opener engages only a message that is NOT already a screen.
239
+ # 331a's late engagement exists for the prose-first reply, where chunk 0
240
+ # wrote NOSCREEN and a later chunk carries the title; it must not fire
241
+ # again once SCREEN is on disk. A reply can hold several screens back to
242
+ # back - the roster is a table then ten cards, the session report is many
243
+ # delivered screens - and re-engaging on each one returned that chunk's
244
+ # own prefix as raw Markdown and rewrote the start index, so the final
245
+ # chunk spliced from the LAST opener and everything before it reached the
246
+ # terminal unpainted. Inside an engaged message a later opener is simply
247
+ # content: it is buffered like any other line and the painter, which
248
+ # already understands a run of screens, lays all of them out.
202
249
  split = split_at_opener(delta)
203
- return engage(dir, index, split, final) if split
250
+ if split && !engaged?(dir)
251
+ @trace_detail["decision"] = "engage"
252
+ return engage(dir, index, split, final)
253
+ end
204
254
 
205
255
  decision = wait_for_decision(dir, gate_delta: final ? nil : delta, index: index)
256
+ @trace_detail["decision"] = decision.to_s
206
257
 
207
258
  return nil unless decision == :screen
208
259
 
@@ -210,6 +261,10 @@ class MessageDisplay
210
261
  final ? finalize_final(dir, index) : ""
211
262
  end
212
263
 
264
+ def engaged?(dir)
265
+ File.exist?(File.join(dir, SCREEN_FILE))
266
+ end
267
+
213
268
  # Engages the message starting at THIS chunk (whatever its index): writes
214
269
  # SCREEN carrying this chunk's index (replacing any NOSCREEN, D2/D6),
215
270
  # buffers the opener onward at this chunk's own index, and returns the
@@ -426,6 +481,7 @@ class MessageDisplay
426
481
 
427
482
  region_stop = ScreenPaint.region_end(lines, start)
428
483
  painted = ScreenPaint.paint(lines[start...region_stop].join, color: true, markdown_safe: true)
484
+ trace_region(buffered, lines, start, region_stop, painted)
429
485
  return buffered unless painted
430
486
 
431
487
  # 331a (D4): a lone CLOSING fence immediately after the painted region is
@@ -445,6 +501,26 @@ class MessageDisplay
445
501
  out
446
502
  end
447
503
 
504
+ # What the final chunk actually painted, for the opt-in trace: how much was
505
+ # buffered, where the region ran, and the first line the grammar rejected -
506
+ # the one fact a terminal capture cannot give you. Computed only when a
507
+ # sink was injected, so an ordinary run pays nothing.
508
+ def trace_region(buffered, lines, start, region_stop, painted)
509
+ return unless @trace
510
+
511
+ rejected = ScreenPaint.first_rejected(lines, start)
512
+ @trace_detail.merge!(
513
+ "buffered_bytes" => buffered.bytesize,
514
+ "buffered_lines" => lines.length,
515
+ "region_start" => start,
516
+ "region_stop" => region_stop,
517
+ "painted" => !painted.nil?,
518
+ "first_rejected_line" => rejected && { "index" => rejected[0], "text" => rejected[1][0, 200] },
519
+ )
520
+ rescue StandardError
521
+ nil
522
+ end
523
+
448
524
  def write_chunk(dir, index, delta)
449
525
  atomic_write(File.join(dir, index.to_s), delta)
450
526
  end
@@ -577,30 +577,69 @@ module ReportScreen
577
577
  rows.compact
578
578
  end
579
579
 
580
- # Intent 331b (plan.md, "The one non-additive edit"): the standalone-token
581
- # rule, extracted so `action_file_for` (the plan screen's Action column)
582
- # calls the exact same rule as `matching_action_heading` and the two can
583
- # never drift on what counts as a match. `matching_action_heading`'s own
584
- # signature, return shape and behavior are unchanged (row P16).
585
- def self.heading_tokens(heading)
586
- heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/)
587
- end
588
-
589
- # Rows 25-27: D19 - the label must appear as a standalone token in an action
590
- # file heading (any level); the count is the matched section's table rows only.
591
- def self.matching_action_heading(intent_dir, label)
580
+ # Intent 331b (plan.md, "The one non-additive edit"): the standalone-token
581
+ # rule, extracted so `action_file_for` (the plan screen's Action column)
582
+ # calls the exact same rule as `matching_action_heading` and the two can
583
+ # never drift on what counts as a match.
584
+ def self.heading_tokens(heading)
585
+ heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/)
586
+ end
587
+
588
+ # Rows 25-27: D19/D1r - the label must appear as a standalone token in an
589
+ # action file heading (any level), AND that heading must own at least one
590
+ # matrix data row - a heading that only names the label, with no table
591
+ # beneath it (or a table with a separator but no data row), is skipped and
592
+ # the walk keeps going. Lexicographic path order (D8), then file order.
593
+ #
594
+ # Merge note (322 into alpha, 2026-09-05): 322's table-owning rule and 331b's
595
+ # extracted `heading_tokens` are both kept. The token split now comes from the
596
+ # shared helper so `action_file_for` cannot drift from this walk, while the
597
+ # `table_rows(body).any?` guard stays the thing that decides the match.
598
+ def self.matching_action_heading(intent_dir, label)
599
+ Dir.glob(File.join(intent_dir, "actions", "*.md")).sort.each do |path|
600
+ split_by_headings(File.read(path)).each do |heading, body|
601
+ next unless heading_tokens(heading).include?(label)
602
+ return [heading, body] if table_rows(body).any?
603
+ end
604
+ end
605
+ [nil, nil]
606
+ end
607
+
608
+ # D3r: the row-cell fallback, for the shape where the label never appears in
609
+ # a heading at all, only as the first cell of a matrix data row. Restricted
610
+ # to tables under a heading that names itself a matrix (/matrix/i) - never a
611
+ # step list or any other table - so it cannot answer for a record that has
612
+ # no matrix anywhere (the close-gate defeat the plan review measured).
613
+ # Emphasis (bold/italic/code) is stripped from the compared cell; the count
614
+ # sums matching rows across every matrix heading, in every action file.
615
+ def self.matching_matrix_rows(intent_dir, label)
616
+ count = 0
592
617
  Dir.glob(File.join(intent_dir, "actions", "*.md")).sort.each do |path|
593
618
  split_by_headings(File.read(path)).each do |heading, body|
594
- return [heading, body] if heading_tokens(heading).include?(label)
619
+ next unless heading.to_s.match?(/matrix/i)
620
+ table_rows(body).each do |cells|
621
+ cell = cells[0].to_s.gsub(/[*_`]/, "").strip
622
+ count += 1 if cell == label
623
+ end
595
624
  end
596
625
  end
597
- [nil, nil]
626
+ count
598
627
  end
599
628
 
629
+ # D7: a label with no letter never resolves, on either path - it is a
630
+ # bullet-derived Delivered number (delivered_rows), never a label anyone
631
+ # wrote, and would otherwise fabricate proof from a numbered heading like
632
+ # "## 1. What this intent is" or from a numbered matrix row-cell column.
600
633
  def self.proven_by(intent_dir, label)
634
+ return NOT_RECORDED unless label.to_s.match?(/[A-Za-z]/)
635
+
601
636
  _heading, body = matching_action_heading(intent_dir, label)
602
- return NOT_RECORDED unless body
603
- n = table_rows(body).length
637
+ if body
638
+ n = table_rows(body).length
639
+ return n.positive? ? "#{n} test#{n == 1 ? '' : 's'}" : NOT_RECORDED
640
+ end
641
+
642
+ n = matching_matrix_rows(intent_dir, label)
604
643
  n.positive? ? "#{n} test#{n == 1 ? '' : 's'}" : NOT_RECORDED
605
644
  end
606
645
 
@@ -38,6 +38,19 @@ module ScreenPaint
38
38
  STEP_LINE_RE = /\A(S\d+)\s+\[ (open|done) \]\s+(.*)\z/.freeze
39
39
  TIMELINE_RE = /\A(\d\d:\d\d)\s{2}(\S+)\s{2}(.*)\z/.freeze
40
40
  COUNT_LINE_RE = /\A\d+ open( · .*)?\z/.freeze
41
+ # 331a1: the session verb prints this note between the delivered screens and
42
+ # the roster (report_screen.rb, render_session). It is our own output, so the
43
+ # painter must not reject it - the live hook trace caught the region stopping
44
+ # dead on it, with the whole roster below reaching the terminal as plain
45
+ # Markdown. Pinned to the exact shape rather than "any sentence": region_end
46
+ # exists to stop at the model's own prose, and a loose rule would swallow it.
47
+ SKIP_NOTE_RE = /\A\d+ completed intents? skipped: .+\z/.freeze
48
+ # 331a2 (D5, S3): render_session's own rescue card when a directory's delivered screen
49
+ # raises (report_screen.rb:1466-1468) - our own output, not model prose. The census (331a2)
50
+ # proved it classified :unknown, orphaning the roster below it in a real reply. `:meta`, not
51
+ # a new kind: it is a one-line grey note under a title and paint's `:meta` arm reads only
52
+ # `line.strip`, with no positional dependency.
53
+ RESCUE_CARD_RE = /\A## \S+ · could not render \(.*\)\z/.freeze
41
54
  BOLD_LEAD_RE = /\A\*\*([^*]+)\*\*(.*)\z/.freeze
42
55
 
43
56
  # Intent 317a1 (D3, D4, D5): the data-table palette. Kind and note columns
@@ -195,11 +208,12 @@ module ScreenPaint
195
208
  return :table if text.lstrip.start_with?("|")
196
209
  return :bold if BOLD_LEAD_RE.match?(stripped) && text == stripped
197
210
  return :meta if idx && opener_idx && idx == opener_idx + 1 && stripped.include?(" · ")
211
+ return :meta if RESCUE_CARD_RE.match?(stripped)
198
212
  return :indented if text.start_with?(" ")
199
213
  return :field if FIELD_LINE_RE.match?(text)
200
214
  return :step if STEP_LINE_RE.match?(text)
201
215
  return :timeline if TIMELINE_RE.match?(text)
202
- return :count if COUNT_LINE_RE.match?(stripped)
216
+ return :count if COUNT_LINE_RE.match?(stripped) || SKIP_NOTE_RE.match?(stripped)
203
217
  return :closer if ["None", "not recorded", "No intents in delivery.", "No intents delivered in this session."].include?(stripped)
204
218
  :unknown
205
219
  end
@@ -207,11 +221,22 @@ module ScreenPaint
207
221
  # Where the screen region ends inside a larger message (B10): walk from the
208
222
  # opener while every line classifies; the first unknown line - ordinary
209
223
  # prose, a prose bullet - is the boundary. Never consumes past the screen.
224
+ #
225
+ # 331a1: `opener_idx` tracks the NEAREST preceding opener, not the first
226
+ # one in the message. A reply can carry several screens back to back - the
227
+ # roster is a table then ten cards, the session report is many delivered
228
+ # screens - and `classify`'s positional rule is "the line right after a
229
+ # title", which means the title above it. Testing every line against the
230
+ # message's first opener made the timestamp under the SECOND screen
231
+ # :unknown, and the region stopped there: 30 lines of a 350-line session
232
+ # report painted, the rest reaching the terminal as plain Markdown.
210
233
  def region_end(lines, start_idx)
211
234
  i = start_idx + 1
235
+ opener_idx = start_idx
212
236
  while i < lines.length
213
- kind = classify(lines[i], idx: i, opener_idx: start_idx)
237
+ kind = classify(lines[i], idx: i, opener_idx: opener_idx)
214
238
  break if kind == :unknown
239
+ opener_idx = i if kind == :opener
215
240
  # A bare "**Section**" head belongs to the screen only when what follows
216
241
  # is still grammar; "**What this means**" over prose bullets is the
217
242
  # model's own commentary and stays outside, unsplit (B10).
@@ -225,6 +250,22 @@ module ScreenPaint
225
250
  i
226
251
  end
227
252
 
253
+ # The first line the grammar rejects, walking from the opener under the same
254
+ # nearest-opener rule region_end uses. Returns [index, line] or nil when the
255
+ # whole run classifies. 331a1: this is what the opt-in hook trace reports,
256
+ # so a live run says which line stopped the region instead of leaving it to
257
+ # be guessed from a terminal capture.
258
+ def first_rejected(lines, start_idx)
259
+ opener_idx = start_idx
260
+ ((start_idx + 1)...lines.length).each do |i|
261
+ kind = classify(lines[i], idx: i, opener_idx: opener_idx)
262
+ return [i, lines[i].to_s.rstrip] if kind == :unknown
263
+
264
+ opener_idx = i if kind == :opener
265
+ end
266
+ nil
267
+ end
268
+
228
269
  def bare_bold?(line)
229
270
  m = BOLD_LEAD_RE.match(line.strip)
230
271
  m && m[2].to_s.strip.empty?
@@ -271,8 +312,14 @@ module ScreenPaint
271
312
  table.clear
272
313
  end
273
314
 
315
+ # 331a1: the same nearest-opener rule region_end uses - a reply carrying
316
+ # several screens must classify each one's meta line against its own
317
+ # title, not against the first title in the message.
318
+ opener_idx = first_idx
319
+
274
320
  lines.each_with_index do |line, idx|
275
- kind = classify(line, idx: idx, opener_idx: first_idx)
321
+ kind = classify(line, idx: idx, opener_idx: opener_idx)
322
+ opener_idx = idx if kind == :opener
276
323
  # Intent 317a1 (O6, D8): the run counter tracks consecutive :indented
277
324
  # lines so only the SECOND and later lines under a heading like
278
325
  # "**Asked**" grey as a note; a :table line (which `next`s below) must
@@ -67,9 +67,10 @@ fill `## Summary`, `## Delivered`, `## Verification`, `## Follow-ups`. `## Deliv
67
67
  `| Row | What |` table: one row per thing delivered, in plain wording a reader
68
68
  recognizes, not a method name or an implementation summary (that detail
69
69
  belongs in `## Summary`). Each row's label must appear as a standalone token
70
- in an action-file heading (`### S1 - ...` proves row S1); that heading's
71
- matrix rows become the row's Proven-by cell on `report-screen delivered`'s
72
- post-delivery screen. `## Needs you` is the literal None or a
70
+ in an action-file heading that owns the matrix table (`### S1 - ...` with a
71
+ table beneath it proves row S1; a table-less heading naming the label is
72
+ skipped); that heading's matrix rows become the row's Proven-by cell on
73
+ `report-screen delivered`'s post-delivery screen. `## Needs you` is the literal None or a
73
74
  `| N | What | Why |` table. On abandon, `## Summary` states the abandonment reason and the trail (see Pivot
74
75
  below). A placeholder outcome.md is backfilled from the record instead, with the
75
76
  close's disposition and the `--outcome-summary` line as its summary. Also author
@@ -75,7 +75,7 @@ Apply the auto skill's risk rule to the executor's return and the diff: a matrix
75
75
 
76
76
  Whenever a review verdict returns - the plan review before code, or the post-execution review above - the lead appends a `Review` line: `ruby ~/.plastic/scripts/savepoint-note <intent_dir> --kind Review --text "<verdict, what changed>"` (intent 317, D17). This is the other half of what `report-screen delay` reads.
77
77
 
78
- **The D19 heading convention.** An action file's `## Delivered` row (in `outcome.md`) is proven by whichever `actions/ACTION_N.md` heading carries that row's label as a standalone token - `### Row A -` proves row A, `### S1 -` proves row S1. Write action-file section headings so the label they prove is unambiguous (never a substring another label could also match, like `A` inside `AB`); `report-screen delivered`'s Proven-by column renders `not recorded` when no heading matches.
78
+ **The D19 heading convention.** An action file's `## Delivered` row (in `outcome.md`) is proven by the first `actions/ACTION_N.md` heading that carries that row's label as a standalone token AND owns the matrix table (322 D1r) - `### Row A -` with a table beneath it proves row A, `### S1 -` proves row S1; a heading that only names the label, with no table under it, is skipped. Write action-file section headings so the label they prove is unambiguous (never a substring another label could also match, like `A` inside `AB`); `report-screen delivered`'s Proven-by column renders `not recorded` when no heading owns a matching table and no matrix row cell carries the label either.
79
79
 
80
80
  ### Step 4: Update Intent and Complete
81
81
  Capture observations in `## Insights`. When ALL checklist items are checked:
@@ -10,8 +10,11 @@ disposition: delivered|abandoned
10
10
  <!-- One row per thing delivered, in plain wording a reader recognizes, not
11
11
  an implementation summary; the technical detail belongs in ## Summary. Each
12
12
  row's label must appear as a standalone token in an actions/*.md heading
13
- (for example "### S1 - ..." proves row S1): that heading's matrix rows become
14
- the row's Proven-by cell on the delivered screen (intent 317 D19, 317a). -->
13
+ that owns the matrix table (for example "### S1 - ..." with a table beneath
14
+ it proves row S1); that heading's matrix rows become the row's Proven-by
15
+ cell on the delivered screen (intent 317 D19, 317a, 322 D1r). A label with no
16
+ owning heading falls back to a matrix row cell that carries it, when one
17
+ under a heading named "matrix" exists (322 D3r). -->
15
18
  | Row | What |
16
19
  | --- | --- |
17
20
  | S1 | ... |