@zalom/plastic 2.0.0-alpha.7 → 2.0.0-alpha.9

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.
@@ -49,8 +49,16 @@ if [ "$is_index_zero" = 1 ]; then
49
49
  # single "#", not "##" — a real screen's own first delta can be as short
50
50
  # as "## " — a "##" glob would filter out exactly the message this hook
51
51
  # exists to recognize.
52
+ # 317a (B11): a screen can open with "## " (state, delivered), a bare "▶"
53
+ # (roster), or a bare "✔" (delay); the JSON may carry the glyph raw or
54
+ # \u-escaped depending on the encoder. All globs are literal bytes - case
55
+ # matching is byte-wise, so multibyte literals are safe under bash 3.2.
52
56
  case $INPUT in
53
57
  *'"delta":"#'*|*'"delta": "#'*) handoff=1 ;;
58
+ *'"delta":"▶'*|*'"delta": "▶'*) handoff=1 ;;
59
+ *'"delta":"✔'*|*'"delta": "✔'*) handoff=1 ;;
60
+ *'"delta":"▶'*|*'"delta": "▶'*) handoff=1 ;;
61
+ *'"delta":"✔'*|*'"delta": "✔'*) handoff=1 ;;
54
62
  esac
55
63
  else
56
64
  # A later chunk: hand off when this message's directory already exists
@@ -62,7 +70,7 @@ else
62
70
  [ "$is_final" = 1 ] && handoff=1
63
71
  case $INPUT in
64
72
  *'"delta":"|'*|*'"delta": "|'*) handoff=1 ;;
65
- *'"delta":"**Steps**'*|*'"delta": "**Steps**'*) handoff=1 ;;
73
+ *'"delta":"**'*|*'"delta": "**'*) handoff=1 ;;
66
74
  *'"delta":""'*|*'"delta": ""'*) handoff=1 ;;
67
75
  *'"delta":"\n"'*|*'"delta": "\n"'*) handoff=1 ;;
68
76
  esac
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "2.0.0-alpha.7",
3
+ "version": "2.0.0-alpha.9",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,8 @@
22
22
  # Exit codes: 0 (report emitted), 2 (usage).
23
23
 
24
24
  require_relative "lib/savepoint"
25
+ require_relative "lib/intent_screen"
26
+
25
27
  def parse_args(argv)
26
28
  role = nil
27
29
  positional = []
@@ -67,11 +69,15 @@ STAGE_LABELS = {
67
69
  "exec" => "Exec", "done" => "Done"
68
70
  }.freeze
69
71
 
72
+ # Intent 317, D6: name the last LIFECYCLE line, never a trailing Lock/Review/
73
+ # Commit line (same guard as spawn-preamble; plan review finding B1).
70
74
  def current_stage(intent_dir)
71
75
  ledger = File.join(intent_dir, Savepoint::SAVEPOINT_FILE)
72
76
  if File.exist?(ledger)
73
- last = File.read(ledger).each_line.map(&:strip).reject(&:empty?).last
74
- return last if last
77
+ lines = File.read(ledger).each_line.map(&:strip).reject(&:empty?)
78
+ lifecycle = lines.select { |l| IntentScreen.lifecycle_line?(l) }
79
+ return lifecycle.last if lifecycle.any?
80
+ return lines.last if lines.any?
75
81
  end
76
82
  STAGE_LABELS.fetch(Savepoint.derive_stage(intent_dir), Savepoint.derive_stage(intent_dir))
77
83
  end
@@ -66,6 +66,8 @@
66
66
  # refused before removal; pass --discard-worktree-changes to override
67
67
  # deliberately.
68
68
  # 6 retired in 2.0 (intent 308): the structure check reports and proceeds.
69
+ # 7 hollow delivered report refused (317a D7) - intent 308's one deliberate
70
+ # exception to report-and-proceed; --allow-hollow-report overrides.
69
71
 
70
72
  require "fileutils"
71
73
  require "date"
@@ -77,6 +79,7 @@ require_relative "lib/savepoint"
77
79
  require_relative "lib/lock"
78
80
  require_relative "lib/intent_validator"
79
81
  require_relative "lib/outcome_guard"
82
+ require_relative "lib/report_screen"
80
83
  require_relative "lib/backfill_intent"
81
84
 
82
85
  DISPOSITIONS = %w[delivered abandoned].freeze
@@ -99,6 +102,7 @@ def parse_args(argv)
99
102
  opts = {
100
103
  store: nil, id: nil, disposition: nil, index: nil,
101
104
  outcome_summary: nil, index_note: nil, no_commit: false, dry_run: false,
105
+ allow_hollow_report: false,
102
106
  session: nil, discard_worktree_changes: false,
103
107
  }
104
108
  i = 0
@@ -115,6 +119,7 @@ def parse_args(argv)
115
119
  when "--discard-worktree-changes" then opts[:discard_worktree_changes] = true
116
120
  when "--no-commit" then opts[:no_commit] = true
117
121
  when "--dry-run" then opts[:dry_run] = true
122
+ when "--allow-hollow-report" then opts[:allow_hollow_report] = true
118
123
  else
119
124
  usage_abort("unknown argument #{arg.inspect}")
120
125
  end
@@ -215,7 +220,75 @@ end
215
220
  # Replace the body of the intent file's `## Outcome` section with `summary`,
216
221
  # leaving every other section and the frontmatter untouched. No-op (returns
217
222
  # content unchanged) when summary is blank or the section is not found.
223
+ # 317a S9 (D7): the self-render gate. Renders the delivered screen's sources on
224
+ # the intent's own record and names what would come out hollow. This is intent
225
+ # 308's ONE deliberate exception to report-and-proceed: a hollow DELIVERED
226
+ # close is refused with exit 7 (--allow-hollow-report overrides), because 317
227
+ # closed exactly this way and the owner read a screen full of "not recorded".
228
+ # Abandoned closes and machine-backfilled outcomes (the backfill marker) are
229
+ # exempt: the reader cannot expect labels the writer never had.
230
+ def hollow_report_reason(intent_dir, disposition)
231
+ return nil unless disposition == "delivered"
232
+
233
+ outcome = File.join(intent_dir, "outcome.md")
234
+ return nil unless File.exist?(outcome)
235
+ text = File.read(outcome)
236
+ return nil if text.include?("<!-- backfilled from the record by end-intent on ")
237
+
238
+ # The gate's evidence bar (A7/B7): it judges only records that CLAIM the
239
+ # modern convention - at least one actions/*.md heading carrying an S-label.
240
+ # A terse legacy close with no labeled matrix stays report-and-proceed.
241
+ heading_labels = Dir.glob(File.join(intent_dir, "actions", "*.md")).sort.flat_map do |path|
242
+ ReportScreen.split_by_headings(File.read(path)).filter_map do |heading, _body|
243
+ heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/).find { |tok| tok.match?(/\AS\d+\z/) }
244
+ end
245
+ end.uniq
246
+ return nil if heading_labels.empty?
247
+
248
+ rows = ReportScreen.delivered_rows(intent_dir)
249
+ return "outcome.md ## Delivered renders no rows although a labeled action matrix exists" if rows.empty?
250
+
251
+ needs = ReportScreen.section_of(text, "## Needs you").gsub(/<!--.*?-->/m, "").strip
252
+ if !needs.empty? && needs != "None" && ReportScreen.needs_you_rows(intent_dir).empty?
253
+ return "## Needs you has content that would render as None"
254
+ end
255
+
256
+ labels = rows.map { |r| r[:label] }
257
+ if (labels & heading_labels).empty?
258
+ return "delivered row labels (#{labels.first(3).join(', ')}...) match no action-file heading (#{heading_labels.first(3).join(', ')}...)"
259
+ end
260
+ if rows.all? { |r| ReportScreen.proven_by(intent_dir, r[:label]) == ReportScreen::NOT_RECORDED }
261
+ return "every Proven-by cell renders not recorded although labeled action headings exist"
262
+ end
263
+
264
+ nil
265
+ rescue StandardError => e
266
+ warn "end-intent: hollow-report gate crashed (#{e.message}); proceeding without it"
267
+ nil
268
+ end
269
+
270
+ # 317a S7 (D5, A8): stamp the live lock's run_mode into outcome.md frontmatter
271
+ # INSIDE the close step - after the gate, before the INDEX move and the store
272
+ # commit - so the stamped file rides the close commit and mode survives disarm.
273
+ def stamp_outcome_mode(intent_dir)
274
+ data = Lock.read(intent_dir)
275
+ run_mode = data && data["run_mode"].to_s
276
+ return if run_mode.nil? || run_mode.empty?
277
+
278
+ path = File.join(intent_dir, "outcome.md")
279
+ return unless File.exist?(path)
280
+ text = File.read(path)
281
+ return unless text.start_with?("---")
282
+ head, body = text[3..].split("---", 2)
283
+ return if body.nil? || head.match?(/^mode:/)
284
+
285
+ File.write(path, "---#{head}mode: #{run_mode}\n---#{body}")
286
+ rescue StandardError => e
287
+ warn "end-intent: mode stamp failed (#{e.message}); proceeding without it"
288
+ end
289
+
218
290
  def stamp_outcome_summary(content, summary)
291
+
219
292
  return content if summary.nil? || summary.to_s.strip.empty?
220
293
 
221
294
  lines = content.lines
@@ -654,6 +727,26 @@ def main(argv)
654
727
  reason = OutcomeGuard.reason(intent_dir, disposition)
655
728
  warn "end-intent: outcome.md: #{reason} (proceeding; doctor --intent #{id} reports it)" if reason
656
729
 
730
+ # 1a2. The self-render gate (317a S9, D7): refuse a hollow delivered close
731
+ # BEFORE any terminal mutation - INDEX, savepoint, and store stay untouched
732
+ # on exit 7 (A9).
733
+ hollow = hollow_report_reason(intent_dir, disposition)
734
+ if hollow
735
+ if opts[:allow_hollow_report]
736
+ warn "end-intent: hollow delivered report overridden (--allow-hollow-report): #{hollow}"
737
+ else
738
+ warn "end-intent: refusing a hollow delivered close: #{hollow}"
739
+ warn "end-intent: write outcome.md's ## Delivered as the labeled | Row | What | table " \
740
+ "matching the action-file headings (templates/outcome.md shows the shape), " \
741
+ "or pass --allow-hollow-report to close anyway"
742
+ exit 7
743
+ end
744
+ end
745
+
746
+ # 1a3. Mode stamp (317a S7, D5): run_mode into outcome frontmatter while the
747
+ # lock still exists, so the stamped file rides the close commit.
748
+ stamp_outcome_mode(intent_dir)
749
+
657
750
  # 1b. Intent-file `## Outcome` summary stamp (no-op unless given).
658
751
  if opts[:outcome_summary]
659
752
  intent_file = Savepoint.intent_file(intent_dir)
@@ -15,11 +15,18 @@
15
15
  #
16
16
  # Claude adapter: Claude Code only; the core is harness-agnostic.
17
17
 
18
- require "json"
19
- require "time"
20
- require "tmpdir"
21
- require "yaml"
22
- require_relative "lib/message_display"
18
+ # 317a (B1): the requires sit inside their own rescue - a missing or broken
19
+ # lib file must never surface as a non-zero exit or stderr on a chunk of an
20
+ # ordinary message (LoadError is a ScriptError, outside StandardError).
21
+ begin
22
+ require "json"
23
+ require "time"
24
+ require "tmpdir"
25
+ require "yaml"
26
+ require_relative "lib/message_display"
27
+ rescue ScriptError, StandardError
28
+ exit 0
29
+ end
23
30
 
24
31
  def color_enabled?(plastic_home)
25
32
  return false unless ENV["NO_COLOR"].to_s.empty?
@@ -12,6 +12,7 @@ require "time"
12
12
  require_relative "session_ledger"
13
13
  require_relative "handoff"
14
14
  require_relative "lock"
15
+ require_relative "report_screen"
15
16
 
16
17
  module DaySummary
17
18
  module_function
@@ -113,8 +114,12 @@ module DaySummary
113
114
  dir = File.join(store_dir, dirname)
114
115
  next unless File.directory?(dir) && Lock.fresh?(dir, now: now)
115
116
  # A guided session's lock is live but not autonomous; a lock with no
116
- # run_mode (a 1.14 auto team) counts as auto.
117
- next if (Lock.read(dir) || {})["run_mode"].to_s == "guided"
117
+ # run_mode (a 1.14 auto team) counts as auto. 317a (B9): when the lock
118
+ # carries no run_mode, the outcome frontmatter stamp (D5) is asked
119
+ # before defaulting, so a guided close never reads as auto.
120
+ run_mode = (Lock.read(dir) || {})["run_mode"].to_s
121
+ run_mode = ReportScreen.outcome_frontmatter(dir)["mode"].to_s if run_mode.empty?
122
+ next if run_mode == "guided"
118
123
 
119
124
  id, slug = dirname.split("--", 2)
120
125
  "- #{id} #{slug}: #{last_savepoint_line(dir)}"
@@ -400,6 +400,9 @@ class InstallerCore
400
400
  "scripts/lib/harness_text.rb" => "scripts/lib/harness_text.rb",
401
401
  "scripts/codex-hook" => "scripts/codex-hook",
402
402
  "scripts/spawn-preamble" => "scripts/spawn-preamble",
403
+ "scripts/lib/report_screen.rb" => "scripts/lib/report_screen.rb",
404
+ "scripts/report-screen" => "scripts/report-screen",
405
+ "scripts/savepoint-note" => "scripts/savepoint-note",
403
406
  "scripts/lib/store_provisioning.rb" => "scripts/lib/store_provisioning.rb",
404
407
  "scripts/provision-project-store" => "scripts/provision-project-store",
405
408
  "scripts/lib/project_validator.rb" => "scripts/lib/project_validator.rb",
@@ -449,6 +452,7 @@ class InstallerCore
449
452
  # entries are their only protection (test/install_sync_test.rb:23-29
450
453
  # greps installer_core.rb's own source text for "scripts/<name>").
451
454
  "scripts/lib/intent_screen_ansi.rb" => "scripts/lib/intent_screen_ansi.rb",
455
+ "scripts/lib/screen_paint.rb" => "scripts/lib/screen_paint.rb",
452
456
  "scripts/lib/message_display.rb" => "scripts/lib/message_display.rb",
453
457
  "scripts/hook-message-display" => "scripts/hook-message-display",
454
458
  }
@@ -24,10 +24,26 @@ module IntentScreen
24
24
  # Em dash and en dash added (intent 316a O1e): a checklist item written
25
25
  # "S1 — text" (the em dash every checklist this intent writes, and the one a
26
26
  # reviewer reads, uses) kept its prefix under the old character class and
27
- # rendered "S1 [ open ] S1 — text" on screen.
28
- STEP_PREFIX_RE = /\A(?:Step|S)\s*\d+\s*[-:·—–]\s*/i
27
+ # rendered "S1 [ open ] S1 — text" on screen. Intent 317a (A1) made the
28
+ # separator optional with required whitespace: "S1 text" (the shape real
29
+ # checklists use) kept its label and doubled in next_fields; a bare "S1"
30
+ # has no following text and stays prose.
31
+ STEP_PREFIX_RE = /\A(?:Step|S)\s*\d+\s*(?:[-:·—–]\s*|\s+)(?=\S)/i
29
32
  INSIGHT_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s+·\s+\S+\s+·\s+.+?\s+—\s+(.+)\z/
30
33
  SAVEPOINT_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s{2,}(\S+)\s{2,}(.+?)\s*\z/
34
+ # Intent 317, D6: field-2 tokens that are genuine lifecycle stages. A ledger
35
+ # can also carry non-lifecycle lines (`Lock takeover: ...`, and 317's own
36
+ # `Review`/`Commit`); those must never be mistaken for the current stage.
37
+ # Placed here, clear of STEP_PREFIX_RE above (316a edits that one).
38
+ LIFECYCLE_STAGES = %w[What Why How Exec Done].freeze
39
+
40
+ # True iff a raw savepoint ledger line's field 2 is a genuine lifecycle
41
+ # stage (post-execution-review finding 5: centralizes what spawn-preamble
42
+ # and agent-report used to each copy-paste beside the constant above).
43
+ def self.lifecycle_line?(line)
44
+ parts = line.to_s.split(/\s{2,}/)
45
+ parts.length >= 2 && LIFECYCLE_STAGES.include?(parts[1])
46
+ end
31
47
 
32
48
  # Word-boundary truncation caps (intent 316a D3/O1a/O1c). Never a clause
33
49
  # trim: a clause trim on step text destroys a pinned `OPEN:` row
@@ -126,14 +142,22 @@ module IntentScreen
126
142
  def self.savepoint_fields(intent_dir, intent_text)
127
143
  path = File.join(intent_dir, "savepoint.md")
128
144
  lines = File.exist?(path) ? File.readlines(path).map(&:strip).reject(&:empty?) : []
129
- last = lines.reverse.map { |l| l.match(SAVEPOINT_RE) }.compact.first
145
+ matched = lines.reverse.map { |l| l.match(SAVEPOINT_RE) }.compact
146
+ last = matched.first
130
147
  unless last
131
148
  return { "stage" => "Why", "stage.note" => "no savepoint line yet",
132
149
  "savepoint" => "none", "savepoint.note" => "" }
133
150
  end
134
151
 
152
+ # D6: the STAGE PICK is guarded to the last LIFECYCLE line (What/Why/How/
153
+ # Exec/Done), so a trailing Lock/Review/Commit line cannot be mistaken for
154
+ # the stage. The Savepoint field below still shows the TRUE last line,
155
+ # whatever its kind - that is what a savepoint is.
156
+ lifecycle_last = matched.find { |m| LIFECYCLE_STAGES.include?(m[2]) }
157
+ stage_source = lifecycle_last || last
158
+
135
159
  ts, stage, milestone = last[1], last[2], last[3]
136
- landing = landing_stage(stage, milestone)
160
+ landing = landing_stage(stage_source[2], stage_source[3])
137
161
  delivered = lines.map { |l| l.match(SAVEPOINT_RE) }.compact.map { |m| m[2] }.uniq
138
162
  delivered &= %w[What Why How Exec]
139
163
  note = if landing == "Done"
@@ -2,10 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "fileutils"
5
- require_relative "intent_screen"
6
- require_relative "intent_screen_ansi"
7
- require_relative "store_discovery"
8
- require_relative "store_provisioning"
5
+ require_relative "screen_paint"
9
6
 
10
7
  # MessageDisplay (intent 316a, O4/O5, round 3 concurrency fix) - the Claude
11
8
  # Code MessageDisplay hook handler. One process per streamed chunk of every
@@ -50,7 +47,10 @@ require_relative "store_provisioning"
50
47
  # buffered original, never nil, never "") and D12 (color: false never
51
48
  # buffers or blanks anything) are unchanged.
52
49
  class MessageDisplay
53
- MARKER_RE = /\A## ▶ (\S+) · /.freeze
50
+ # 317a (A4): engagement is grammar, not identity - any screen-family
51
+ # opener engages, with NO intent-id resolution (the roster and delay
52
+ # screens have none to resolve). ScreenPaint owns the full grammar.
53
+ ENGAGE_RE = /\A(?:##? )?[▶✔] /.freeze
54
54
  BUFFER_DIR_NAME = "plastic-message-display"
55
55
  BUFFER_MAX_AGE_SECONDS = 3600
56
56
  SCREEN_FILE = "SCREEN"
@@ -118,17 +118,14 @@ class MessageDisplay
118
118
  # resolve the id, both before anything is buffered or blanked (F4). Either
119
119
  # failure writes NOSCREEN so every later chunk can decide instantly rather
120
120
  # than waiting out its own budget for a decision that will never arrive.
121
- def handle_chunk_zero(dir, delta, cwd, final)
121
+ def handle_chunk_zero(dir, delta, _cwd, final)
122
122
  stripped = delta.sub(/\A[ \t]+/, "")
123
- m = stripped.match(MARKER_RE)
124
- resolved = m && resolve_intent_dir(m[1], cwd)
125
-
126
- unless resolved
123
+ unless ENGAGE_RE.match?(stripped)
127
124
  write_noscreen(dir)
128
125
  return nil
129
126
  end
130
127
 
131
- write_screen(dir, resolved)
128
+ write_screen(dir)
132
129
  write_chunk(dir, 0, delta)
133
130
  final ? finalize_final(dir, 0) : ""
134
131
  end
@@ -177,7 +174,7 @@ class MessageDisplay
177
174
  # every ordinary prose message answers no, at zero cost.
178
175
  def maybe_screen?(delta)
179
176
  stripped = delta.lstrip
180
- stripped.empty? || stripped.start_with?("|") || stripped.start_with?("**Steps**")
177
+ stripped.empty? || stripped.start_with?("|") || stripped.start_with?("**")
181
178
  end
182
179
 
183
180
  # The final chunk additionally waits (same budget) for every earlier chunk
@@ -190,8 +187,7 @@ class MessageDisplay
190
187
  buffered = nil
191
188
  begin
192
189
  buffered = read_buffered_chunks(dir, index)
193
- decision = read_screen_decision(dir)
194
- finalize(buffered, decision)
190
+ finalize(buffered, nil)
195
191
  rescue StandardError
196
192
  buffered
197
193
  ensure
@@ -226,28 +222,31 @@ class MessageDisplay
226
222
  end.join
227
223
  end
228
224
 
229
- def read_screen_decision(dir)
230
- content = File.read(File.join(dir, SCREEN_FILE))
231
- intent_dir, store_root = content.split("\n")
232
- { intent_dir: intent_dir, store_root: store_root }
233
- end
225
+ # 317a (D1/B10): paint what was printed. The buffered message's screen
226
+ # region - located and bounded by ScreenPaint's own grammar - is re-laid in
227
+ # the ANSI vocabulary; prose before and after survives verbatim. A region
228
+ # the painter cannot parse returns the buffered original (A3: chunks were
229
+ # already blanked, so nil here would truncate the message to its final
230
+ # delta; nil is only for the never-engaged path in handle).
231
+ #
232
+ # markdown_safe: true (intent 316a1, D5) - Claude Code still Markdown-
233
+ # processes displayContent even inside a raw ANSI block, so the Claude
234
+ # adapter asks the harness-agnostic core to strip markdown noise. A harness
235
+ # whose display surface passes raw ANSI through untouched would ask for
236
+ # false instead.
237
+ def finalize(buffered, _decision)
238
+ lines = buffered.lines
239
+ start = lines.index { |l| ScreenPaint.classify(l) == :opener }
240
+ return buffered unless start
234
241
 
235
- def finalize(buffered, decision)
236
- intent_dir = decision[:intent_dir]
237
- store_root = decision[:store_root]
238
- # markdown_safe: true (intent 316a1, D5) - Claude Code still Markdown-
239
- # processes displayContent even inside a raw ANSI block (316a's live
240
- # capture showed backticks silently stripped from step text), so the
241
- # Claude adapter asks the harness-agnostic core to strip markdown noise
242
- # before it ever reaches the block. A harness whose display surface
243
- # passes raw ANSI through untouched would ask for false instead.
244
- ansi = IntentScreenAnsi.render(intent_dir: intent_dir, store_root: store_root, color: true, markdown_safe: true)
245
- plain = IntentScreen.render(intent_dir: intent_dir, store_root: store_root, template: File.read(template_path))
246
- splice(buffered, plain, ansi)
247
- end
242
+ stop = ScreenPaint.region_end(lines, start)
243
+ painted = ScreenPaint.paint(lines[start...stop].join, color: true, markdown_safe: true)
244
+ return buffered unless painted
248
245
 
249
- def template_path
250
- File.expand_path("../../templates/intent-screen.md", __dir__)
246
+ suffix = lines[stop..].to_a.join.sub(/\A\n+/, "")
247
+ out = +"#{lines[0...start].join}#{painted.rstrip}\n"
248
+ out << "\n#{suffix}" unless suffix.empty?
249
+ out
251
250
  end
252
251
 
253
252
  def write_chunk(dir, index, delta)
@@ -257,8 +256,8 @@ class MessageDisplay
257
256
  # IntentScreen/IntentScreenAnsi's store_root: is the TIER root (what HOLDS
258
257
  # store/ — e.g. .../projects/<slug> or plastic_home itself), never the
259
258
  # store/ directory itself; resolve_intent_dir's `root:` is already that.
260
- def write_screen(dir, resolved)
261
- atomic_write(File.join(dir, SCREEN_FILE), "#{resolved[:intent_dir]}\n#{resolved[:root]}\n")
259
+ def write_screen(dir)
260
+ atomic_write(File.join(dir, SCREEN_FILE), "")
262
261
  end
263
262
 
264
263
  def write_noscreen(dir)
@@ -272,98 +271,7 @@ class MessageDisplay
272
271
  File.rename(tmp_path, path)
273
272
  end
274
273
 
275
- # D16: replace the plain render's own text wherever it sits in the buffered
276
- # message, keeping everything after it verbatim. Falls back to a line-based
277
- # boundary (the "## ▶ " line through the last line starting with "|") only
278
- # when the buffered text does not start with the plain render exactly (the
279
- # model reformatted something, or a chunk gap broke the exact match) — the
280
- # fallback also has to work for a checklist-less intent, whose only Steps
281
- # row is "| | | no steps yet |".
282
- def splice(buffered, plain, ansi)
283
- suffix =
284
- if buffered.start_with?(plain)
285
- buffered[plain.length..]
286
- else
287
- line_based_suffix(buffered, plain)
288
- end
289
- return buffered if suffix.nil?
290
-
291
- "#{ansi.rstrip}\n\n#{suffix}"
292
- end
293
274
 
294
- # Bounded fallback (matrix, lead's B1): walk forward from the "## ▶ " line
295
- # only through the screen's OWN contiguous run of blank lines, "|"-prefixed
296
- # table rows and the "**Steps**" heading, and stop at the first line that is
297
- # none of those. The boundary is the last "|" line seen before that stop —
298
- # never the last "|" line anywhere in the message. Scanning to the end
299
- # unbounded (the old behavior) swallows any prose the model wrote between
300
- # the screen and an unrelated Markdown table further down (a real hazard:
301
- # Plastic replies carry tables often).
302
- def line_based_suffix(buffered, plain)
303
- lines = buffered.lines
304
- start_idx = lines.index { |l| l.start_with?("## ▶ ") }
305
- return nil unless start_idx
306
-
307
- last_pipe_idx = nil
308
- i = start_idx + 1
309
- while i < lines.length
310
- line = lines[i]
311
- stripped = line.strip
312
- break unless stripped.empty? || line.start_with?("|") || stripped == "**Steps**"
313
-
314
- last_pipe_idx = i if line.start_with?("|")
315
- i += 1
316
- end
317
- return nil unless last_pipe_idx
318
-
319
- # Guard: never let the bounded scan consume more lines than the freshly
320
- # rendered plain screen itself has. If it would, something about the
321
- # buffered text does not match the shape splice() expects at all — pass
322
- # the original through rather than risk eating real prose.
323
- consumed = last_pipe_idx + 1 - start_idx
324
- return nil if consumed > plain.lines.length
325
-
326
- lines[(last_pipe_idx + 1)..].join
327
- end
328
-
329
- # O5: candidates are every discovered store holding a "<id>--*" directory.
330
- # A single candidate resolves outright (no ambiguity to break). With two or
331
- # more, the store whose project root is a path prefix of the payload's cwd
332
- # decides; if that narrows to anything other than exactly one, pass through
333
- # rather than guess (matrix 36).
334
- #
335
- # "cwd is a path prefix" is checked against the project's REAL checkout
336
- # path (projects.yml's own `path:`, e.g. ~/apps/personal/plastic) — never
337
- # against StoreDiscovery's `root` (~/.plastic/projects/<slug>, which only
338
- # holds INDEX.md and store/). Those are two different directories; a real
339
- # session's cwd lives under the former, never the latter. The global store
340
- # has no such checkout path, so it never wins by cwd — only by being the
341
- # sole candidate.
342
- def resolve_intent_dir(id, cwd)
343
- pattern = "#{glob_escape(id)}--*"
344
- candidates = StoreDiscovery.discover(@plastic_home)[:stores].filter_map do |s|
345
- dir = Dir.glob(File.join(s[:store], pattern)).find { |d| File.directory?(d) }
346
- dir && { slug: s[:slug], root: s[:root], intent_dir: dir }
347
- end
348
- return nil if candidates.empty?
349
- return candidates.first if candidates.length == 1
350
-
351
- registered = StoreProvisioning.load_projects(@plastic_home)
352
- cwd_matches = candidates.select do |c|
353
- real_path = registered.dig(c[:slug], "path")
354
- real_path && (cwd == real_path || cwd.start_with?("#{real_path}#{File::SEPARATOR}"))
355
- end
356
- return cwd_matches.first if cwd_matches.length == 1
357
-
358
- nil
359
- end
360
-
361
- # A recognized id should just be [A-Za-z0-9]+, but the id comes out of the
362
- # assistant's own streamed text, not a trusted schema — escape glob
363
- # metacharacters rather than assume it is well-formed.
364
- def glob_escape(str)
365
- str.gsub(/([*?\[\]{}])/) { "\\#{Regexp.last_match(1)}" }
366
- end
367
275
 
368
276
  def prune_old_buffers
369
277
  root = File.join(@tmp_root, BUFFER_DIR_NAME)