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

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 CHANGED
@@ -64,6 +64,18 @@
64
64
  }
65
65
  ]
66
66
  }
67
+ ],
68
+ "MessageDisplay": [
69
+ {
70
+ "matcher": "",
71
+ "hooks": [
72
+ {
73
+ "type": "command",
74
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" message-display",
75
+ "statusMessage": ""
76
+ }
77
+ ]
78
+ }
67
79
  ]
68
80
  }
69
81
  }
@@ -0,0 +1,73 @@
1
+ #!/bin/bash
2
+ # hooks/message-display (intent 316a, O6, round 3 concurrency fix): the
3
+ # MessageDisplay launcher. Fires on every streamed chunk of every assistant
4
+ # message (D11), so the common case — an ordinary chunk of an ordinary
5
+ # message — must decide with shell builtins alone and fork nothing. Only a
6
+ # candidate message hands off to Ruby (scripts/hook-message-display), which
7
+ # is the one place allowed to do real work.
8
+ #
9
+ # No command substitution, no backticks, no sed/jq/cat: case, [, parameter
10
+ # expansion and printf are all builtins. Deliberately does NOT copy hooks/
11
+ # capture's SCRIPT_DIR-via-subshell pattern (cd into dirname of $0, inside a
12
+ # command substitution, then pwd) — that forks a subshell on every single
13
+ # invocation, which is exactly the cost this hook cannot carry.
14
+ #
15
+ # A live run under a real pty found Claude Code fires these chunk processes
16
+ # CONCURRENTLY: a chunk with index > 0 can arrive, and be judged here,
17
+ # before chunk 0 ever runs. The OLD hand-off test — "does a buffer already
18
+ # exist for this message" — answered no in that race and silently dropped
19
+ # the chunk before Ruby ever saw it, no matter what MessageDisplay's own
20
+ # (correct) polling logic would have done. So a later chunk is now also
21
+ # handed off when its OWN delta looks like it could be part of a screen
22
+ # (leading "|" or "**Steps**", or blank — the same cheap test Ruby itself
23
+ # uses to decide whether a wait is worth paying for), and the final chunk is
24
+ # ALWAYS handed off, whatever it looks like, since it is the one that must
25
+ # not race. Ruby is the one place that actually waits (bounded, injectable
26
+ # for tests); this script only ever decides once, fast, and never sleeps.
27
+ #
28
+ # Claude adapter: Claude Code only; the core is harness-agnostic.
29
+ IFS= read -r -d '' INPUT # returns 1 at EOF: do NOT set -e
30
+ case $INPUT in *'"message_id"'*) ;; *) exit 0 ;; esac
31
+ rest=${INPUT#*\"message_id\"}; rest=${rest#*\"}; mid=${rest%%\"*}
32
+ rest=${INPUT#*\"session_id\"}; rest=${rest#*\"}; sid=${rest%%\"*}
33
+
34
+ SCRIPT_DIR=${0%/*}
35
+ TMP_ROOT=${PLASTIC_TMP:-${TMPDIR:-/tmp}}
36
+ export PLASTIC_TMP="$TMP_ROOT"
37
+ MSGDIR="$TMP_ROOT/plastic-message-display/$sid/$mid"
38
+
39
+ is_index_zero=0
40
+ case $INPUT in *'"index":0'*|*'"index": 0'*) is_index_zero=1 ;; esac
41
+
42
+ is_final=0
43
+ case $INPUT in *'"final":true'*|*'"final": true'*) is_final=1 ;; esac
44
+
45
+ handoff=0
46
+ if [ "$is_index_zero" = 1 ]; then
47
+ # Chunk 0 decides synchronously; a bare "#" first delta, in either JSON
48
+ # spacing, is the only shape that can possibly open a screen. Must be a
49
+ # single "#", not "##" — a real screen's own first delta can be as short
50
+ # as "## " — a "##" glob would filter out exactly the message this hook
51
+ # exists to recognize.
52
+ case $INPUT in
53
+ *'"delta":"#'*|*'"delta": "#'*) handoff=1 ;;
54
+ esac
55
+ else
56
+ # A later chunk: hand off when this message's directory already exists
57
+ # (chunk 0 already left a decision or a chunk file), when this chunk is
58
+ # final (it must always be checked, whatever it looks like), or when its
59
+ # own delta is shaped like part of a screen — a Markdown table row, the
60
+ # "**Steps**" heading, or a blank line, in either JSON spacing.
61
+ [ -d "$MSGDIR" ] && handoff=1
62
+ [ "$is_final" = 1 ] && handoff=1
63
+ case $INPUT in
64
+ *'"delta":"|'*|*'"delta": "|'*) handoff=1 ;;
65
+ *'"delta":"**Steps**'*|*'"delta": "**Steps**'*) handoff=1 ;;
66
+ *'"delta":""'*|*'"delta": ""'*) handoff=1 ;;
67
+ *'"delta":"\n"'*|*'"delta": "\n"'*) handoff=1 ;;
68
+ esac
69
+ fi
70
+
71
+ [ "$handoff" = 1 ] || exit 0
72
+
73
+ printf '%s' "$INPUT" | env -u RUBYOPT ruby "$SCRIPT_DIR/../scripts/hook-message-display"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "2.0.0-alpha.5",
3
+ "version": "2.0.0-alpha.7",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # hook-message-display (intent 316a, O4): the MessageDisplay hook CLI. Reads
6
+ # the harness's per-chunk JSON payload from stdin, hands it to MessageDisplay
7
+ # (the pure handler class), and prints the hookSpecificOutput envelope when a
8
+ # String comes back. Always exits 0, whatever happens — a raised exception
9
+ # here must never surface as a non-zero exit or stray stderr on an ordinary
10
+ # chunk of an ordinary message (matrix 31).
11
+ #
12
+ # This script is the one place in the O4 stack allowed to read ENV, the
13
+ # clock, or Dir.tmpdir: MessageDisplay itself takes tmp_root/plastic_home/
14
+ # color/now as constructor arguments and touches none of them directly.
15
+ #
16
+ # Claude adapter: Claude Code only; the core is harness-agnostic.
17
+
18
+ require "json"
19
+ require "time"
20
+ require "tmpdir"
21
+ require "yaml"
22
+ require_relative "lib/message_display"
23
+
24
+ def color_enabled?(plastic_home)
25
+ return false unless ENV["NO_COLOR"].to_s.empty?
26
+
27
+ cfg_path = File.join(plastic_home, "config.yml")
28
+ return true unless File.exist?(cfg_path)
29
+
30
+ cfg = YAML.safe_load(File.read(cfg_path))
31
+ display = cfg.is_a?(Hash) ? cfg["display"] : nil
32
+ return true unless display.is_a?(Hash)
33
+
34
+ display.fetch("ansi_screen", true) != false
35
+ rescue StandardError
36
+ true
37
+ end
38
+
39
+ begin
40
+ raw = $stdin.tty? ? "" : $stdin.read
41
+ payload = raw.to_s.strip.empty? ? nil : JSON.parse(raw)
42
+
43
+ if payload.is_a?(Hash)
44
+ tmp_root = ENV["PLASTIC_TMP"].to_s.empty? ? Dir.tmpdir : ENV["PLASTIC_TMP"]
45
+ plastic_home = File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
46
+
47
+ handler = MessageDisplay.new(
48
+ tmp_root: tmp_root,
49
+ plastic_home: plastic_home,
50
+ color: color_enabled?(plastic_home),
51
+ now: Time.now,
52
+ )
53
+ result = handler.handle(payload)
54
+
55
+ if result.is_a?(String)
56
+ puts JSON.generate(
57
+ "hookSpecificOutput" => {
58
+ "hookEventName" => "MessageDisplay",
59
+ "displayContent" => result,
60
+ },
61
+ )
62
+ end
63
+ end
64
+ rescue StandardError
65
+ nil
66
+ end
67
+ exit 0
@@ -8,18 +8,31 @@
8
8
  # the close; it never edits the numbers.
9
9
  #
10
10
  # Usage:
11
- # intent-screen <intent_dir> [--template <path>]
11
+ # intent-screen <intent_dir> [--template <path>] [--ansi]
12
12
  #
13
13
  # The store root is the directory two levels above the intent (<root>/store/<id--slug>);
14
14
  # the template defaults to templates/intent-screen.md next to this script's dir,
15
15
  # in-repo (<repo>/scripts -> <repo>/templates) and installed (~/.plastic/scripts ->
16
16
  # ~/.plastic/templates) alike.
17
17
  #
18
+ # --ansi (intent 316a, O3): emits scripts/lib/intent_screen_ansi.rb's styled
19
+ # truecolor block instead of the plain Markdown screen. Plain stays the
20
+ # default with no flag. Two things force plain even WITH --ansi (D18): NO_COLOR
21
+ # present in the environment (any value counts), or a non-TTY stdout — the
22
+ # true default form of D2, not IntentScreenAnsi's own uncoloured layout. The
23
+ # library (scripts/lib/intent_screen_ansi.rb) is pure and never reads either;
24
+ # this script is the one place allowed to.
25
+ #
18
26
  # Exit codes:
19
27
  # 0 - the screen is on stdout
20
28
  # 2 - usage error, or the path is not an intent directory (one line on stderr)
29
+ #
30
+ # Harness-agnostic core: no harness assumption lives here. This script holds
31
+ # the plain/ANSI selection (intent 316a1, D3) and belongs to the same
32
+ # harness-agnostic core as lib/intent_screen.rb and lib/intent_screen_ansi.rb.
21
33
 
22
34
  require_relative "lib/intent_screen"
35
+ require_relative "lib/intent_screen_ansi"
23
36
 
24
37
  def usage_abort(message)
25
38
  warn "intent-screen: #{message}"
@@ -28,18 +41,21 @@ end
28
41
 
29
42
  args = ARGV.dup
30
43
  template_path = nil
44
+ ansi = false
31
45
  positional = []
32
46
  while (arg = args.shift)
33
47
  case arg
34
48
  when "--template"
35
49
  template_path = args.shift or usage_abort("--template needs a path")
50
+ when "--ansi"
51
+ ansi = true
36
52
  else
37
53
  usage_abort("unknown flag #{arg.inspect}") if arg.start_with?("--")
38
54
  positional << arg
39
55
  end
40
56
  end
41
57
 
42
- usage_abort("usage: intent-screen <intent_dir> [--template <path>]") unless positional.length == 1
58
+ usage_abort("usage: intent-screen <intent_dir> [--template <path>] [--ansi]") unless positional.length == 1
43
59
  intent_dir = File.expand_path(positional.first)
44
60
  usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
45
61
 
@@ -47,6 +63,15 @@ store_root = File.expand_path("../..", intent_dir)
47
63
  template_path ||= File.expand_path("../templates/intent-screen.md", __dir__)
48
64
  usage_abort("template not found at #{template_path}") unless File.exist?(template_path)
49
65
 
50
- $stdout.write IntentScreen.render(intent_dir: intent_dir, store_root: store_root,
51
- template: File.read(template_path))
66
+ plain = -> { IntentScreen.render(intent_dir: intent_dir, store_root: store_root, template: File.read(template_path)) }
67
+
68
+ degrade_to_plain = ENV.key?("NO_COLOR") || !$stdout.tty?
69
+
70
+ $stdout.write(
71
+ if ansi && !degrade_to_plain
72
+ IntentScreenAnsi.render(intent_dir: intent_dir, store_root: store_root, color: true)
73
+ else
74
+ plain.call
75
+ end
76
+ )
52
77
  exit 0
@@ -27,9 +27,10 @@ class Doctor
27
27
  "hermes" => { name: "Hermes", dir: File.join(Dir.home, ".hermes") },
28
28
  }.freeze
29
29
 
30
- # The Claude events hooks_registered expects in settings.json: the five-event map of
31
- # cut-inventory 3b (intent 309 added SessionEnd, registered for close since intent 301).
32
- CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit SessionEnd].freeze
30
+ # The Claude events hooks_registered expects in settings.json: the six-event map of
31
+ # cut-inventory 3b (intent 309 added SessionEnd, registered for close since intent 301;
32
+ # intent 316a added MessageDisplay, registered for message-display, Claude only).
33
+ CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit SessionEnd MessageDisplay].freeze
33
34
 
34
35
  # Launchers the installer places in the agent's hooks dir that are NOT hooks
35
36
  # (intent 204): plastic-statusline is the settings["statusLine"] command, wired
@@ -54,6 +54,20 @@ module HookRegistry
54
54
  { "name" => "capture", "status" => "Capturing prompt into the session ledger..." },
55
55
  ] },
56
56
  ],
57
+ # This entry belongs to the Claude adapter half of Plastic's
58
+ # harness-agnostic-core / Claude-adapter split (intent 316a1, D3
59
+ # supersedes 316a's D6): MessageDisplay is not one of
60
+ # CODEX_LIVE_STATE_EVENTS, so codex_hooks_json (below) never picks it
61
+ # up; codex_hook_names stays exactly what it was (pinned by
62
+ # test/hook_registry_test.rb:82 and :110-111). Fires on every streamed
63
+ # chunk of every assistant message (D11); the launcher (hooks/message-
64
+ # display) decides with shell builtins and forks nothing on the common
65
+ # case, execing Ruby only for a candidate message.
66
+ "MessageDisplay" => [
67
+ { "matcher" => "", "hooks" => [
68
+ { "name" => "message-display", "status" => "" },
69
+ ] },
70
+ ],
57
71
  }
58
72
  end
59
73
 
@@ -443,6 +443,14 @@ class InstallerCore
443
443
  "scripts/lib/intent_screen.rb" => "scripts/lib/intent_screen.rb",
444
444
  "scripts/intent-screen" => "scripts/intent-screen",
445
445
  "scripts/hook-savepoint" => "scripts/hook-savepoint",
446
+ # Intent 316a: hooks/* only glob-copies scripts/*, never scripts/lib/*
447
+ # (see hook_files above), so the two lib files a require_relative
448
+ # between themselves are unguarded there — these three literal
449
+ # entries are their only protection (test/install_sync_test.rb:23-29
450
+ # greps installer_core.rb's own source text for "scripts/<name>").
451
+ "scripts/lib/intent_screen_ansi.rb" => "scripts/lib/intent_screen_ansi.rb",
452
+ "scripts/lib/message_display.rb" => "scripts/lib/message_display.rb",
453
+ "scripts/hook-message-display" => "scripts/hook-message-display",
446
454
  }
447
455
  end
448
456
 
@@ -1,9 +1,19 @@
1
1
  # encoding: UTF-8
2
2
  # frozen_string_literal: true
3
3
  # IntentScreen (intent 316) - fills templates/intent-screen.md from one intent's
4
- # record: the intent file, the tier's INDEX.md, savepoint.md, and checklist.md.
4
+ # record: the intent file, the tier's INDEX.md, savepoint.md and checklist.md.
5
5
  # Every number on the screen comes from here so the session never writes one by
6
6
  # eye. Pure: explicit paths in, a Markdown string out; no ENV, no Dir.pwd.
7
+ #
8
+ # Intent 316a fixed three defects the field code inherited into both the plain
9
+ # renderer and the ANSI renderer (scripts/lib/intent_screen_ansi.rb): the
10
+ # Insight row dumping a multi-clause remainder into the note column, an empty
11
+ # "What this means" heading rendering bold with nothing under it, and step
12
+ # text cut mid-sentence. `step_text`, `insight_fields` and `next_fields` are
13
+ # public so the ANSI renderer reuses the exact same trims (D3) rather than
14
+ # re-deriving them and drifting.
15
+ #
16
+ # Harness-agnostic core: no harness assumption lives here.
7
17
  module IntentScreen
8
18
  BAR_WIDTH = 20
9
19
  ON = "█"
@@ -11,10 +21,22 @@ module IntentScreen
11
21
  PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
12
22
  SECTIONS = %w[Active Future Completed Abandoned].freeze
13
23
  ITEM_RE = /^\s*- \[([ xX])\]\s+(.*)$/
14
- STEP_PREFIX_RE = /\A(?:Step|S)\s*\d+\s*[-:·]\s*/i
24
+ # Em dash and en dash added (intent 316a O1e): a checklist item written
25
+ # "S1 — text" (the em dash every checklist this intent writes, and the one a
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
15
29
  INSIGHT_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s+·\s+\S+\s+·\s+.+?\s+—\s+(.+)\z/
16
30
  SAVEPOINT_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s{2,}(\S+)\s{2,}(.+?)\s*\z/
17
31
 
32
+ # Word-boundary truncation caps (intent 316a D3/O1a/O1c). Never a clause
33
+ # trim: a clause trim on step text destroys a pinned `OPEN:` row
34
+ # (test/intent_screen_test.rb:171-178) that a mid-sentence cut would eat.
35
+ INSIGHT_VALUE_MAX = 72
36
+ INSIGHT_NOTE_MAX = 96
37
+ NEXT_VALUE_MAX = 72
38
+ STEP_TEXT_MAX = 110
39
+
18
40
  # Where a resume lands, from the ledger's last line (the boarding matrix).
19
41
  def self.landing_stage(stage, milestone)
20
42
  case stage
@@ -54,8 +76,6 @@ module IntentScreen
54
76
  fields.merge!(next_fields(items, status, checklist_present: items_present?(intent_dir)))
55
77
  fields.merge!(insight_fields(intent_text))
56
78
  fields["steps.rows"] = steps_rows(items)
57
- fields["meaning"] = ""
58
- fields["close"] = ""
59
79
 
60
80
  out = template.dup
61
81
  fields.each { |k, v| out = out.gsub("{{#{k}}}", v.to_s) }
@@ -171,7 +191,11 @@ module IntentScreen
171
191
  "progress.note" => note }
172
192
  end
173
193
 
174
- def self.next_fields(items, status, checklist_present:)
194
+ # `escape_pipes:` (intent 316a O1d, default true) keeps the plain Markdown
195
+ # table's pipe-escaping; the ANSI renderer, which never emits a table,
196
+ # passes `escape_pipes: false` to get the raw value instead of a literal
197
+ # `\|`. Named to not shadow the module's own `escape` method.
198
+ def self.next_fields(items, status, checklist_present:, escape_pipes: true)
175
199
  return { "next" => "", "next.note" => "" } if %w[Completed Abandoned].include?(status)
176
200
  return { "next" => "write checklist.md", "next.note" => "How" } unless checklist_present
177
201
 
@@ -179,43 +203,83 @@ module IntentScreen
179
203
  return { "next" => "", "next.note" => "all steps done" } unless idx
180
204
 
181
205
  head, = split_first_clause(items[idx][:text])
182
- { "next" => "S#{idx + 1} · #{escape(head)}", "next.note" => "first open step" }
206
+ head = truncate_words(head, NEXT_VALUE_MAX)
207
+ head = escape(head) if escape_pipes
208
+ { "next" => "S#{idx + 1} · #{head}", "next.note" => "first open step" }
183
209
  end
184
210
 
185
211
  def self.steps_rows(items)
186
212
  return "| | | no steps yet |" if items.empty?
187
213
 
188
214
  items.each_with_index.map do |item, i|
189
- "| S#{i + 1} | #{item[:done] ? 'done' : 'open'} | #{escape(item[:text])} |"
215
+ "| S#{i + 1} | #{item[:done] ? 'done' : 'open'} | #{escape(step_text(item[:text]))} |"
190
216
  end.join("\n")
191
217
  end
192
218
 
219
+ # Public (intent 316a O1c) so the ANSI renderer trims step text identically:
220
+ # word-boundary truncation only, never a clause trim, at STEP_TEXT_MAX.
221
+ def self.step_text(text)
222
+ truncate_words(text, STEP_TEXT_MAX)
223
+ end
224
+
193
225
  def self.escape(text)
194
226
  text.gsub("|", "\\|")
195
227
  end
196
228
 
197
229
  # --- ## Insights ----------------------------------------------------------------
198
230
 
199
- def self.insight_fields(intent_text)
231
+ def self.insight_fields(intent_text, escape_pipes: true)
200
232
  section = intent_text.split(/^## Insights\s*$/, 2)[1].to_s.split(/^## /, 2)[0].to_s
201
233
  entry = section.lines.map(&:strip).reverse.map { |l| l.match(INSIGHT_RE) }.compact.first
202
234
  return { "insight" => "none yet", "insight.note" => "" } unless entry
203
235
 
204
236
  ts, text = entry[1], entry[2].strip
205
237
  head, tail = split_first_clause(text)
238
+ value = truncate_words(head, INSIGHT_VALUE_MAX)
239
+ tail = tail.empty? ? "" : truncate_words(tail, INSIGHT_NOTE_MAX)
206
240
  note = tail.empty? ? human_time(ts) : "#{human_time(ts)} · #{tail}"
207
- { "insight" => escape(head), "insight.note" => escape(note) }
241
+ if escape_pipes
242
+ { "insight" => escape(value), "insight.note" => escape(note) }
243
+ else
244
+ { "insight" => value, "insight.note" => note }
245
+ end
208
246
  end
209
247
 
248
+ # First clause of `text`, and at most one following clause as the tail.
249
+ # Anything past the second clause is discarded (intent 316a O1a): the old
250
+ # behavior dumped the ENTIRE remainder into the note (an 800-character
251
+ # real-world tail starting mid-list). Boundary is a `.` or `;` immediately
252
+ # followed by whitespace-then-more or end of string, so "alpha.2" and "2.0"
253
+ # are never mistaken for clause ends.
210
254
  def self.split_first_clause(text)
211
- m = text.match(/\A(.+?)[.;](\s+.*|\z)/m)
212
- head = m ? m[1] : text
213
- tail = m ? m[2].to_s.strip : ""
214
- if head.length > 60
215
- cut = head[0, 60].rindex(" ") || 60
216
- tail = "#{head[cut..].strip} #{tail}".strip
217
- head = head[0, cut].strip
218
- end
255
+ head, rest = clause_and_rest(text)
256
+ return [head, ""] unless rest
257
+
258
+ second, more = clause_and_rest(rest)
259
+ tail = more ? second : rest
219
260
  [head, tail]
220
261
  end
262
+
263
+ # Returns [clause_without_terminal_punctuation, remainder_or_nil]. `nil` for
264
+ # the remainder means either no boundary exists at all, or the boundary
265
+ # sits at the absolute end of `text` (a single trailing clause with nothing
266
+ # after it) — both cases where there is no SECOND clause to fold in.
267
+ def self.clause_and_rest(text)
268
+ m = text.match(/\A(.+?)[.;](\s+(.*)|\z)/m)
269
+ return [text, nil] unless m
270
+
271
+ remainder = m[2].to_s.strip
272
+ remainder.empty? ? [m[1], nil] : [m[1], remainder]
273
+ end
274
+
275
+ # Word-boundary truncation with a trailing "…" when cut, never mid-word and
276
+ # never a clause trim (intent 316a D3).
277
+ def self.truncate_words(text, max)
278
+ return text if text.length <= max
279
+ return "…" if max <= 1
280
+
281
+ cut = text[0, max - 1].rindex(" ")
282
+ cut = max - 1 if cut.nil? || cut.zero?
283
+ "#{text[0, cut].rstrip}…"
284
+ end
221
285
  end
@@ -0,0 +1,201 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "intent_screen"
5
+
6
+ # IntentScreenAnsi (intent 316a, O2) - renders one intent screen with raw
7
+ # truecolor ANSI escapes, productionizing 318's mockup--render.rb. Standard
8
+ # library only. Calls the SAME public IntentScreen.* field methods
9
+ # scripts/intent-screen calls (store_fields, index_fields, savepoint_fields,
10
+ # checklist_items, progress_fields, next_fields, insight_fields,
11
+ # items_present?, fallback_name, step_text) and re-derives nothing, so every
12
+ # field the ANSI block prints is the identical value the plain screen prints
13
+ # (D3), just carried through a different layout.
14
+ #
15
+ # `color:` is a constructor/call argument, never an environment read (D18):
16
+ # the plain path (`color: false`) is one call away and testable without
17
+ # touching NO_COLOR or a TTY. No ENV, no Dir.pwd, no Dir.home.
18
+ #
19
+ # Harness-agnostic core: no harness assumption lives here. `markdown_safe:`
20
+ # (intent 316a1, D3/D5) is the one choice a caller supplies rather than a
21
+ # choice this module makes for itself: a display surface that passes raw
22
+ # ANSI through untouched should not inherit a concession it never needed.
23
+ # See docs/reference/harness-adapters.md for which caller asks for it and
24
+ # why.
25
+ module IntentScreenAnsi
26
+ ESC = "\e"
27
+ RESET = "#{ESC}[0m".freeze
28
+ BOLD = "#{ESC}[1m".freeze
29
+ TEAL = "#{ESC}[38;2;45;212;191m".freeze
30
+ AMBER = "#{ESC}[38;2;245;158;11m".freeze
31
+ GRAPHITE_BG = "#{ESC}[48;2;31;41;55m".freeze
32
+ MIDGREY = "#{ESC}[38;2;148;163;184m".freeze
33
+ NEARWHITE = "#{ESC}[38;2;243;244;246m".freeze
34
+
35
+ BAR_CELLS = 24
36
+ EIGHTHS = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"].freeze
37
+
38
+ DEFAULT_WIDTH = 100
39
+
40
+ ELLIPSIS = "…"
41
+
42
+ def self.render(intent_dir:, store_root:, color: true, width: DEFAULT_WIDTH, markdown_safe: false)
43
+ base = File.basename(intent_dir)
44
+ id = base.split("--", 2).first
45
+ intent_text = File.read(File.join(intent_dir, "#{base}.md"))
46
+
47
+ fields = {}
48
+ fields.merge!(IntentScreen.store_fields(store_root))
49
+ status, title = IntentScreen.index_fields(store_root, id)
50
+ fields["status"] = status
51
+ fields["status.note"] = status == "unlisted" ? "no INDEX.md line names this id" : "listed under ## #{status} in INDEX.md"
52
+ fields["id"] = id
53
+ fields["name"] = title || IntentScreen.fallback_name(intent_text)
54
+ fields.merge!(IntentScreen.savepoint_fields(intent_dir, intent_text))
55
+ items = IntentScreen.checklist_items(intent_dir)
56
+ fields.merge!(IntentScreen.progress_fields(items))
57
+ fields.merge!(IntentScreen.next_fields(items, status, checklist_present: IntentScreen.items_present?(intent_dir), escape_pipes: false))
58
+ fields.merge!(IntentScreen.insight_fields(intent_text, escape_pipes: false))
59
+ fields.transform_values! { |v| markdown_safe ? clean(v) : v }
60
+
61
+ done_n = fields["progress.done"].to_i
62
+ total_n = fields["progress.total"].to_i
63
+
64
+ out = +""
65
+ out << fit("▶ #{fields['id']} · #{fields['name']}", width) { |t| styled(t, color, BOLD, NEARWHITE) }
66
+ out << "\n\n"
67
+
68
+ # The 4th column marks a row whose value is already a finished, pre-fit
69
+ # string (the Progress bar, built above from styled glyphs plus a count)
70
+ # rather than raw field text still needing `fit_plain`. Naming that
71
+ # explicitly here reads better than testing the value for a leading ESC
72
+ # byte further down, which is really just asking "is this the Progress
73
+ # row?" through a type check.
74
+ field_rows = [
75
+ ["Store", fields["store"], fields["store.note"], false],
76
+ ["Status", fields["status"], fields["status.note"], false],
77
+ ["Stage", fields["stage"], fields["stage.note"], false],
78
+ ["Savepoint", fields["savepoint"], fields["savepoint.note"], false],
79
+ ["Progress", "#{render_bar(done_n, total_n, color)} #{done_n} / #{total_n}", fields["progress.note"], true],
80
+ ["Next", fields["next"], fields["next.note"], false],
81
+ ["Insight", fields["insight"], fields["insight.note"], false],
82
+ ]
83
+ key_width = field_rows.map { |k, _, _, _| k.length }.max
84
+ prefix_width = key_width + 4 # " " + key.ljust + " "
85
+
86
+ field_rows.each do |key, value, note, prebuilt|
87
+ value_budget = [width - prefix_width, 0].max
88
+ value_text = prebuilt ? value : fit_plain(value, value_budget)
89
+ out << " #{styled(key.ljust(key_width), color, BOLD)} #{value_text}\n"
90
+ next if note.to_s.empty?
91
+
92
+ note_budget = [width - prefix_width, 0].max
93
+ indent = " " * prefix_width
94
+ out << "#{indent}#{fit(note, note_budget) { |t| styled(t, color, MIDGREY) }}\n"
95
+ end
96
+
97
+ out << "\n"
98
+ out << fit("Steps", width) { |t| styled(t, color, BOLD, NEARWHITE) }
99
+ out << "\n\n"
100
+
101
+ if items.empty?
102
+ out << " no steps yet\n"
103
+ else
104
+ # Padded to the widest label (matrix B2): at 10+ steps "S10" is one
105
+ # column wider than "S1..S9", and without padding every badge past S9
106
+ # drifts out of column with the rows above it.
107
+ label_width = "S#{items.size}".length
108
+ items.each_with_index do |item, i|
109
+ num = "S#{i + 1}".ljust(label_width)
110
+ badge = status_cell(item[:done], color)
111
+ prefix_plain = " #{num} [ #{item[:done] ? 'done' : 'open'} ] "
112
+ text_budget = [width - prefix_plain.length, 0].max
113
+ step = IntentScreen.step_text(item[:text])
114
+ text = fit_plain(markdown_safe ? clean(step) : step, text_budget)
115
+ out << " #{num} [#{badge}] #{text}\n"
116
+ end
117
+ end
118
+
119
+ out
120
+ end
121
+
122
+ # --- markdown-noise stripping, adapter-optional (intent 316a1, D3/D5) ------
123
+ #
124
+ # Not every display surface passes text through a Markdown renderer, so
125
+ # stripping is not this module's call to make (see `markdown_safe:` on
126
+ # `render` above; the justification for WHY a caller would ever ask for
127
+ # this lives with that caller, in scripts/lib/message_display.rb). When
128
+ # asked, strips backticks and neutralises `*`/`_` runs from a value.
129
+ # Single underscores are left alone: they are common inside ordinary words
130
+ # (`intent_screen.rb`) and GFM does not treat an intraword underscore as
131
+ # emphasis; only a run of 2+ (the bold marker `__`) is markdown-active.
132
+ def self.clean(text)
133
+ text.to_s.delete("`*").gsub(/_{2,}/, "")
134
+ end
135
+
136
+ # --- width cap (D15, matrix 18) --------------------------------------------
137
+
138
+ # Truncates `text` to `max` visible columns with a trailing ellipsis when
139
+ # cut, then yields the truncated plain text to the block for coloring.
140
+ # Coloring never adds visible width. The cap itself is harness-neutral: a
141
+ # fixed width, not a re-flow, is what lets a column layout survive whatever
142
+ # display eventually shows it — no display's own wrapping is assumed here.
143
+ def self.fit(text, max)
144
+ plain = fit_plain(text, max)
145
+ block_given? ? yield(plain) : plain
146
+ end
147
+
148
+ def self.fit_plain(text, max)
149
+ return "" if max <= 0
150
+ return text if text.length <= max
151
+ return ELLIPSIS[0, max] if max <= 1
152
+
153
+ "#{text[0, max - 1]}#{ELLIPSIS}"
154
+ end
155
+
156
+ # --- palette ----------------------------------------------------------------
157
+
158
+ def self.styled(text, color, *codes)
159
+ return text unless color
160
+
161
+ "#{codes.join}#{text}#{RESET}"
162
+ end
163
+
164
+ def self.status_cell(done, color)
165
+ label = done ? " done " : " open "
166
+ return label unless color
167
+
168
+ hue = done ? TEAL : AMBER
169
+ "#{hue}#{BOLD}#{label}#{RESET}"
170
+ end
171
+
172
+ # `.dup` matters, not just style (318's own note, carried forward): these
173
+ # constants are built via string interpolation, which frozen_string_literal
174
+ # does NOT freeze automatically — only static literals get that. `.freeze`
175
+ # above makes them immutable, but `bar << ...` below still needs its OWN
176
+ # mutable copy or it would raise (or, without the freeze, silently corrupt
177
+ # the shared constant for every later call in the same process — matrix 16).
178
+ def self.render_bar(done, total, color)
179
+ ratio = total.zero? ? 0.0 : done.to_f / total
180
+
181
+ unless color
182
+ on = total.zero? ? 0 : (done * BAR_CELLS) / total
183
+ return ("#" * on) + ("." * (BAR_CELLS - on))
184
+ end
185
+
186
+ units = (ratio * BAR_CELLS * 8).round.clamp(0, BAR_CELLS * 8)
187
+ full, rem = units.divmod(8)
188
+ full = [full, BAR_CELLS].min
189
+
190
+ bar = TEAL.dup
191
+ bar << ("█" * full)
192
+ if full < BAR_CELLS && rem.positive?
193
+ bar << EIGHTHS[rem]
194
+ full += 1
195
+ end
196
+ track = BAR_CELLS - full
197
+ bar << GRAPHITE_BG << (" " * track) if track.positive?
198
+ bar << RESET
199
+ bar
200
+ end
201
+ end
@@ -0,0 +1,382 @@
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
+ # Claude adapter: Claude Code only; the core is harness-agnostic. (intent
19
+ # 316a1, D3 supersedes 316a's D6.) This is the sole caller that asks
20
+ # IntentScreenAnsi.render for `markdown_safe: true` (scripts/lib/
21
+ # intent_screen_ansi.rb) — see `finalize` below for why.
22
+ #
23
+ # A live run under a real pty (round 3) found that Claude Code fires the
24
+ # per-chunk hook processes CONCURRENTLY, not strictly in order. Chunk 0 is
25
+ # the one that recognizes the screen and creates the buffer (D13), and it can
26
+ # lose the race to chunks with a higher index: they would find no buffer yet
27
+ # and pass their raw Markdown straight through, producing a half plain /
28
+ # half styled screen. This class now survives that:
29
+ #
30
+ # - One file per chunk (index-named), written atomically (temp name in the
31
+ # same directory, then File.rename), so reassembly never depends on
32
+ # arrival order — only on the index each chunk already carries.
33
+ # - A decision file written BEFORE anything slow: chunk 0 writes SCREEN
34
+ # (the resolved intent dir + store root) the moment it engages, or
35
+ # NOSCREEN the moment it does not, so later chunks can decide without
36
+ # redoing any of chunk 0's work.
37
+ # - A later chunk asks a cheap, local question before ever waiting: could
38
+ # this delta plausibly be part of a screen (leading "|", "**Steps**", or
39
+ # blank)? An ordinary prose chunk arriving before SCREEN/NOSCREEN exists
40
+ # passes through at once, at zero cost. A chunk shaped like part of a
41
+ # screen polls for the decision, bounded (wait_ms/poll_ms), then fails
42
+ # open. The final chunk always waits for the decision, whatever its own
43
+ # shape, since it is the one that must not race — and it additionally
44
+ # waits (same budget) for every earlier chunk file to exist before it
45
+ # splices, returning whatever it does have rather than nothing when the
46
+ # budget runs out.
47
+ #
48
+ # Protocol (D13, preserved): chunk 0 still decides, once, before anything is
49
+ # buffered or blanked. D10 (any failure while finalizing returns the
50
+ # buffered original, never nil, never "") and D12 (color: false never
51
+ # buffers or blanks anything) are unchanged.
52
+ class MessageDisplay
53
+ MARKER_RE = /\A## ▶ (\S+) · /.freeze
54
+ BUFFER_DIR_NAME = "plastic-message-display"
55
+ BUFFER_MAX_AGE_SECONDS = 3600
56
+ SCREEN_FILE = "SCREEN"
57
+ NOSCREEN_FILE = "NOSCREEN"
58
+
59
+ def initialize(tmp_root:, plastic_home:, color:, now:, wait_ms: 300, poll_ms: 20,
60
+ sleeper: ->(seconds) { sleep(seconds) })
61
+ @tmp_root = tmp_root
62
+ @plastic_home = plastic_home
63
+ @color = color
64
+ @now = now
65
+ @wait_ms = wait_ms
66
+ @poll_ms = poll_ms
67
+ @sleeper = sleeper
68
+ end
69
+
70
+ def handle(payload)
71
+ return nil unless @color
72
+ return nil unless payload.is_a?(Hash)
73
+
74
+ prune_old_buffers
75
+
76
+ message_id = payload["message_id"].to_s
77
+ session_id = payload["session_id"].to_s
78
+ delta = payload["delta"].to_s
79
+ final = payload["final"] == true
80
+ index = payload["index"]
81
+ cwd = payload["cwd"].to_s
82
+
83
+ return nil if message_id.empty? || session_id.empty?
84
+
85
+ dir = self.class.buffer_path(tmp_root: @tmp_root, session_id: session_id, message_id: message_id)
86
+
87
+ if index == 0
88
+ handle_chunk_zero(dir, delta, cwd, final)
89
+ else
90
+ handle_later_chunk(dir, index, delta, final)
91
+ end
92
+ end
93
+
94
+ # The message directory both this class and the bash launcher (hooks/
95
+ # message-display) must agree on byte for byte (matrix 40): the launcher
96
+ # checks this exact path's existence to decide whether chunk > 0 of an
97
+ # engaged message gets handed to Ruby at all.
98
+ def self.buffer_path(tmp_root:, session_id:, message_id:)
99
+ File.join(tmp_root, BUFFER_DIR_NAME, session_id, message_id)
100
+ end
101
+
102
+ def self.chunk_path(tmp_root:, session_id:, message_id:, index:)
103
+ File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), index.to_s)
104
+ end
105
+
106
+ def self.screen_path(tmp_root:, session_id:, message_id:)
107
+ File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), SCREEN_FILE)
108
+ end
109
+
110
+ def self.noscreen_path(tmp_root:, session_id:, message_id:)
111
+ File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), NOSCREEN_FILE)
112
+ end
113
+
114
+ private
115
+
116
+ # Chunk 0 decides, synchronously, before anything else touches this
117
+ # message: recognize the marker (after leading whitespace only) AND
118
+ # resolve the id, both before anything is buffered or blanked (F4). Either
119
+ # failure writes NOSCREEN so every later chunk can decide instantly rather
120
+ # than waiting out its own budget for a decision that will never arrive.
121
+ def handle_chunk_zero(dir, delta, cwd, final)
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
127
+ write_noscreen(dir)
128
+ return nil
129
+ end
130
+
131
+ write_screen(dir, resolved)
132
+ write_chunk(dir, 0, delta)
133
+ final ? finalize_final(dir, 0) : ""
134
+ end
135
+
136
+ # A later chunk (index > 0) never redoes chunk 0's work: it only asks
137
+ # whether a decision already exists, waiting for one (bounded) when it
138
+ # does not and the chunk looks like it could matter. The final chunk
139
+ # always waits for the decision regardless of its own shape.
140
+ def handle_later_chunk(dir, index, delta, final)
141
+ decision = wait_for_decision(dir, gate_delta: final ? nil : delta)
142
+
143
+ return nil unless decision == :screen
144
+
145
+ write_chunk(dir, index, delta)
146
+ final ? finalize_final(dir, index) : ""
147
+ end
148
+
149
+ # Checks for an existing decision first (free) and only pays the cheap
150
+ # shape test, then the bounded poll, when neither SCREEN nor NOSCREEN is
151
+ # there yet. `gate_delta: nil` (the final chunk) skips the shape test
152
+ # entirely and always polls for the decision.
153
+ def wait_for_decision(dir, gate_delta:)
154
+ decision = read_decision_now(dir)
155
+ return decision if decision
156
+
157
+ return :timeout if gate_delta && !maybe_screen?(gate_delta)
158
+
159
+ max_polls_for_budget.times do
160
+ @sleeper.call(@poll_ms / 1000.0)
161
+ decision = read_decision_now(dir)
162
+ return decision if decision
163
+ end
164
+
165
+ :timeout
166
+ end
167
+
168
+ def read_decision_now(dir)
169
+ return :screen if File.exist?(File.join(dir, SCREEN_FILE))
170
+ return :noscreen if File.exist?(File.join(dir, NOSCREEN_FILE))
171
+
172
+ nil
173
+ end
174
+
175
+ # Cheap, local, no file I/O: could this chunk's own delta plausibly be
176
+ # part of an intent screen (ignoring leading whitespace)? Every chunk of
177
+ # every ordinary prose message answers no, at zero cost.
178
+ def maybe_screen?(delta)
179
+ stripped = delta.lstrip
180
+ stripped.empty? || stripped.start_with?("|") || stripped.start_with?("**Steps**")
181
+ end
182
+
183
+ # The final chunk additionally waits (same budget) for every earlier chunk
184
+ # file to exist before it reassembles and splices. On timeout it proceeds
185
+ # anyway with whatever is there (matrix, lead's guard): never nil, never
186
+ # swallowed.
187
+ def finalize_final(dir, index)
188
+ wait_for_chunk_files(dir, index)
189
+
190
+ buffered = nil
191
+ begin
192
+ buffered = read_buffered_chunks(dir, index)
193
+ decision = read_screen_decision(dir)
194
+ finalize(buffered, decision)
195
+ rescue StandardError
196
+ buffered
197
+ ensure
198
+ FileUtils.rm_rf(dir)
199
+ end
200
+ end
201
+
202
+ def wait_for_chunk_files(dir, index)
203
+ return if index <= 0
204
+
205
+ needed = (0...index).map(&:to_s)
206
+ max_polls_for_budget.times do
207
+ return if needed.all? { |n| File.exist?(File.join(dir, n)) }
208
+
209
+ @sleeper.call(@poll_ms / 1000.0)
210
+ end
211
+ end
212
+
213
+ def max_polls_for_budget
214
+ return 0 unless @poll_ms.to_f.positive?
215
+
216
+ (@wait_ms / @poll_ms.to_f).ceil
217
+ end
218
+
219
+ # Whatever chunk files exist, in index order, concatenated -- gaps (a
220
+ # chunk that never arrived, or arrived too late) are skipped rather than
221
+ # blocking reassembly (lead's guard: never return nothing).
222
+ def read_buffered_chunks(dir, index)
223
+ (0..index).filter_map do |i|
224
+ path = File.join(dir, i.to_s)
225
+ File.exist?(path) ? File.read(path) : nil
226
+ end.join
227
+ end
228
+
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
234
+
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
248
+
249
+ def template_path
250
+ File.expand_path("../../templates/intent-screen.md", __dir__)
251
+ end
252
+
253
+ def write_chunk(dir, index, delta)
254
+ atomic_write(File.join(dir, index.to_s), delta)
255
+ end
256
+
257
+ # IntentScreen/IntentScreenAnsi's store_root: is the TIER root (what HOLDS
258
+ # store/ — e.g. .../projects/<slug> or plastic_home itself), never the
259
+ # 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")
262
+ end
263
+
264
+ def write_noscreen(dir)
265
+ atomic_write(File.join(dir, NOSCREEN_FILE), "")
266
+ end
267
+
268
+ def atomic_write(path, content)
269
+ FileUtils.mkdir_p(File.dirname(path))
270
+ tmp_path = "#{path}.tmp#{Process.pid}-#{rand(1_000_000)}"
271
+ File.write(tmp_path, content)
272
+ File.rename(tmp_path, path)
273
+ end
274
+
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
+
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
+
368
+ def prune_old_buffers
369
+ root = File.join(@tmp_root, BUFFER_DIR_NAME)
370
+ return unless File.directory?(root)
371
+
372
+ Dir.children(root).each do |session_dir|
373
+ full = File.join(root, session_dir)
374
+ next unless File.directory?(full)
375
+
376
+ age = @now.to_i - File.mtime(full).to_i
377
+ FileUtils.rm_rf(full) if age > BUFFER_MAX_AGE_SECONDS
378
+ end
379
+ rescue StandardError
380
+ nil
381
+ end
382
+ end
@@ -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}}