@zalom/plastic 2.0.0-alpha.8 → 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/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 +1 -0
- package/scripts/lib/intent_screen.rb +5 -2
- package/scripts/lib/message_display.rb +35 -127
- package/scripts/lib/report_screen.rb +91 -29
- package/scripts/lib/screen_paint.rb +218 -0
- package/scripts/report-screen +15 -14
- package/skills/auto/SKILL.md +2 -1
- package/skills/conventions/references/locks-and-worktrees.md +12 -0
- package/skills/intent-ending/SKILL.md +8 -5
- package/templates/outcome.md +10 -2
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/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)}"
|
|
@@ -452,6 +452,7 @@ class InstallerCore
|
|
|
452
452
|
# entries are their only protection (test/install_sync_test.rb:23-29
|
|
453
453
|
# greps installer_core.rb's own source text for "scripts/<name>").
|
|
454
454
|
"scripts/lib/intent_screen_ansi.rb" => "scripts/lib/intent_screen_ansi.rb",
|
|
455
|
+
"scripts/lib/screen_paint.rb" => "scripts/lib/screen_paint.rb",
|
|
455
456
|
"scripts/lib/message_display.rb" => "scripts/lib/message_display.rb",
|
|
456
457
|
"scripts/hook-message-display" => "scripts/hook-message-display",
|
|
457
458
|
}
|
|
@@ -24,8 +24,11 @@ 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/
|
|
31
34
|
# Intent 317, D6: field-2 tokens that are genuine lifecycle stages. A ledger
|
|
@@ -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)
|
|
@@ -139,6 +139,31 @@ module ReportScreen
|
|
|
139
139
|
body.empty? ? NOT_RECORDED : body
|
|
140
140
|
end
|
|
141
141
|
|
|
142
|
+
PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
|
|
143
|
+
|
|
144
|
+
# 317a S3 (A6): the note under Asked. Bulleted decisions in a real spec keep
|
|
145
|
+
# the historic "N decisions in spec.md"; a prose ## Decisions falls back to
|
|
146
|
+
# the highest D<n> it names; a placeholder spec falls through to the intent
|
|
147
|
+
# record's "### Decisions" (which section_of's "^## " anchor cannot reach);
|
|
148
|
+
# nothing anywhere says "decisions not recorded" - never a false 0, and the
|
|
149
|
+
# scaffold's "- ..." never counts as 1.
|
|
150
|
+
def self.decision_note(intent_dir)
|
|
151
|
+
spec = spec_text(intent_dir)
|
|
152
|
+
if spec && !spec.lstrip.start_with?(PLACEHOLDER_SENTINEL)
|
|
153
|
+
n = decisions_in(section_of(spec, "## Decisions"))
|
|
154
|
+
return "#{n} decisions in spec.md" if n.positive?
|
|
155
|
+
end
|
|
156
|
+
n = decisions_in(intent_text(intent_dir).to_s.split(/^### Decisions\s*$/, 2)[1].to_s.split(/^#+ /, 2)[0])
|
|
157
|
+
return "#{n} decisions in the intent record" if n.positive?
|
|
158
|
+
"decisions not recorded"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def self.decisions_in(body)
|
|
162
|
+
bullets = body.to_s.lines.count { |l| s = l.lstrip; s.start_with?("- ") && s.strip != "- ..." }
|
|
163
|
+
return bullets if bullets.positive?
|
|
164
|
+
body.to_s.scan(/\bD(\d{1,3})\b/).flatten.map(&:to_i).max.to_i
|
|
165
|
+
end
|
|
166
|
+
|
|
142
167
|
# Row 22: bullets under spec.md's ## Decisions only.
|
|
143
168
|
def self.decision_count(intent_dir)
|
|
144
169
|
text = spec_text(intent_dir)
|
|
@@ -156,10 +181,28 @@ module ReportScreen
|
|
|
156
181
|
rows = table_rows(section)
|
|
157
182
|
return rows.map { |cells| { label: cells[0].to_s, text: cells[1].to_s } } if rows.any?
|
|
158
183
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
184
|
+
bullet_rows(section).each_with_index.map do |text, i|
|
|
185
|
+
{ label: (i + 1).to_s, text: text }
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# 317a S1 (matrix S1a/S1b): a bullet row is its "- " line PLUS its wrapped
|
|
190
|
+
# continuation lines - outcome prose is hand-wrapped at ~100 columns, and
|
|
191
|
+
# taking one physical line truncated every real record mid-sentence. A blank
|
|
192
|
+
# line or a heading ends the row; prose after a blank is never swept in.
|
|
193
|
+
def self.bullet_rows(section)
|
|
194
|
+
rows = []
|
|
195
|
+
section.to_s.each_line do |line|
|
|
196
|
+
stripped = line.strip
|
|
197
|
+
if line.lstrip.start_with?("- ")
|
|
198
|
+
rows << line.lstrip.sub(/\A-\s*/, "").strip
|
|
199
|
+
elsif stripped.empty? || line.start_with?("#")
|
|
200
|
+
rows << nil unless rows.empty? || rows.last.nil?
|
|
201
|
+
elsif !rows.empty? && !rows.last.nil?
|
|
202
|
+
rows[rows.length - 1] = "#{rows.last} #{stripped}"
|
|
203
|
+
end
|
|
162
204
|
end
|
|
205
|
+
rows.compact
|
|
163
206
|
end
|
|
164
207
|
|
|
165
208
|
# Rows 25-27: D19 - the label must appear as a standalone token in an action
|
|
@@ -189,9 +232,21 @@ module ReportScreen
|
|
|
189
232
|
return [] unless text.include?("## Needs you")
|
|
190
233
|
section = section_of(text, "## Needs you")
|
|
191
234
|
rows = table_rows(section)
|
|
192
|
-
rows.
|
|
193
|
-
|
|
235
|
+
if rows.any?
|
|
236
|
+
return rows.each_with_index.map do |cells, i|
|
|
237
|
+
{ n: "N#{i + 1}", what: cells[1].to_s, why: cells[2].to_s }
|
|
238
|
+
end
|
|
194
239
|
end
|
|
240
|
+
|
|
241
|
+
# 317a S2 (matrix S2a): prose that exists must never render as None - the
|
|
242
|
+
# 317 record hid three owner picks behind exactly that. One joined row,
|
|
243
|
+
# why "not recorded"; a literal None (or an empty section) stays [].
|
|
244
|
+
content = section.gsub(/<!--.*?-->/m, "").strip
|
|
245
|
+
return [] if content.empty? || content == "None"
|
|
246
|
+
|
|
247
|
+
what = content.lines.map(&:strip).reject(&:empty?)
|
|
248
|
+
.join(" ").sub(/\A-\s*/, "").squeeze(" ")
|
|
249
|
+
[{ n: "N1", what: what, why: NOT_RECORDED }]
|
|
195
250
|
end
|
|
196
251
|
|
|
197
252
|
# Row 35: first-to-last savepoint timestamp, "1 h 51 min" / "n min".
|
|
@@ -212,9 +267,27 @@ module ReportScreen
|
|
|
212
267
|
def self.mode(intent_dir)
|
|
213
268
|
data = Lock.read(intent_dir)
|
|
214
269
|
value = data && data["run_mode"]
|
|
270
|
+
return value.to_s if value && !value.to_s.empty?
|
|
271
|
+
|
|
272
|
+
# 317a S7 (D5): after the close the lock is gone; end-intent stamps the
|
|
273
|
+
# run_mode into outcome.md frontmatter, so mode stops being unknowable
|
|
274
|
+
# retrospectively. Live lock first - it is the source of truth mid-flight.
|
|
275
|
+
value = outcome_frontmatter(intent_dir)["mode"]
|
|
215
276
|
value && !value.to_s.empty? ? value.to_s : NOT_RECORDED
|
|
216
277
|
end
|
|
217
278
|
|
|
279
|
+
def self.outcome_frontmatter(intent_dir)
|
|
280
|
+
text = outcome_text(intent_dir)
|
|
281
|
+
return {} unless text && text.start_with?("---")
|
|
282
|
+
parts = text.split("---", 3)
|
|
283
|
+
return {} if parts.length < 3
|
|
284
|
+
require "yaml"
|
|
285
|
+
require "date"
|
|
286
|
+
YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
|
|
287
|
+
rescue StandardError
|
|
288
|
+
{}
|
|
289
|
+
end
|
|
290
|
+
|
|
218
291
|
# --- evidence rows (rows 28-33, 37) --------------------------------------------
|
|
219
292
|
|
|
220
293
|
def self.suite_row(section)
|
|
@@ -460,7 +533,7 @@ module ReportScreen
|
|
|
460
533
|
lines << ""
|
|
461
534
|
lines << "**Asked**"
|
|
462
535
|
lines << " #{asked(intent_dir)}"
|
|
463
|
-
lines << " #{
|
|
536
|
+
lines << " #{decision_note(intent_dir)}"
|
|
464
537
|
lines << ""
|
|
465
538
|
lines << "**Delivered**"
|
|
466
539
|
lines << "| Row | What | Proven by |"
|
|
@@ -470,10 +543,18 @@ module ReportScreen
|
|
|
470
543
|
end
|
|
471
544
|
lines << ""
|
|
472
545
|
lines << "**Evidence**"
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
546
|
+
ev = evidence_rows(intent_dir, tag_reader: tag_reader)
|
|
547
|
+
if ev.empty?
|
|
548
|
+
# 317a S4 (matrix S4a): a header-only table (319's live rendering) says
|
|
549
|
+
# nothing; the honest floor is the same phrase every other absent source
|
|
550
|
+
# prints.
|
|
551
|
+
lines << NOT_RECORDED
|
|
552
|
+
else
|
|
553
|
+
lines << "| Kind | What | Source |"
|
|
554
|
+
lines << "| --- | --- | --- |"
|
|
555
|
+
ev.each do |r|
|
|
556
|
+
lines << "| #{r[:kind]} | #{escape(r[:what])} | #{escape(r[:source])} |"
|
|
557
|
+
end
|
|
477
558
|
end
|
|
478
559
|
lines << ""
|
|
479
560
|
needsyou = needs_you_rows(intent_dir)
|
|
@@ -564,23 +645,4 @@ module ReportScreen
|
|
|
564
645
|
# A renderer file, when present, is expected to define IntentScreenAnsi.paint
|
|
565
646
|
# (one plain-text string in, one string out). Wiring the real contract 316a
|
|
566
647
|
# ships is left to a follow-up step once that file exists (see checklist S14).
|
|
567
|
-
def self.maybe_paint(text, renderer_path:, enabled:)
|
|
568
|
-
return text unless enabled
|
|
569
|
-
return text unless renderer_path && File.exist?(renderer_path)
|
|
570
|
-
|
|
571
|
-
begin
|
|
572
|
-
require renderer_path
|
|
573
|
-
rescue LoadError, StandardError
|
|
574
|
-
return text
|
|
575
|
-
end
|
|
576
|
-
|
|
577
|
-
mod = Object.const_get(:IntentScreenAnsi) if Object.const_defined?(:IntentScreenAnsi)
|
|
578
|
-
return text unless mod && mod.respond_to?(:paint)
|
|
579
|
-
|
|
580
|
-
begin
|
|
581
|
-
mod.paint(text)
|
|
582
|
-
rescue StandardError
|
|
583
|
-
text
|
|
584
|
-
end
|
|
585
|
-
end
|
|
586
648
|
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "intent_screen_ansi"
|
|
5
|
+
|
|
6
|
+
# ScreenPaint (intent 317a, D1) - the paint seam 317's Needs-you named. Parses
|
|
7
|
+
# the plain Markdown screens our own renderers emit (intent, state, roster,
|
|
8
|
+
# delivered, delay) and re-lays them out in the shipped intent-screen ANSI
|
|
9
|
+
# vocabulary. A parser and RE-LAYOUTER, not a colorizer (A5): the plain
|
|
10
|
+
# screens are pipe tables whose scaffolding rows only disappear under a
|
|
11
|
+
# Markdown renderer, so the painter drops them and rebuilds the layout;
|
|
12
|
+
# content-survival is the contract - every value and note survives, nothing
|
|
13
|
+
# is invented, and text it does not recognize returns nil so every caller
|
|
14
|
+
# fails open to plain.
|
|
15
|
+
#
|
|
16
|
+
# Harness-agnostic core: no harness assumption lives here. No ENV, no TTY. Color, width, and
|
|
17
|
+
# markdown_safe are caller arguments, exactly like IntentScreenAnsi before it
|
|
18
|
+
# (316a1); the 318 ceiling holds - the palette is IntentScreenAnsi's, no new
|
|
19
|
+
# colors, no box borders.
|
|
20
|
+
module ScreenPaint
|
|
21
|
+
A = IntentScreenAnsi
|
|
22
|
+
|
|
23
|
+
# A screen's first line: "## ▶ id · name", "## ✔ id · name · delivered",
|
|
24
|
+
# "▶ In delivery · ...", "✔ id · name · delivered in ...".
|
|
25
|
+
OPENER_RE = /\A(?:## )?[▶✔] .+ · /.freeze
|
|
26
|
+
|
|
27
|
+
FIELD_LINE_RE = /\A(Stage|Next|Changed|Lead|Progress)(\s{2,})(.*)\z/.freeze
|
|
28
|
+
STEP_LINE_RE = /\A(S\d+)\s+\[ (open|done) \]\s+(.*)\z/.freeze
|
|
29
|
+
TIMELINE_RE = /\A(\d\d:\d\d)\s{2}(\S+)\s{2}(.*)\z/.freeze
|
|
30
|
+
COUNT_LINE_RE = /\A\d+ open( · .*)?\z/.freeze
|
|
31
|
+
BOLD_LEAD_RE = /\A\*\*([^*]+)\*\*(.*)\z/.freeze
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# The classifier both paint and region_end share. `idx`/`opener_idx` give
|
|
36
|
+
# the positional rule its footing: the line right after a title is the meta
|
|
37
|
+
# line (delivered/delay print one), recognizable by its " · " separators.
|
|
38
|
+
def classify(line, idx: nil, opener_idx: nil)
|
|
39
|
+
text = line.chomp
|
|
40
|
+
stripped = text.strip
|
|
41
|
+
return :blank if stripped.empty?
|
|
42
|
+
return :opener if OPENER_RE.match?(stripped) && text == stripped
|
|
43
|
+
return :table if text.lstrip.start_with?("|")
|
|
44
|
+
return :bold if BOLD_LEAD_RE.match?(stripped) && text == stripped
|
|
45
|
+
return :meta if idx && opener_idx && idx == opener_idx + 1 && stripped.include?(" · ")
|
|
46
|
+
return :indented if text.start_with?(" ")
|
|
47
|
+
return :field if FIELD_LINE_RE.match?(text)
|
|
48
|
+
return :step if STEP_LINE_RE.match?(text)
|
|
49
|
+
return :timeline if TIMELINE_RE.match?(text)
|
|
50
|
+
return :count if COUNT_LINE_RE.match?(stripped)
|
|
51
|
+
return :closer if ["None", "not recorded", "No intents in delivery."].include?(stripped)
|
|
52
|
+
:unknown
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Where the screen region ends inside a larger message (B10): walk from the
|
|
56
|
+
# opener while every line classifies; the first unknown line - ordinary
|
|
57
|
+
# prose, a prose bullet - is the boundary. Never consumes past the screen.
|
|
58
|
+
def region_end(lines, start_idx)
|
|
59
|
+
i = start_idx + 1
|
|
60
|
+
while i < lines.length
|
|
61
|
+
kind = classify(lines[i], idx: i, opener_idx: start_idx)
|
|
62
|
+
break if kind == :unknown
|
|
63
|
+
# A bare "**Section**" head belongs to the screen only when what follows
|
|
64
|
+
# is still grammar; "**What this means**" over prose bullets is the
|
|
65
|
+
# model's own commentary and stays outside, unsplit (B10).
|
|
66
|
+
if kind == :bold && bare_bold?(lines[i]) && !grammar_follows?(lines, i, start_idx)
|
|
67
|
+
break
|
|
68
|
+
end
|
|
69
|
+
i += 1
|
|
70
|
+
end
|
|
71
|
+
# Trailing blanks belong to the message, not the screen.
|
|
72
|
+
i -= 1 while i > start_idx + 1 && lines[i - 1].strip.empty?
|
|
73
|
+
i
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def bare_bold?(line)
|
|
77
|
+
m = BOLD_LEAD_RE.match(line.strip)
|
|
78
|
+
m && m[2].to_s.strip.empty?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def grammar_follows?(lines, idx, opener_idx)
|
|
82
|
+
j = idx + 1
|
|
83
|
+
j += 1 while j < lines.length && lines[j].strip.empty?
|
|
84
|
+
return false if j >= lines.length
|
|
85
|
+
kind = classify(lines[j], idx: j, opener_idx: opener_idx)
|
|
86
|
+
kind != :unknown && kind != :opener
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The painter. Returns the ANSI (or plain re-laid, when color: false) text,
|
|
90
|
+
# or nil when the input does not open with a screen title or carries a line
|
|
91
|
+
# outside the grammar - the caller's cue to print the original untouched.
|
|
92
|
+
def paint(text, color: true, width: A::DEFAULT_WIDTH, markdown_safe: false)
|
|
93
|
+
lines = text.to_s.lines
|
|
94
|
+
first_idx = lines.index { |l| !l.strip.empty? }
|
|
95
|
+
return nil if first_idx.nil?
|
|
96
|
+
return nil unless classify(lines[first_idx]) == :opener
|
|
97
|
+
|
|
98
|
+
out = +""
|
|
99
|
+
table = []
|
|
100
|
+
ok = true
|
|
101
|
+
|
|
102
|
+
flush = lambda do
|
|
103
|
+
next if table.empty?
|
|
104
|
+
out << paint_table(table, color: color, width: width, markdown_safe: markdown_safe)
|
|
105
|
+
table.clear
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
lines.each_with_index do |line, idx|
|
|
109
|
+
kind = classify(line, idx: idx, opener_idx: first_idx)
|
|
110
|
+
if kind == :table
|
|
111
|
+
table << line.strip
|
|
112
|
+
next
|
|
113
|
+
end
|
|
114
|
+
flush.call
|
|
115
|
+
case kind
|
|
116
|
+
when :opener
|
|
117
|
+
t = clean(line.strip.sub(/\A## /, ""), markdown_safe)
|
|
118
|
+
out << A.fit(t, width) { |s| A.styled(s, color, A::BOLD, A::NEARWHITE) } << "\n"
|
|
119
|
+
when :meta
|
|
120
|
+
out << A.fit(clean(line.strip, markdown_safe), width) { |s| A.styled(s, color, A::MIDGREY) } << "\n"
|
|
121
|
+
when :bold
|
|
122
|
+
m = BOLD_LEAD_RE.match(line.strip)
|
|
123
|
+
head = A.styled(clean(m[1], markdown_safe), color, A::BOLD, A::NEARWHITE)
|
|
124
|
+
out << head << clean(m[2], markdown_safe) << "\n"
|
|
125
|
+
when :indented
|
|
126
|
+
out << A.fit_plain(clean(line.chomp, markdown_safe), width) << "\n"
|
|
127
|
+
when :field
|
|
128
|
+
m = FIELD_LINE_RE.match(line.chomp)
|
|
129
|
+
out << A.styled(m[1].ljust(8), color, A::BOLD) << " " << clean(m[3], markdown_safe) << "\n"
|
|
130
|
+
when :step
|
|
131
|
+
m = STEP_LINE_RE.match(line.chomp)
|
|
132
|
+
badge = A.status_cell(m[2] == "done", color)
|
|
133
|
+
out << "#{m[1].ljust(4)} [#{badge}] #{A.fit_plain(clean(m[3], markdown_safe), width - 12)}\n"
|
|
134
|
+
when :timeline
|
|
135
|
+
m = TIMELINE_RE.match(line.chomp)
|
|
136
|
+
out << A.styled(m[1], color, A::MIDGREY) << " " << A.styled(m[2].ljust(6), color, A::BOLD) \
|
|
137
|
+
<< " " << clean(m[3], markdown_safe) << "\n"
|
|
138
|
+
when :count
|
|
139
|
+
out << A.fit(line.strip, width) { |s| A.styled(s, color, A::MIDGREY) } << "\n"
|
|
140
|
+
when :closer
|
|
141
|
+
out << A.styled(line.strip, color, A::MIDGREY) << "\n"
|
|
142
|
+
when :blank
|
|
143
|
+
out << "\n"
|
|
144
|
+
else
|
|
145
|
+
ok = false
|
|
146
|
+
break
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
flush.call
|
|
150
|
+
return nil unless ok
|
|
151
|
+
|
|
152
|
+
paint_bars(out.gsub(/\n{3,}/, "\n\n"), color)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# --- tables -----------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
SEPARATOR_RE = /\A\|[\s:|-]+\|?\z/.freeze
|
|
158
|
+
|
|
159
|
+
def cells_of(row)
|
|
160
|
+
row.split("|", -1).map(&:strip)[1..-2].to_a
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def field_table?(rows)
|
|
164
|
+
rows.first&.gsub(/[\s|]/, "") == "" || cells_of(rows.first).first.to_s.start_with?("**")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# A field table ("| | | |" scaffold, "| **Key** | value | note |" rows)
|
|
168
|
+
# re-lays as the intent screen's vertical field block: bold key, value,
|
|
169
|
+
# mid-grey note on its own line. A data table re-lays as padded columns
|
|
170
|
+
# with a bold header, done/open cells colored, no pipes anywhere.
|
|
171
|
+
def paint_table(rows, color:, width:, markdown_safe:)
|
|
172
|
+
rows = rows.reject { |r| SEPARATOR_RE.match?(r) || r.gsub(/[\s|]/, "").empty? }
|
|
173
|
+
return "" if rows.empty?
|
|
174
|
+
|
|
175
|
+
if cells_of(rows.first).first.to_s.start_with?("**")
|
|
176
|
+
out = +""
|
|
177
|
+
key_w = rows.map { |r| cells_of(r).first.to_s.gsub("*", "").length }.max
|
|
178
|
+
rows.each do |row|
|
|
179
|
+
key, value, note = cells_of(row)
|
|
180
|
+
key = key.to_s.gsub("*", "")
|
|
181
|
+
out << " #{A.styled(key.ljust(key_w), color, A::BOLD)} #{clean(value.to_s, markdown_safe)}\n"
|
|
182
|
+
next if note.to_s.empty?
|
|
183
|
+
out << (" " * (key_w + 4)) << A.fit(clean(note, markdown_safe), width - key_w - 4) { |s| A.styled(s, color, A::MIDGREY) } << "\n"
|
|
184
|
+
end
|
|
185
|
+
return out
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
grid = rows.map { |r| cells_of(r).map { |c| clean(c, markdown_safe) } }
|
|
189
|
+
widths = grid.first.each_index.map { |i| grid.map { |r| r[i].to_s.length }.max }
|
|
190
|
+
out = +""
|
|
191
|
+
grid.each_with_index do |cols, ri|
|
|
192
|
+
cells = cols.each_with_index.map do |cell, ci|
|
|
193
|
+
padded = cell.to_s.ljust(widths[ci])
|
|
194
|
+
if ri.zero?
|
|
195
|
+
A.styled(padded, color, A::BOLD)
|
|
196
|
+
elsif cell == "done"
|
|
197
|
+
A.styled(padded, color, A::TEAL)
|
|
198
|
+
elsif cell == "open"
|
|
199
|
+
A.styled(padded, color, A::AMBER)
|
|
200
|
+
else
|
|
201
|
+
padded
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
out << " " << cells.join(" ").rstrip << "\n"
|
|
205
|
+
end
|
|
206
|
+
out
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def paint_bars(text, color)
|
|
210
|
+
return text unless color
|
|
211
|
+
text.gsub(/█+/) { |run| "#{A::TEAL}#{run}#{A::RESET}" }
|
|
212
|
+
.gsub(/░+/) { |run| "#{A::MIDGREY}#{run}#{A::RESET}" }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def clean(text, markdown_safe)
|
|
216
|
+
markdown_safe ? A.clean(text) : text
|
|
217
|
+
end
|
|
218
|
+
end
|
package/scripts/report-screen
CHANGED
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
# report-screen delivered <intent_dir> [--ansi]
|
|
13
13
|
# report-screen delay <intent_dir> [--ansi]
|
|
14
14
|
#
|
|
15
|
-
# --ansi delegates to
|
|
16
|
-
#
|
|
17
|
-
#
|
|
15
|
+
# --ansi delegates to ScreenPaint (intent 317a, D1), the parser/re-layouter
|
|
16
|
+
# in the shared TUI core. Selection is by capability, never by harness:
|
|
17
|
+
# NO_COLOR forces plain, a non-TTY stdout stays plain unless
|
|
18
|
+
# PLASTIC_FORCE_COLOR=1 (the test seam), and an unparseable screen falls
|
|
19
|
+
# open to the plain text untouched.
|
|
18
20
|
#
|
|
19
21
|
# Exit codes:
|
|
20
22
|
# 0 - the screen is on stdout
|
|
@@ -23,6 +25,7 @@
|
|
|
23
25
|
|
|
24
26
|
require_relative "lib/report_screen"
|
|
25
27
|
require_relative "lib/intent_screen"
|
|
28
|
+
require_relative "lib/screen_paint"
|
|
26
29
|
|
|
27
30
|
def usage_abort(message)
|
|
28
31
|
warn "report-screen: #{message}"
|
|
@@ -46,7 +49,6 @@ verb = args.shift
|
|
|
46
49
|
changed = nil
|
|
47
50
|
ansi = false
|
|
48
51
|
template_path = nil
|
|
49
|
-
renderer_path = nil
|
|
50
52
|
positional = []
|
|
51
53
|
|
|
52
54
|
while (arg = args.shift)
|
|
@@ -60,8 +62,6 @@ while (arg = args.shift)
|
|
|
60
62
|
positional << "--all"
|
|
61
63
|
when "--template"
|
|
62
64
|
template_path = args.shift or usage_abort("--template needs a path")
|
|
63
|
-
when "--renderer-path"
|
|
64
|
-
renderer_path = args.shift or usage_abort("--renderer-path needs a path")
|
|
65
65
|
else
|
|
66
66
|
usage_abort("unknown flag #{arg.inspect}") if arg.start_with?("--") && arg != "--all"
|
|
67
67
|
positional << arg
|
|
@@ -73,11 +73,12 @@ usage_abort("usage: report-screen state|delivered|delay <intent_dir> [--changed
|
|
|
73
73
|
all_mode = positional.delete("--all") ? true : false
|
|
74
74
|
target = positional.first
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
ansi_enabled = ansi && ENV["NO_COLOR"].to_s.empty? &&
|
|
77
|
+
($stdout.tty? || ENV["PLASTIC_FORCE_COLOR"] == "1")
|
|
78
78
|
|
|
79
|
-
def paint(text, ansi_enabled,
|
|
80
|
-
|
|
79
|
+
def paint(text, ansi_enabled, _renderer_path = nil)
|
|
80
|
+
return text unless ansi_enabled
|
|
81
|
+
ScreenPaint.paint(text, color: true) || text
|
|
81
82
|
end
|
|
82
83
|
|
|
83
84
|
case verb
|
|
@@ -87,7 +88,7 @@ when "state"
|
|
|
87
88
|
store_root = File.expand_path(target)
|
|
88
89
|
usage_abort("#{store_root} is not a store (no INDEX.md)") unless File.exist?(File.join(store_root, "INDEX.md"))
|
|
89
90
|
out = ReportScreen.render_roster(store_root, changed: changed)
|
|
90
|
-
$stdout.write paint(out, ansi_enabled
|
|
91
|
+
$stdout.write paint(out, ansi_enabled)
|
|
91
92
|
else
|
|
92
93
|
usage_abort("usage: report-screen state <intent_dir> [--changed \"<text>\"]") unless target
|
|
93
94
|
intent_dir = File.expand_path(target)
|
|
@@ -97,7 +98,7 @@ when "state"
|
|
|
97
98
|
usage_abort("template not found at #{template_path}") unless File.exist?(template_path)
|
|
98
99
|
out = ReportScreen.render_state(intent_dir: intent_dir, store_root: store_root, changed: changed,
|
|
99
100
|
template: File.read(template_path))
|
|
100
|
-
$stdout.write paint(out, ansi_enabled
|
|
101
|
+
$stdout.write paint(out, ansi_enabled)
|
|
101
102
|
end
|
|
102
103
|
when "delivered"
|
|
103
104
|
usage_abort("usage: report-screen delivered <intent_dir>") unless target
|
|
@@ -105,13 +106,13 @@ when "delivered"
|
|
|
105
106
|
usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
|
|
106
107
|
repo_root = File.expand_path("..", __dir__)
|
|
107
108
|
out = ReportScreen.render_delivered(intent_dir: intent_dir, tag_reader: git_tag_reader(repo_root))
|
|
108
|
-
$stdout.write paint(out, ansi_enabled
|
|
109
|
+
$stdout.write paint(out, ansi_enabled)
|
|
109
110
|
when "delay"
|
|
110
111
|
usage_abort("usage: report-screen delay <intent_dir>") unless target
|
|
111
112
|
intent_dir = File.expand_path(target)
|
|
112
113
|
usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
|
|
113
114
|
out = ReportScreen.render_delay(intent_dir: intent_dir)
|
|
114
|
-
$stdout.write paint(out, ansi_enabled
|
|
115
|
+
$stdout.write paint(out, ansi_enabled)
|
|
115
116
|
else
|
|
116
117
|
usage_abort("unknown verb #{verb.inspect} (use state|delivered|delay)")
|
|
117
118
|
end
|
package/skills/auto/SKILL.md
CHANGED
|
@@ -253,7 +253,8 @@ every choice is non-destructive and the team has full autonomy.
|
|
|
253
253
|
Read `../plastic-conventions/references/completion-and-done.md` for what "intent done" means.
|
|
254
254
|
|
|
255
255
|
1. Verify every checklist item is checked and the suite is green once on the branch.
|
|
256
|
-
2. Write `outcome.md` from `~/.plastic/templates/outcome.md` with `disposition: delivered
|
|
256
|
+
2. Write `outcome.md` from `~/.plastic/templates/outcome.md` with `disposition: delivered`,
|
|
257
|
+
`## Delivered` as the labeled table whose row labels match the action-file headings (317a).
|
|
257
258
|
3. Release, if configured: match the working directory against `~/.plastic/projects.yml`, read
|
|
258
259
|
`project.yml`'s `release` block, and act on `on_complete` (`commit`, `commit_and_push`,
|
|
259
260
|
`manual`), `verify` (green proceeds; red follows `on_red`: `fix_and_retry` up to twice,
|
|
@@ -103,3 +103,15 @@ what gets written down.
|
|
|
103
103
|
| Exec | code on the intent branch, checklist checked off | heartbeat; code edits confined to the provisioned worktree; delegates write under the owner's lock | checklist boxes; savepoint milestones; the day-ledger line promotes when a project file lands |
|
|
104
104
|
| End (done) | mandatory `outcome.md` (`disposition: delivered\|abandoned`), INDEX moves to Completed or Abandoned | ordered End tail: verify, merge and remove worktrees, disarm clears `delivery.lock`, then the pointer is purge-eligible, and the QMD reindex runs LAST (after purge); `end-intent` backfills a placeholder `outcome.md` from the record and its structure check reports (never refuses) | savepoint `Done delivered` (or `abandoned`); takeover audits, if any, remain in savepoint.md |
|
|
105
105
|
| Maintenance (Future, Terminal, or Active-with-a-stale-or-no-lock) | `revisions.md` move-and-record entries | detects (never acquires) `delivery.lock`; defers and reports while the target's lock is FRESH (`Lock.fresh?`); a stale or absent lock is not-active, maintenance proceeds | append-only, rule-tagged `revisions.md` entry written in the same operation as the change, or the change is refused; lands via a fresh branch off store main merged back as one closed op, never `git add -A` |
|
|
106
|
+
|
|
107
|
+
## The write guard is not residue
|
|
108
|
+
|
|
109
|
+
`<type>.write.lock` (usually `delivery.write.lock`) is a deliberate sibling
|
|
110
|
+
inode used only for `flock`: no owner, no timestamp, no content, and it is
|
|
111
|
+
NEVER unlinked - deleting it while a writer holds the flock hands the next
|
|
112
|
+
writer a fresh inode at the same path, so two writers hold "the" guard at
|
|
113
|
+
once (see `scripts/lib/lock.rb`, the write-guard comment). A zero-byte
|
|
114
|
+
`*.write.lock` in a completed intent directory is by design; no cleaner may
|
|
115
|
+
sweep it, and it is already inside the store's `*.lock` gitignore rule.
|
|
116
|
+
(Intent 317a, A2: a review misread it as stale residue and nearly shipped
|
|
117
|
+
the sweep.)
|
|
@@ -63,11 +63,14 @@ on disk is what the record becomes, so before the call:
|
|
|
63
63
|
|
|
64
64
|
Author outcome.md yourself when it deserves prose: copy `templates/outcome.md`,
|
|
65
65
|
set the frontmatter to `disposition: delivered` or `disposition: abandoned`, and
|
|
66
|
-
fill `## Summary`, `## Delivered`, `## Verification`, `## Follow-ups`.
|
|
67
|
-
|
|
68
|
-
not a method name or an implementation summary (that detail
|
|
69
|
-
`## Summary`)
|
|
70
|
-
|
|
66
|
+
fill `## Summary`, `## Delivered`, `## Verification`, `## Follow-ups`. `## Delivered` is a
|
|
67
|
+
`| Row | What |` table: one row per thing delivered, in plain wording a reader
|
|
68
|
+
recognizes, not a method name or an implementation summary (that detail
|
|
69
|
+
belongs in `## Summary`). Each row's label must appear as a standalone token
|
|
70
|
+
in an action-file heading (`### S1 - ...` proves row S1); that heading's
|
|
71
|
+
matrix rows become the row's Proven-by cell on `report-screen delivered`'s
|
|
72
|
+
post-delivery screen. `## Needs you` is the literal None or a
|
|
73
|
+
`| N | What | Why |` table. On abandon, `## Summary` states the abandonment reason and the trail (see Pivot
|
|
71
74
|
below). A placeholder outcome.md is backfilled from the record instead, with the
|
|
72
75
|
close's disposition and the `--outcome-summary` line as its summary. Also author
|
|
73
76
|
the rich INDEX entry note now (a short line in the store's existing
|
package/templates/outcome.md
CHANGED
|
@@ -8,13 +8,21 @@ disposition: delivered|abandoned
|
|
|
8
8
|
|
|
9
9
|
## Delivered
|
|
10
10
|
<!-- One row per thing delivered, in plain wording a reader recognizes, not
|
|
11
|
-
an implementation summary; the technical detail belongs in ## Summary.
|
|
12
|
-
|
|
11
|
+
an implementation summary; the technical detail belongs in ## Summary. Each
|
|
12
|
+
row's label must appear as a standalone token in an actions/*.md heading
|
|
13
|
+
(for example "### S1 - ..." proves row S1): that heading's matrix rows become
|
|
14
|
+
the row's Proven-by cell on the delivered screen (intent 317 D19, 317a). -->
|
|
15
|
+
| Row | What |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| S1 | ... |
|
|
13
18
|
|
|
14
19
|
## Verification
|
|
15
20
|
- <acceptance criterion> — verified by ... → result
|
|
16
21
|
|
|
17
22
|
## Needs you
|
|
23
|
+
<!-- The literal None, or a table shaped | N | What | Why | with one row per
|
|
24
|
+
open owner action. Prose is tolerated by the reader but renders as a single
|
|
25
|
+
untyped row - write the table. -->
|
|
18
26
|
None
|
|
19
27
|
|
|
20
28
|
## Follow-ups
|