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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/hooks/hooks.json 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,71 @@
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
+ IFS= read -r -d '' INPUT # returns 1 at EOF: do NOT set -e
28
+ case $INPUT in *'"message_id"'*) ;; *) exit 0 ;; esac
29
+ rest=${INPUT#*\"message_id\"}; rest=${rest#*\"}; mid=${rest%%\"*}
30
+ rest=${INPUT#*\"session_id\"}; rest=${rest#*\"}; sid=${rest%%\"*}
31
+
32
+ SCRIPT_DIR=${0%/*}
33
+ TMP_ROOT=${PLASTIC_TMP:-${TMPDIR:-/tmp}}
34
+ export PLASTIC_TMP="$TMP_ROOT"
35
+ MSGDIR="$TMP_ROOT/plastic-message-display/$sid/$mid"
36
+
37
+ is_index_zero=0
38
+ case $INPUT in *'"index":0'*|*'"index": 0'*) is_index_zero=1 ;; esac
39
+
40
+ is_final=0
41
+ case $INPUT in *'"final":true'*|*'"final": true'*) is_final=1 ;; esac
42
+
43
+ handoff=0
44
+ if [ "$is_index_zero" = 1 ]; then
45
+ # Chunk 0 decides synchronously; a bare "#" first delta, in either JSON
46
+ # spacing, is the only shape that can possibly open a screen. Must be a
47
+ # single "#", not "##" — a real screen's own first delta can be as short
48
+ # as "## " — a "##" glob would filter out exactly the message this hook
49
+ # exists to recognize.
50
+ case $INPUT in
51
+ *'"delta":"#'*|*'"delta": "#'*) handoff=1 ;;
52
+ esac
53
+ else
54
+ # A later chunk: hand off when this message's directory already exists
55
+ # (chunk 0 already left a decision or a chunk file), when this chunk is
56
+ # final (it must always be checked, whatever it looks like), or when its
57
+ # own delta is shaped like part of a screen — a Markdown table row, the
58
+ # "**Steps**" heading, or a blank line, in either JSON spacing.
59
+ [ -d "$MSGDIR" ] && handoff=1
60
+ [ "$is_final" = 1 ] && handoff=1
61
+ case $INPUT in
62
+ *'"delta":"|'*|*'"delta": "|'*) handoff=1 ;;
63
+ *'"delta":"**Steps**'*|*'"delta": "**Steps**'*) handoff=1 ;;
64
+ *'"delta":""'*|*'"delta": ""'*) handoff=1 ;;
65
+ *'"delta":"\n"'*|*'"delta": "\n"'*) handoff=1 ;;
66
+ esac
67
+ fi
68
+
69
+ [ "$handoff" = 1 ] || exit 0
70
+
71
+ 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.6",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,65 @@
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
+ require "json"
17
+ require "time"
18
+ require "tmpdir"
19
+ require "yaml"
20
+ require_relative "lib/message_display"
21
+
22
+ def color_enabled?(plastic_home)
23
+ return false unless ENV["NO_COLOR"].to_s.empty?
24
+
25
+ cfg_path = File.join(plastic_home, "config.yml")
26
+ return true unless File.exist?(cfg_path)
27
+
28
+ cfg = YAML.safe_load(File.read(cfg_path))
29
+ display = cfg.is_a?(Hash) ? cfg["display"] : nil
30
+ return true unless display.is_a?(Hash)
31
+
32
+ display.fetch("ansi_screen", true) != false
33
+ rescue StandardError
34
+ true
35
+ end
36
+
37
+ begin
38
+ raw = $stdin.tty? ? "" : $stdin.read
39
+ payload = raw.to_s.strip.empty? ? nil : JSON.parse(raw)
40
+
41
+ if payload.is_a?(Hash)
42
+ tmp_root = ENV["PLASTIC_TMP"].to_s.empty? ? Dir.tmpdir : ENV["PLASTIC_TMP"]
43
+ plastic_home = File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
44
+
45
+ handler = MessageDisplay.new(
46
+ tmp_root: tmp_root,
47
+ plastic_home: plastic_home,
48
+ color: color_enabled?(plastic_home),
49
+ now: Time.now,
50
+ )
51
+ result = handler.handle(payload)
52
+
53
+ if result.is_a?(String)
54
+ puts JSON.generate(
55
+ "hookSpecificOutput" => {
56
+ "hookEventName" => "MessageDisplay",
57
+ "displayContent" => result,
58
+ },
59
+ )
60
+ end
61
+ end
62
+ rescue StandardError
63
+ nil
64
+ end
65
+ exit 0
@@ -8,18 +8,27 @@
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)
21
29
 
22
30
  require_relative "lib/intent_screen"
31
+ require_relative "lib/intent_screen_ansi"
23
32
 
24
33
  def usage_abort(message)
25
34
  warn "intent-screen: #{message}"
@@ -28,18 +37,21 @@ end
28
37
 
29
38
  args = ARGV.dup
30
39
  template_path = nil
40
+ ansi = false
31
41
  positional = []
32
42
  while (arg = args.shift)
33
43
  case arg
34
44
  when "--template"
35
45
  template_path = args.shift or usage_abort("--template needs a path")
46
+ when "--ansi"
47
+ ansi = true
36
48
  else
37
49
  usage_abort("unknown flag #{arg.inspect}") if arg.start_with?("--")
38
50
  positional << arg
39
51
  end
40
52
  end
41
53
 
42
- usage_abort("usage: intent-screen <intent_dir> [--template <path>]") unless positional.length == 1
54
+ usage_abort("usage: intent-screen <intent_dir> [--template <path>] [--ansi]") unless positional.length == 1
43
55
  intent_dir = File.expand_path(positional.first)
44
56
  usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
45
57
 
@@ -47,6 +59,15 @@ store_root = File.expand_path("../..", intent_dir)
47
59
  template_path ||= File.expand_path("../templates/intent-screen.md", __dir__)
48
60
  usage_abort("template not found at #{template_path}") unless File.exist?(template_path)
49
61
 
50
- $stdout.write IntentScreen.render(intent_dir: intent_dir, store_root: store_root,
51
- template: File.read(template_path))
62
+ plain = -> { IntentScreen.render(intent_dir: intent_dir, store_root: store_root, template: File.read(template_path)) }
63
+
64
+ degrade_to_plain = ENV.key?("NO_COLOR") || !$stdout.tty?
65
+
66
+ $stdout.write(
67
+ if ansi && !degrade_to_plain
68
+ IntentScreenAnsi.render(intent_dir: intent_dir, store_root: store_root, color: true)
69
+ else
70
+ plain.call
71
+ end
72
+ )
52
73
  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,18 @@ module HookRegistry
54
54
  { "name" => "capture", "status" => "Capturing prompt into the session ledger..." },
55
55
  ] },
56
56
  ],
57
+ # Intent 316a (D6): Claude Code only, no Codex projection — MessageDisplay
58
+ # is not one of CODEX_LIVE_STATE_EVENTS, so codex_hooks_json (below) never
59
+ # picks it up; codex_hook_names stays exactly what it was (pinned by
60
+ # test/hook_registry_test.rb:82 and :110-111). Fires on every streamed
61
+ # chunk of every assistant message (D11); the launcher (hooks/message-
62
+ # display) decides with shell builtins and forks nothing on the common
63
+ # case, execing Ruby only for a candidate message.
64
+ "MessageDisplay" => [
65
+ { "matcher" => "", "hooks" => [
66
+ { "name" => "message-display", "status" => "" },
67
+ ] },
68
+ ],
57
69
  }
58
70
  end
59
71
 
@@ -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,17 @@
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.
7
15
  module IntentScreen
8
16
  BAR_WIDTH = 20
9
17
  ON = "█"
@@ -11,10 +19,22 @@ module IntentScreen
11
19
  PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
12
20
  SECTIONS = %w[Active Future Completed Abandoned].freeze
13
21
  ITEM_RE = /^\s*- \[([ xX])\]\s+(.*)$/
14
- STEP_PREFIX_RE = /\A(?:Step|S)\s*\d+\s*[-:·]\s*/i
22
+ # Em dash and en dash added (intent 316a O1e): a checklist item written
23
+ # "S1 — text" (the em dash every checklist this intent writes, and the one a
24
+ # reviewer reads, uses) kept its prefix under the old character class and
25
+ # rendered "S1 [ open ] S1 — text" on screen.
26
+ STEP_PREFIX_RE = /\A(?:Step|S)\s*\d+\s*[-:·—–]\s*/i
15
27
  INSIGHT_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s+·\s+\S+\s+·\s+.+?\s+—\s+(.+)\z/
16
28
  SAVEPOINT_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s{2,}(\S+)\s{2,}(.+?)\s*\z/
17
29
 
30
+ # Word-boundary truncation caps (intent 316a D3/O1a/O1c). Never a clause
31
+ # trim: a clause trim on step text destroys a pinned `OPEN:` row
32
+ # (test/intent_screen_test.rb:171-178) that a mid-sentence cut would eat.
33
+ INSIGHT_VALUE_MAX = 72
34
+ INSIGHT_NOTE_MAX = 96
35
+ NEXT_VALUE_MAX = 72
36
+ STEP_TEXT_MAX = 110
37
+
18
38
  # Where a resume lands, from the ledger's last line (the boarding matrix).
19
39
  def self.landing_stage(stage, milestone)
20
40
  case stage
@@ -54,8 +74,6 @@ module IntentScreen
54
74
  fields.merge!(next_fields(items, status, checklist_present: items_present?(intent_dir)))
55
75
  fields.merge!(insight_fields(intent_text))
56
76
  fields["steps.rows"] = steps_rows(items)
57
- fields["meaning"] = ""
58
- fields["close"] = ""
59
77
 
60
78
  out = template.dup
61
79
  fields.each { |k, v| out = out.gsub("{{#{k}}}", v.to_s) }
@@ -171,7 +189,11 @@ module IntentScreen
171
189
  "progress.note" => note }
172
190
  end
173
191
 
174
- def self.next_fields(items, status, checklist_present:)
192
+ # `escape_pipes:` (intent 316a O1d, default true) keeps the plain Markdown
193
+ # table's pipe-escaping; the ANSI renderer, which never emits a table,
194
+ # passes `escape_pipes: false` to get the raw value instead of a literal
195
+ # `\|`. Named to not shadow the module's own `escape` method.
196
+ def self.next_fields(items, status, checklist_present:, escape_pipes: true)
175
197
  return { "next" => "", "next.note" => "" } if %w[Completed Abandoned].include?(status)
176
198
  return { "next" => "write checklist.md", "next.note" => "How" } unless checklist_present
177
199
 
@@ -179,43 +201,83 @@ module IntentScreen
179
201
  return { "next" => "", "next.note" => "all steps done" } unless idx
180
202
 
181
203
  head, = split_first_clause(items[idx][:text])
182
- { "next" => "S#{idx + 1} · #{escape(head)}", "next.note" => "first open step" }
204
+ head = truncate_words(head, NEXT_VALUE_MAX)
205
+ head = escape(head) if escape_pipes
206
+ { "next" => "S#{idx + 1} · #{head}", "next.note" => "first open step" }
183
207
  end
184
208
 
185
209
  def self.steps_rows(items)
186
210
  return "| | | no steps yet |" if items.empty?
187
211
 
188
212
  items.each_with_index.map do |item, i|
189
- "| S#{i + 1} | #{item[:done] ? 'done' : 'open'} | #{escape(item[:text])} |"
213
+ "| S#{i + 1} | #{item[:done] ? 'done' : 'open'} | #{escape(step_text(item[:text]))} |"
190
214
  end.join("\n")
191
215
  end
192
216
 
217
+ # Public (intent 316a O1c) so the ANSI renderer trims step text identically:
218
+ # word-boundary truncation only, never a clause trim, at STEP_TEXT_MAX.
219
+ def self.step_text(text)
220
+ truncate_words(text, STEP_TEXT_MAX)
221
+ end
222
+
193
223
  def self.escape(text)
194
224
  text.gsub("|", "\\|")
195
225
  end
196
226
 
197
227
  # --- ## Insights ----------------------------------------------------------------
198
228
 
199
- def self.insight_fields(intent_text)
229
+ def self.insight_fields(intent_text, escape_pipes: true)
200
230
  section = intent_text.split(/^## Insights\s*$/, 2)[1].to_s.split(/^## /, 2)[0].to_s
201
231
  entry = section.lines.map(&:strip).reverse.map { |l| l.match(INSIGHT_RE) }.compact.first
202
232
  return { "insight" => "none yet", "insight.note" => "" } unless entry
203
233
 
204
234
  ts, text = entry[1], entry[2].strip
205
235
  head, tail = split_first_clause(text)
236
+ value = truncate_words(head, INSIGHT_VALUE_MAX)
237
+ tail = tail.empty? ? "" : truncate_words(tail, INSIGHT_NOTE_MAX)
206
238
  note = tail.empty? ? human_time(ts) : "#{human_time(ts)} · #{tail}"
207
- { "insight" => escape(head), "insight.note" => escape(note) }
239
+ if escape_pipes
240
+ { "insight" => escape(value), "insight.note" => escape(note) }
241
+ else
242
+ { "insight" => value, "insight.note" => note }
243
+ end
208
244
  end
209
245
 
246
+ # First clause of `text`, and at most one following clause as the tail.
247
+ # Anything past the second clause is discarded (intent 316a O1a): the old
248
+ # behavior dumped the ENTIRE remainder into the note (an 800-character
249
+ # real-world tail starting mid-list). Boundary is a `.` or `;` immediately
250
+ # followed by whitespace-then-more or end of string, so "alpha.2" and "2.0"
251
+ # are never mistaken for clause ends.
210
252
  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
253
+ head, rest = clause_and_rest(text)
254
+ return [head, ""] unless rest
255
+
256
+ second, more = clause_and_rest(rest)
257
+ tail = more ? second : rest
219
258
  [head, tail]
220
259
  end
260
+
261
+ # Returns [clause_without_terminal_punctuation, remainder_or_nil]. `nil` for
262
+ # the remainder means either no boundary exists at all, or the boundary
263
+ # sits at the absolute end of `text` (a single trailing clause with nothing
264
+ # after it) — both cases where there is no SECOND clause to fold in.
265
+ def self.clause_and_rest(text)
266
+ m = text.match(/\A(.+?)[.;](\s+(.*)|\z)/m)
267
+ return [text, nil] unless m
268
+
269
+ remainder = m[2].to_s.strip
270
+ remainder.empty? ? [m[1], nil] : [m[1], remainder]
271
+ end
272
+
273
+ # Word-boundary truncation with a trailing "…" when cut, never mid-word and
274
+ # never a clause trim (intent 316a D3).
275
+ def self.truncate_words(text, max)
276
+ return text if text.length <= max
277
+ return "…" if max <= 1
278
+
279
+ cut = text[0, max - 1].rindex(" ")
280
+ cut = max - 1 if cut.nil? || cut.zero?
281
+ "#{text[0, cut].rstrip}…"
282
+ end
221
283
  end
@@ -0,0 +1,190 @@
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
+ module IntentScreenAnsi
19
+ ESC = "\e"
20
+ RESET = "#{ESC}[0m".freeze
21
+ BOLD = "#{ESC}[1m".freeze
22
+ TEAL = "#{ESC}[38;2;45;212;191m".freeze
23
+ AMBER = "#{ESC}[38;2;245;158;11m".freeze
24
+ GRAPHITE_BG = "#{ESC}[48;2;31;41;55m".freeze
25
+ MIDGREY = "#{ESC}[38;2;148;163;184m".freeze
26
+ NEARWHITE = "#{ESC}[38;2;243;244;246m".freeze
27
+
28
+ BAR_CELLS = 24
29
+ EIGHTHS = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"].freeze
30
+
31
+ DEFAULT_WIDTH = 100
32
+
33
+ ELLIPSIS = "…"
34
+
35
+ def self.render(intent_dir:, store_root:, color: true, width: DEFAULT_WIDTH)
36
+ base = File.basename(intent_dir)
37
+ id = base.split("--", 2).first
38
+ intent_text = File.read(File.join(intent_dir, "#{base}.md"))
39
+
40
+ fields = {}
41
+ fields.merge!(IntentScreen.store_fields(store_root))
42
+ status, title = IntentScreen.index_fields(store_root, id)
43
+ fields["status"] = status
44
+ fields["status.note"] = status == "unlisted" ? "no INDEX.md line names this id" : "listed under ## #{status} in INDEX.md"
45
+ fields["id"] = id
46
+ fields["name"] = title || IntentScreen.fallback_name(intent_text)
47
+ fields.merge!(IntentScreen.savepoint_fields(intent_dir, intent_text))
48
+ items = IntentScreen.checklist_items(intent_dir)
49
+ fields.merge!(IntentScreen.progress_fields(items))
50
+ fields.merge!(IntentScreen.next_fields(items, status, checklist_present: IntentScreen.items_present?(intent_dir), escape_pipes: false))
51
+ fields.merge!(IntentScreen.insight_fields(intent_text, escape_pipes: false))
52
+ fields.transform_values! { |v| clean(v) }
53
+
54
+ done_n = fields["progress.done"].to_i
55
+ total_n = fields["progress.total"].to_i
56
+
57
+ out = +""
58
+ out << fit("▶ #{fields['id']} · #{fields['name']}", width) { |t| styled(t, color, BOLD, NEARWHITE) }
59
+ out << "\n\n"
60
+
61
+ # The 4th column marks a row whose value is already a finished, pre-fit
62
+ # string (the Progress bar, built above from styled glyphs plus a count)
63
+ # rather than raw field text still needing `fit_plain`. Naming that
64
+ # explicitly here reads better than testing the value for a leading ESC
65
+ # byte further down, which is really just asking "is this the Progress
66
+ # row?" through a type check.
67
+ field_rows = [
68
+ ["Store", fields["store"], fields["store.note"], false],
69
+ ["Status", fields["status"], fields["status.note"], false],
70
+ ["Stage", fields["stage"], fields["stage.note"], false],
71
+ ["Savepoint", fields["savepoint"], fields["savepoint.note"], false],
72
+ ["Progress", "#{render_bar(done_n, total_n, color)} #{done_n} / #{total_n}", fields["progress.note"], true],
73
+ ["Next", fields["next"], fields["next.note"], false],
74
+ ["Insight", fields["insight"], fields["insight.note"], false],
75
+ ]
76
+ key_width = field_rows.map { |k, _, _, _| k.length }.max
77
+ prefix_width = key_width + 4 # " " + key.ljust + " "
78
+
79
+ field_rows.each do |key, value, note, prebuilt|
80
+ value_budget = [width - prefix_width, 0].max
81
+ value_text = prebuilt ? value : fit_plain(value, value_budget)
82
+ out << " #{styled(key.ljust(key_width), color, BOLD)} #{value_text}\n"
83
+ next if note.to_s.empty?
84
+
85
+ note_budget = [width - prefix_width, 0].max
86
+ indent = " " * prefix_width
87
+ out << "#{indent}#{fit(note, note_budget) { |t| styled(t, color, MIDGREY) }}\n"
88
+ end
89
+
90
+ out << "\n"
91
+ out << fit("Steps", width) { |t| styled(t, color, BOLD, NEARWHITE) }
92
+ out << "\n\n"
93
+
94
+ if items.empty?
95
+ out << " no steps yet\n"
96
+ else
97
+ # Padded to the widest label (matrix B2): at 10+ steps "S10" is one
98
+ # column wider than "S1..S9", and without padding every badge past S9
99
+ # drifts out of column with the rows above it.
100
+ label_width = "S#{items.size}".length
101
+ items.each_with_index do |item, i|
102
+ num = "S#{i + 1}".ljust(label_width)
103
+ badge = status_cell(item[:done], color)
104
+ prefix_plain = " #{num} [ #{item[:done] ? 'done' : 'open'} ] "
105
+ text_budget = [width - prefix_plain.length, 0].max
106
+ text = fit_plain(clean(IntentScreen.step_text(item[:text])), text_budget)
107
+ out << " #{num} [#{badge}] #{text}\n"
108
+ end
109
+ end
110
+
111
+ out
112
+ end
113
+
114
+ # --- markdown-noise stripping (intent 316a, S1 answer 5 / matrix 19b) ------
115
+ #
116
+ # `displayContent` is still Markdown-processed by Claude Code even inside a
117
+ # raw ANSI block (a live capture showed backticks silently stripped from
118
+ # step text). Strip backticks and neutralise `*`/`_` runs from every value
119
+ # before it reaches the block, so nothing is left for that pass to act on.
120
+ # Single underscores are left alone: they are common inside ordinary words
121
+ # (`intent_screen.rb`) and GFM does not treat an intraword underscore as
122
+ # emphasis; only a run of 2+ (the bold marker `__`) is markdown-active.
123
+ def self.clean(text)
124
+ text.to_s.delete("`*").gsub(/_{2,}/, "")
125
+ end
126
+
127
+ # --- width cap (D15, matrix 18) --------------------------------------------
128
+
129
+ # Truncates `text` (already markdown-clean) to `max` visible columns with a
130
+ # trailing ellipsis when cut, then yields the truncated plain text to the
131
+ # block for coloring. Coloring never adds visible width.
132
+ def self.fit(text, max)
133
+ plain = fit_plain(text, max)
134
+ block_given? ? yield(plain) : plain
135
+ end
136
+
137
+ def self.fit_plain(text, max)
138
+ return "" if max <= 0
139
+ return text if text.length <= max
140
+ return ELLIPSIS[0, max] if max <= 1
141
+
142
+ "#{text[0, max - 1]}#{ELLIPSIS}"
143
+ end
144
+
145
+ # --- palette ----------------------------------------------------------------
146
+
147
+ def self.styled(text, color, *codes)
148
+ return text unless color
149
+
150
+ "#{codes.join}#{text}#{RESET}"
151
+ end
152
+
153
+ def self.status_cell(done, color)
154
+ label = done ? " done " : " open "
155
+ return label unless color
156
+
157
+ hue = done ? TEAL : AMBER
158
+ "#{hue}#{BOLD}#{label}#{RESET}"
159
+ end
160
+
161
+ # `.dup` matters, not just style (318's own note, carried forward): these
162
+ # constants are built via string interpolation, which frozen_string_literal
163
+ # does NOT freeze automatically — only static literals get that. `.freeze`
164
+ # above makes them immutable, but `bar << ...` below still needs its OWN
165
+ # mutable copy or it would raise (or, without the freeze, silently corrupt
166
+ # the shared constant for every later call in the same process — matrix 16).
167
+ def self.render_bar(done, total, color)
168
+ ratio = total.zero? ? 0.0 : done.to_f / total
169
+
170
+ unless color
171
+ on = total.zero? ? 0 : (done * BAR_CELLS) / total
172
+ return ("#" * on) + ("." * (BAR_CELLS - on))
173
+ end
174
+
175
+ units = (ratio * BAR_CELLS * 8).round.clamp(0, BAR_CELLS * 8)
176
+ full, rem = units.divmod(8)
177
+ full = [full, BAR_CELLS].min
178
+
179
+ bar = TEAL.dup
180
+ bar << ("█" * full)
181
+ if full < BAR_CELLS && rem.positive?
182
+ bar << EIGHTHS[rem]
183
+ full += 1
184
+ end
185
+ track = BAR_CELLS - full
186
+ bar << GRAPHITE_BG << (" " * track) if track.positive?
187
+ bar << RESET
188
+ bar
189
+ end
190
+ end
@@ -0,0 +1,371 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require_relative "intent_screen"
6
+ require_relative "intent_screen_ansi"
7
+ require_relative "store_discovery"
8
+ require_relative "store_provisioning"
9
+
10
+ # MessageDisplay (intent 316a, O4/O5, round 3 concurrency fix) - the Claude
11
+ # Code MessageDisplay hook handler. One process per streamed chunk of every
12
+ # assistant message (D11), so it must be cheap and decide fast. Pure: every
13
+ # dependency (tmp_root, plastic_home, color, now, wait_ms, poll_ms, sleeper)
14
+ # is a constructor argument, never an ENV read, a Dir.pwd/Dir.home read, or
15
+ # the real Time.now/Kernel#sleep — the thin CLI (scripts/hook-message-display)
16
+ # is the one place allowed to read any of those.
17
+ #
18
+ # A live run under a real pty (round 3) found that Claude Code fires the
19
+ # per-chunk hook processes CONCURRENTLY, not strictly in order. Chunk 0 is
20
+ # the one that recognizes the screen and creates the buffer (D13), and it can
21
+ # lose the race to chunks with a higher index: they would find no buffer yet
22
+ # and pass their raw Markdown straight through, producing a half plain /
23
+ # half styled screen. This class now survives that:
24
+ #
25
+ # - One file per chunk (index-named), written atomically (temp name in the
26
+ # same directory, then File.rename), so reassembly never depends on
27
+ # arrival order — only on the index each chunk already carries.
28
+ # - A decision file written BEFORE anything slow: chunk 0 writes SCREEN
29
+ # (the resolved intent dir + store root) the moment it engages, or
30
+ # NOSCREEN the moment it does not, so later chunks can decide without
31
+ # redoing any of chunk 0's work.
32
+ # - A later chunk asks a cheap, local question before ever waiting: could
33
+ # this delta plausibly be part of a screen (leading "|", "**Steps**", or
34
+ # blank)? An ordinary prose chunk arriving before SCREEN/NOSCREEN exists
35
+ # passes through at once, at zero cost. A chunk shaped like part of a
36
+ # screen polls for the decision, bounded (wait_ms/poll_ms), then fails
37
+ # open. The final chunk always waits for the decision, whatever its own
38
+ # shape, since it is the one that must not race — and it additionally
39
+ # waits (same budget) for every earlier chunk file to exist before it
40
+ # splices, returning whatever it does have rather than nothing when the
41
+ # budget runs out.
42
+ #
43
+ # Protocol (D13, preserved): chunk 0 still decides, once, before anything is
44
+ # buffered or blanked. D10 (any failure while finalizing returns the
45
+ # buffered original, never nil, never "") and D12 (color: false never
46
+ # buffers or blanks anything) are unchanged.
47
+ class MessageDisplay
48
+ MARKER_RE = /\A## ▶ (\S+) · /.freeze
49
+ BUFFER_DIR_NAME = "plastic-message-display"
50
+ BUFFER_MAX_AGE_SECONDS = 3600
51
+ SCREEN_FILE = "SCREEN"
52
+ NOSCREEN_FILE = "NOSCREEN"
53
+
54
+ def initialize(tmp_root:, plastic_home:, color:, now:, wait_ms: 300, poll_ms: 20,
55
+ sleeper: ->(seconds) { sleep(seconds) })
56
+ @tmp_root = tmp_root
57
+ @plastic_home = plastic_home
58
+ @color = color
59
+ @now = now
60
+ @wait_ms = wait_ms
61
+ @poll_ms = poll_ms
62
+ @sleeper = sleeper
63
+ end
64
+
65
+ def handle(payload)
66
+ return nil unless @color
67
+ return nil unless payload.is_a?(Hash)
68
+
69
+ prune_old_buffers
70
+
71
+ message_id = payload["message_id"].to_s
72
+ session_id = payload["session_id"].to_s
73
+ delta = payload["delta"].to_s
74
+ final = payload["final"] == true
75
+ index = payload["index"]
76
+ cwd = payload["cwd"].to_s
77
+
78
+ return nil if message_id.empty? || session_id.empty?
79
+
80
+ dir = self.class.buffer_path(tmp_root: @tmp_root, session_id: session_id, message_id: message_id)
81
+
82
+ if index == 0
83
+ handle_chunk_zero(dir, delta, cwd, final)
84
+ else
85
+ handle_later_chunk(dir, index, delta, final)
86
+ end
87
+ end
88
+
89
+ # The message directory both this class and the bash launcher (hooks/
90
+ # message-display) must agree on byte for byte (matrix 40): the launcher
91
+ # checks this exact path's existence to decide whether chunk > 0 of an
92
+ # engaged message gets handed to Ruby at all.
93
+ def self.buffer_path(tmp_root:, session_id:, message_id:)
94
+ File.join(tmp_root, BUFFER_DIR_NAME, session_id, message_id)
95
+ end
96
+
97
+ def self.chunk_path(tmp_root:, session_id:, message_id:, index:)
98
+ File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), index.to_s)
99
+ end
100
+
101
+ def self.screen_path(tmp_root:, session_id:, message_id:)
102
+ File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), SCREEN_FILE)
103
+ end
104
+
105
+ def self.noscreen_path(tmp_root:, session_id:, message_id:)
106
+ File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), NOSCREEN_FILE)
107
+ end
108
+
109
+ private
110
+
111
+ # Chunk 0 decides, synchronously, before anything else touches this
112
+ # message: recognize the marker (after leading whitespace only) AND
113
+ # resolve the id, both before anything is buffered or blanked (F4). Either
114
+ # failure writes NOSCREEN so every later chunk can decide instantly rather
115
+ # than waiting out its own budget for a decision that will never arrive.
116
+ def handle_chunk_zero(dir, delta, cwd, final)
117
+ stripped = delta.sub(/\A[ \t]+/, "")
118
+ m = stripped.match(MARKER_RE)
119
+ resolved = m && resolve_intent_dir(m[1], cwd)
120
+
121
+ unless resolved
122
+ write_noscreen(dir)
123
+ return nil
124
+ end
125
+
126
+ write_screen(dir, resolved)
127
+ write_chunk(dir, 0, delta)
128
+ final ? finalize_final(dir, 0) : ""
129
+ end
130
+
131
+ # A later chunk (index > 0) never redoes chunk 0's work: it only asks
132
+ # whether a decision already exists, waiting for one (bounded) when it
133
+ # does not and the chunk looks like it could matter. The final chunk
134
+ # always waits for the decision regardless of its own shape.
135
+ def handle_later_chunk(dir, index, delta, final)
136
+ decision = wait_for_decision(dir, gate_delta: final ? nil : delta)
137
+
138
+ return nil unless decision == :screen
139
+
140
+ write_chunk(dir, index, delta)
141
+ final ? finalize_final(dir, index) : ""
142
+ end
143
+
144
+ # Checks for an existing decision first (free) and only pays the cheap
145
+ # shape test, then the bounded poll, when neither SCREEN nor NOSCREEN is
146
+ # there yet. `gate_delta: nil` (the final chunk) skips the shape test
147
+ # entirely and always polls for the decision.
148
+ def wait_for_decision(dir, gate_delta:)
149
+ decision = read_decision_now(dir)
150
+ return decision if decision
151
+
152
+ return :timeout if gate_delta && !maybe_screen?(gate_delta)
153
+
154
+ max_polls_for_budget.times do
155
+ @sleeper.call(@poll_ms / 1000.0)
156
+ decision = read_decision_now(dir)
157
+ return decision if decision
158
+ end
159
+
160
+ :timeout
161
+ end
162
+
163
+ def read_decision_now(dir)
164
+ return :screen if File.exist?(File.join(dir, SCREEN_FILE))
165
+ return :noscreen if File.exist?(File.join(dir, NOSCREEN_FILE))
166
+
167
+ nil
168
+ end
169
+
170
+ # Cheap, local, no file I/O: could this chunk's own delta plausibly be
171
+ # part of an intent screen (ignoring leading whitespace)? Every chunk of
172
+ # every ordinary prose message answers no, at zero cost.
173
+ def maybe_screen?(delta)
174
+ stripped = delta.lstrip
175
+ stripped.empty? || stripped.start_with?("|") || stripped.start_with?("**Steps**")
176
+ end
177
+
178
+ # The final chunk additionally waits (same budget) for every earlier chunk
179
+ # file to exist before it reassembles and splices. On timeout it proceeds
180
+ # anyway with whatever is there (matrix, lead's guard): never nil, never
181
+ # swallowed.
182
+ def finalize_final(dir, index)
183
+ wait_for_chunk_files(dir, index)
184
+
185
+ buffered = nil
186
+ begin
187
+ buffered = read_buffered_chunks(dir, index)
188
+ decision = read_screen_decision(dir)
189
+ finalize(buffered, decision)
190
+ rescue StandardError
191
+ buffered
192
+ ensure
193
+ FileUtils.rm_rf(dir)
194
+ end
195
+ end
196
+
197
+ def wait_for_chunk_files(dir, index)
198
+ return if index <= 0
199
+
200
+ needed = (0...index).map(&:to_s)
201
+ max_polls_for_budget.times do
202
+ return if needed.all? { |n| File.exist?(File.join(dir, n)) }
203
+
204
+ @sleeper.call(@poll_ms / 1000.0)
205
+ end
206
+ end
207
+
208
+ def max_polls_for_budget
209
+ return 0 unless @poll_ms.to_f.positive?
210
+
211
+ (@wait_ms / @poll_ms.to_f).ceil
212
+ end
213
+
214
+ # Whatever chunk files exist, in index order, concatenated -- gaps (a
215
+ # chunk that never arrived, or arrived too late) are skipped rather than
216
+ # blocking reassembly (lead's guard: never return nothing).
217
+ def read_buffered_chunks(dir, index)
218
+ (0..index).filter_map do |i|
219
+ path = File.join(dir, i.to_s)
220
+ File.exist?(path) ? File.read(path) : nil
221
+ end.join
222
+ end
223
+
224
+ def read_screen_decision(dir)
225
+ content = File.read(File.join(dir, SCREEN_FILE))
226
+ intent_dir, store_root = content.split("\n")
227
+ { intent_dir: intent_dir, store_root: store_root }
228
+ end
229
+
230
+ def finalize(buffered, decision)
231
+ intent_dir = decision[:intent_dir]
232
+ store_root = decision[:store_root]
233
+ ansi = IntentScreenAnsi.render(intent_dir: intent_dir, store_root: store_root, color: true)
234
+ plain = IntentScreen.render(intent_dir: intent_dir, store_root: store_root, template: File.read(template_path))
235
+ splice(buffered, plain, ansi)
236
+ end
237
+
238
+ def template_path
239
+ File.expand_path("../../templates/intent-screen.md", __dir__)
240
+ end
241
+
242
+ def write_chunk(dir, index, delta)
243
+ atomic_write(File.join(dir, index.to_s), delta)
244
+ end
245
+
246
+ # IntentScreen/IntentScreenAnsi's store_root: is the TIER root (what HOLDS
247
+ # store/ — e.g. .../projects/<slug> or plastic_home itself), never the
248
+ # store/ directory itself; resolve_intent_dir's `root:` is already that.
249
+ def write_screen(dir, resolved)
250
+ atomic_write(File.join(dir, SCREEN_FILE), "#{resolved[:intent_dir]}\n#{resolved[:root]}\n")
251
+ end
252
+
253
+ def write_noscreen(dir)
254
+ atomic_write(File.join(dir, NOSCREEN_FILE), "")
255
+ end
256
+
257
+ def atomic_write(path, content)
258
+ FileUtils.mkdir_p(File.dirname(path))
259
+ tmp_path = "#{path}.tmp#{Process.pid}-#{rand(1_000_000)}"
260
+ File.write(tmp_path, content)
261
+ File.rename(tmp_path, path)
262
+ end
263
+
264
+ # D16: replace the plain render's own text wherever it sits in the buffered
265
+ # message, keeping everything after it verbatim. Falls back to a line-based
266
+ # boundary (the "## ▶ " line through the last line starting with "|") only
267
+ # when the buffered text does not start with the plain render exactly (the
268
+ # model reformatted something, or a chunk gap broke the exact match) — the
269
+ # fallback also has to work for a checklist-less intent, whose only Steps
270
+ # row is "| | | no steps yet |".
271
+ def splice(buffered, plain, ansi)
272
+ suffix =
273
+ if buffered.start_with?(plain)
274
+ buffered[plain.length..]
275
+ else
276
+ line_based_suffix(buffered, plain)
277
+ end
278
+ return buffered if suffix.nil?
279
+
280
+ "#{ansi.rstrip}\n\n#{suffix}"
281
+ end
282
+
283
+ # Bounded fallback (matrix, lead's B1): walk forward from the "## ▶ " line
284
+ # only through the screen's OWN contiguous run of blank lines, "|"-prefixed
285
+ # table rows and the "**Steps**" heading, and stop at the first line that is
286
+ # none of those. The boundary is the last "|" line seen before that stop —
287
+ # never the last "|" line anywhere in the message. Scanning to the end
288
+ # unbounded (the old behavior) swallows any prose the model wrote between
289
+ # the screen and an unrelated Markdown table further down (a real hazard:
290
+ # Plastic replies carry tables often).
291
+ def line_based_suffix(buffered, plain)
292
+ lines = buffered.lines
293
+ start_idx = lines.index { |l| l.start_with?("## ▶ ") }
294
+ return nil unless start_idx
295
+
296
+ last_pipe_idx = nil
297
+ i = start_idx + 1
298
+ while i < lines.length
299
+ line = lines[i]
300
+ stripped = line.strip
301
+ break unless stripped.empty? || line.start_with?("|") || stripped == "**Steps**"
302
+
303
+ last_pipe_idx = i if line.start_with?("|")
304
+ i += 1
305
+ end
306
+ return nil unless last_pipe_idx
307
+
308
+ # Guard: never let the bounded scan consume more lines than the freshly
309
+ # rendered plain screen itself has. If it would, something about the
310
+ # buffered text does not match the shape splice() expects at all — pass
311
+ # the original through rather than risk eating real prose.
312
+ consumed = last_pipe_idx + 1 - start_idx
313
+ return nil if consumed > plain.lines.length
314
+
315
+ lines[(last_pipe_idx + 1)..].join
316
+ end
317
+
318
+ # O5: candidates are every discovered store holding a "<id>--*" directory.
319
+ # A single candidate resolves outright (no ambiguity to break). With two or
320
+ # more, the store whose project root is a path prefix of the payload's cwd
321
+ # decides; if that narrows to anything other than exactly one, pass through
322
+ # rather than guess (matrix 36).
323
+ #
324
+ # "cwd is a path prefix" is checked against the project's REAL checkout
325
+ # path (projects.yml's own `path:`, e.g. ~/apps/personal/plastic) — never
326
+ # against StoreDiscovery's `root` (~/.plastic/projects/<slug>, which only
327
+ # holds INDEX.md and store/). Those are two different directories; a real
328
+ # session's cwd lives under the former, never the latter. The global store
329
+ # has no such checkout path, so it never wins by cwd — only by being the
330
+ # sole candidate.
331
+ def resolve_intent_dir(id, cwd)
332
+ pattern = "#{glob_escape(id)}--*"
333
+ candidates = StoreDiscovery.discover(@plastic_home)[:stores].filter_map do |s|
334
+ dir = Dir.glob(File.join(s[:store], pattern)).find { |d| File.directory?(d) }
335
+ dir && { slug: s[:slug], root: s[:root], intent_dir: dir }
336
+ end
337
+ return nil if candidates.empty?
338
+ return candidates.first if candidates.length == 1
339
+
340
+ registered = StoreProvisioning.load_projects(@plastic_home)
341
+ cwd_matches = candidates.select do |c|
342
+ real_path = registered.dig(c[:slug], "path")
343
+ real_path && (cwd == real_path || cwd.start_with?("#{real_path}#{File::SEPARATOR}"))
344
+ end
345
+ return cwd_matches.first if cwd_matches.length == 1
346
+
347
+ nil
348
+ end
349
+
350
+ # A recognized id should just be [A-Za-z0-9]+, but the id comes out of the
351
+ # assistant's own streamed text, not a trusted schema — escape glob
352
+ # metacharacters rather than assume it is well-formed.
353
+ def glob_escape(str)
354
+ str.gsub(/([*?\[\]{}])/) { "\\#{Regexp.last_match(1)}" }
355
+ end
356
+
357
+ def prune_old_buffers
358
+ root = File.join(@tmp_root, BUFFER_DIR_NAME)
359
+ return unless File.directory?(root)
360
+
361
+ Dir.children(root).each do |session_dir|
362
+ full = File.join(root, session_dir)
363
+ next unless File.directory?(full)
364
+
365
+ age = @now.to_i - File.mtime(full).to_i
366
+ FileUtils.rm_rf(full) if age > BUFFER_MAX_AGE_SECONDS
367
+ end
368
+ rescue StandardError
369
+ nil
370
+ end
371
+ end
@@ -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}}