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