@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 +12 -0
- package/hooks/message-display +71 -0
- package/package.json +1 -1
- package/scripts/hook-message-display +65 -0
- package/scripts/intent-screen +25 -4
- package/scripts/lib/doctor_core.rb +4 -3
- package/scripts/lib/hook_registry.rb +12 -0
- package/scripts/lib/installer_core.rb +8 -0
- package/scripts/lib/intent_screen.rb +79 -17
- package/scripts/lib/intent_screen_ansi.rb +190 -0
- package/scripts/lib/message_display.rb +371 -0
- package/skills/intent-continuing/SKILL.md +5 -1
- package/templates/intent-screen.md +0 -5
package/hooks/hooks.json
CHANGED
|
@@ -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
|
@@ -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
|
package/scripts/intent-screen
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
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
|
|
31
|
-
# cut-inventory 3b (intent 309 added SessionEnd, registered for close since intent 301
|
|
32
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
head
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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.**
|
|
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,
|