@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.21
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/agents/plastic-enforcer.md +3 -1
- package/agents/plastic-executor.md +9 -3
- package/bin/test +24 -4
- package/hooks/call-budget +4 -0
- package/hooks/hooks.json +24 -0
- package/hooks/message-display +134 -0
- package/hooks/statusline +4 -27
- package/package.json +2 -2
- package/scripts/agent-report +8 -2
- package/scripts/dashboard.rb +297 -18
- package/scripts/doctor.rb +652 -5
- package/scripts/end-intent +219 -0
- package/scripts/hook-call-budget +222 -0
- package/scripts/hook-capture +25 -113
- package/scripts/hook-message-display +81 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-session-start +45 -7
- package/scripts/index-projection +74 -0
- package/scripts/intent-screen +77 -0
- package/scripts/lib/action_graph_shim.rb +277 -0
- package/scripts/lib/arm.rb +26 -1
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/compact_instructions.rb +5 -5
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/day_summary.rb +7 -2
- package/scripts/lib/doctor_core.rb +102 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +137 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/hook_registry.rb +28 -2
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +141 -6
- package/scripts/lib/intent_screen.rb +309 -0
- package/scripts/lib/intent_screen_ansi.rb +262 -0
- package/scripts/lib/message_display.rb +586 -0
- package/scripts/lib/meter_watch.rb +179 -0
- package/scripts/lib/node_file.rb +214 -0
- package/scripts/lib/node_ids.rb +99 -0
- package/scripts/lib/node_ledger.rb +377 -0
- package/scripts/lib/node_packet.rb +908 -0
- package/scripts/lib/node_return.rb +199 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/outcome_report.rb +440 -0
- package/scripts/lib/packet_wrapper.rb +132 -0
- package/scripts/lib/ready_set.rb +462 -0
- package/scripts/lib/release_guard.rb +16 -0
- package/scripts/lib/report_screen.rb +1967 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +155 -5
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/roadmap_savepoint.rb +62 -12
- package/scripts/lib/runner_absorb.rb +620 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +506 -0
- package/scripts/lib/runner_policy.rb +173 -0
- package/scripts/lib/runner_proposals.rb +275 -0
- package/scripts/lib/runner_rewind.rb +201 -0
- package/scripts/lib/runner_sweep.rb +231 -0
- package/scripts/lib/savepoint.rb +149 -12
- package/scripts/lib/screen_paint.rb +555 -0
- package/scripts/lib/screens/dashboard.rb +20 -0
- package/scripts/lib/screens/plan.rb +18 -0
- package/scripts/lib/screens/roadmap.rb +15 -0
- package/scripts/lib/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +128 -0
- package/scripts/lib/session_usage.rb +190 -0
- package/scripts/lib/verify_intent.rb +33 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/meter-watch +57 -0
- package/scripts/node-packet +92 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/plastic-lock +8 -1
- package/scripts/read-config +3 -3
- package/scripts/ready-set +126 -0
- package/scripts/release-check +118 -0
- package/scripts/report-screen +281 -0
- package/scripts/roadmap-graph +119 -0
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/runner +397 -0
- package/scripts/savepoint-note +69 -0
- package/scripts/session-usage +56 -0
- package/scripts/spawn-preamble +9 -2
- package/scripts/validate-work-graph +39 -0
- package/scripts/verify-intent +2 -1
- package/skills/auto/SKILL.md +41 -34
- package/skills/auto/references/human-report-contract.md +136 -54
- package/skills/conventions/references/locks-and-worktrees.md +12 -0
- package/skills/dashboard/SKILL.md +13 -2
- package/skills/dashboard/templates/dashboard-global.md +1 -1
- package/skills/dashboard/templates/dashboard-project.md +2 -2
- package/skills/doctor/SKILL.md +10 -4
- package/skills/intent-continuing/SKILL.md +51 -41
- package/skills/intent-continuing/references/board-fill.md +9 -0
- package/skills/intent-continuing/references/boarding-matrix.md +6 -5
- package/skills/intent-continuing/references/context-management.md +1 -1
- package/skills/intent-ending/SKILL.md +36 -16
- package/skills/intent-executing/SKILL.md +21 -5
- package/skills/intent-executing/implementer-prompt.md +6 -1
- package/skills/intent-speccing/SKILL.md +7 -4
- package/skills/releasing/SKILL.md +39 -0
- package/skills/releasing/references/promotion-and-tagging.md +10 -6
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/roadmap/SKILL.md +26 -0
- package/skills/roadmap/references/file-format.md +10 -0
- package/templates/config.yml +3 -3
- package/templates/dashboard-screen.md +22 -0
- package/templates/display-fixture.md +21 -0
- package/templates/graph.md +16 -0
- package/templates/intent-screen.md +17 -0
- package/templates/node-decision.md +11 -0
- package/templates/node-research.md +11 -0
- package/templates/node-verify.md +13 -0
- package/templates/node-work.md +22 -0
- package/templates/outcome.md +19 -1
- package/templates/report-plan.md +15 -0
- package/templates/report-roadmap-delivered.md +10 -0
- package/templates/report-roadmap-plan.md +9 -0
- package/templates/report-roadmap-state.md +9 -0
- package/templates/report-state.md +11 -0
- package/templates/roadmap.md +13 -0
|
@@ -0,0 +1,555 @@
|
|
|
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
|
+
#
|
|
21
|
+
# Intent 331a (D6): a registry, so a new screen KIND is a new file
|
|
22
|
+
# (scripts/lib/screens/<kind>.rb, calling `register` on load), never a diff
|
|
23
|
+
# to this one. `paint:` is optional and defaults to the shared pipeline
|
|
24
|
+
# below - no shipped kind has its own palette; `classify`/`paint` branch on
|
|
25
|
+
# LINE SHAPE, never on kind. The registry's job is only that a new kind's
|
|
26
|
+
# opener is recognized without editing this file, and that a kind CAN
|
|
27
|
+
# supply its own paint lambda on the rare day one needs one.
|
|
28
|
+
module ScreenPaint
|
|
29
|
+
A = IntentScreenAnsi
|
|
30
|
+
|
|
31
|
+
# A screen's first line: "## ▶ id · name", "## ✔ id · name · delivered",
|
|
32
|
+
# "▶ In delivery · ...", "✔ id · name · delivered in ...". Retained for
|
|
33
|
+
# reference (and as the union every shipped kind below decomposes into);
|
|
34
|
+
# `classify`/`paint` consult the registry, not this constant, directly.
|
|
35
|
+
OPENER_RE = /\A(?:## )?[▶✔] .+ · /.freeze
|
|
36
|
+
|
|
37
|
+
FIELD_LINE_RE = /\A(Stage|Next|Changed|Lead|Progress)(\s{2,})(.*)\z/.freeze
|
|
38
|
+
STEP_LINE_RE = /\A(S\d+)\s+\[ (open|done) \]\s+(.*)\z/.freeze
|
|
39
|
+
TIMELINE_RE = /\A(\d\d:\d\d)\s{2}(\S+)\s{2}(.*)\z/.freeze
|
|
40
|
+
COUNT_LINE_RE = /\A\d+ open( · .*)?\z/.freeze
|
|
41
|
+
# 331a1: the session verb prints this note between the delivered screens and
|
|
42
|
+
# the roster (report_screen.rb, render_session). It is our own output, so the
|
|
43
|
+
# painter must not reject it - the live hook trace caught the region stopping
|
|
44
|
+
# dead on it, with the whole roster below reaching the terminal as plain
|
|
45
|
+
# Markdown. Pinned to the exact shape rather than "any sentence": region_end
|
|
46
|
+
# exists to stop at the model's own prose, and a loose rule would swallow it.
|
|
47
|
+
SKIP_NOTE_RE = /\A\d+ completed intents? skipped: .+\z/.freeze
|
|
48
|
+
# 331a2 (D5, S3): render_session's own rescue card when a directory's delivered screen
|
|
49
|
+
# raises (report_screen.rb:1466-1468) - our own output, not model prose. The census (331a2)
|
|
50
|
+
# proved it classified :unknown, orphaning the roster below it in a real reply. `:meta`, not
|
|
51
|
+
# a new kind: it is a one-line grey note under a title and paint's `:meta` arm reads only
|
|
52
|
+
# `line.strip`, with no positional dependency.
|
|
53
|
+
RESCUE_CARD_RE = /\A## \S+ · could not render \(.*\)\z/.freeze
|
|
54
|
+
BOLD_LEAD_RE = /\A\*\*([^*]+)\*\*(.*)\z/.freeze
|
|
55
|
+
|
|
56
|
+
# Intent 317a1 (D3, D4, D5): the data-table palette. Kind and note columns
|
|
57
|
+
# are chosen by the table's own header, never by position; a cell whose
|
|
58
|
+
# text is exactly "not recorded" greys wherever it appears.
|
|
59
|
+
EVIDENCE_PROOF_KINDS = %w[suite red ship doctor deposits verdict].freeze
|
|
60
|
+
EVIDENCE_DEVIATION_KINDS = %w[deviates].freeze
|
|
61
|
+
# "Reason" is the current header (D5, intent 331f); "Why" stays too so a screen captured
|
|
62
|
+
# before the rename still paints (the header map's own forgiving-reader guarantee).
|
|
63
|
+
NOTE_HEADERS = %w[Source Why Reason].freeze
|
|
64
|
+
NOT_RECORDED = "not recorded"
|
|
65
|
+
|
|
66
|
+
# Intent 331f (finding 1, post-exec review): the width bound a column shrink never crosses,
|
|
67
|
+
# and the glyphs that mark a column as a progress bar, never itself shrunk. Shared with
|
|
68
|
+
# ReportScreen.fit_table_block (report_screen.rb requires this file, not the other way
|
|
69
|
+
# around) so a markdown row and a painted row bound their columns the same way.
|
|
70
|
+
FIT_COLUMN_FLOOR = 8
|
|
71
|
+
PROGRESS_BAR_CHARS_RE = /[█░]/.freeze
|
|
72
|
+
|
|
73
|
+
@registry = {}
|
|
74
|
+
|
|
75
|
+
module_function
|
|
76
|
+
|
|
77
|
+
# Intent 331f1 (spec.md's acceptance rule): the conservative terminal cost used ONLY to
|
|
78
|
+
# enforce the 115-column bound - ANSI escapes stripped, then every character at or above
|
|
79
|
+
# U+1100 (the East Asian Wide/Ambiguous threshold; a block glyph like "█"/"░" sits well past
|
|
80
|
+
# it) counts two columns, everything else one. `IntentScreenAnsi.visible_width` stays a
|
|
81
|
+
# plain ANSI-stripped `.length` because it drives PADDING, not the bound - a terminal that
|
|
82
|
+
# draws "█" one column wide would misalign if padding used this conservative cost.
|
|
83
|
+
# Over-bounding only ever makes a row narrower, never wraps one, so using this rule for the
|
|
84
|
+
# bound alone is always safe.
|
|
85
|
+
WIDE_CODEPOINT_MIN = 0x1100
|
|
86
|
+
|
|
87
|
+
def display_columns(text)
|
|
88
|
+
text.to_s.gsub(A::ANSI_RE, "").each_char.sum { |c| c.ord >= WIDE_CODEPOINT_MIN ? 2 : 1 }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Truncate `text` to at most `max_chars` DISPLAY COLUMNS, cutting at the last whitespace at
|
|
92
|
+
# or before the limit (never mid-word) and appending a single ellipsis when truncation
|
|
93
|
+
# happens. The one shared implementation (intent 331f, finding 1; intent 331f1 finding A1):
|
|
94
|
+
# ReportScreen.truncate_on_word_boundary and dashboard.rb's own helper of the same name both
|
|
95
|
+
# delegate here. The ellipsis (U+2026) itself sits above WIDE_CODEPOINT_MIN, so it costs TWO
|
|
96
|
+
# display columns even though it is one character - the budget reserves that display width,
|
|
97
|
+
# not `ellipsis.length`, or every truncated cell lands one column over (finding A1). Any
|
|
98
|
+
# ellipsis already trailing the word-boundary slice is dropped before the fresh one is
|
|
99
|
+
# appended, so a value truncated twice (the assembled-row backstop truncating a cell that
|
|
100
|
+
# was already cut) never stacks a second ellipsis onto the first.
|
|
101
|
+
def truncate_on_word_boundary(text, max_chars)
|
|
102
|
+
t = text.to_s
|
|
103
|
+
return t if display_columns(t) <= max_chars
|
|
104
|
+
ellipsis = A::ELLIPSIS
|
|
105
|
+
budget = [max_chars - display_columns(ellipsis), 0].max
|
|
106
|
+
# Walk characters accumulating DISPLAY columns, not a raw character index: the row-level
|
|
107
|
+
# backstop calls this on an ALREADY-ASSEMBLED row that can carry several prior per-cell
|
|
108
|
+
# ellipses (each one two display columns for one character), so a plain `t[0, limit]`
|
|
109
|
+
# character slice can under-cut and still land over budget once its own wide characters
|
|
110
|
+
# are counted.
|
|
111
|
+
cols = 0
|
|
112
|
+
cut_at = 0
|
|
113
|
+
t.each_char do |c|
|
|
114
|
+
w = c.ord >= WIDE_CODEPOINT_MIN ? 2 : 1
|
|
115
|
+
break if cols + w > budget
|
|
116
|
+
cols += w
|
|
117
|
+
cut_at += 1
|
|
118
|
+
end
|
|
119
|
+
slice = t[0, cut_at]
|
|
120
|
+
cut = slice.rindex(/\s/)
|
|
121
|
+
slice = slice[0, cut] if cut && cut.positive?
|
|
122
|
+
slice = slice.rstrip
|
|
123
|
+
slice = slice.chomp(ellipsis) while slice.end_with?(ellipsis)
|
|
124
|
+
"#{slice}#{ellipsis}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Shrinks a row of column `widths` until their sum fits `budget`: the widest shrinkable
|
|
128
|
+
# column loses one column at a time, ties break toward the leftmost column, no column ever
|
|
129
|
+
# drops below its own `floors[i]` (FIT_COLUMN_FLOOR for every column when `floors` is
|
|
130
|
+
# omitted, the original behavior), and a column flagged in `bar_columns` (it carries a
|
|
131
|
+
# progress bar) is never touched regardless of its floor. The one shared shrink (intent
|
|
132
|
+
# 331f, finding 1; intent 331f1, S3): ReportScreen.fit_table_block's markdown row and
|
|
133
|
+
# ScreenPaint.paint_data_table's painted row both bound through this rather than carrying
|
|
134
|
+
# two loops that could drift apart. Returns a new array; the caller's own `widths` is left
|
|
135
|
+
# untouched.
|
|
136
|
+
def shrink_column_widths(widths, budget, bar_columns:, floors: nil)
|
|
137
|
+
floors ||= Array.new(widths.length, FIT_COLUMN_FLOOR)
|
|
138
|
+
widths = widths.dup
|
|
139
|
+
loop do
|
|
140
|
+
break if widths.sum <= budget
|
|
141
|
+
candidates = widths.each_index.select { |ci| !bar_columns[ci] && widths[ci] > floors[ci] }
|
|
142
|
+
break if candidates.empty?
|
|
143
|
+
target = candidates.max_by { |ci| [widths[ci], -ci] }
|
|
144
|
+
widths[target] -= 1
|
|
145
|
+
end
|
|
146
|
+
widths
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Intent 331f1, S3 (brief 4): the per-column floor a data-table shrink never crosses - a
|
|
150
|
+
# column never shrinks below its own header cell, below its natural width when that is at
|
|
151
|
+
# most 10 columns (the id case: an id column is already narrow, so "natural width" IS its
|
|
152
|
+
# floor and a uniform 8-column floor could still crush it), or below a bar cell (bar columns
|
|
153
|
+
# are handled separately, by `bar_columns:` above, but a caller may still pass a natural
|
|
154
|
+
# width here too - `[header_len, base].max` never fights that).
|
|
155
|
+
def column_floor(header_len, natural_width)
|
|
156
|
+
base = natural_width <= 10 ? natural_width : FIT_COLUMN_FLOOR
|
|
157
|
+
[header_len, base].max
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Intent 331f1 (post-execution review, findings P2/P3): a padded cell renders as its column
|
|
161
|
+
# width (characters, from `ljust`) plus that cell's own DISPLAY overage -
|
|
162
|
+
# `display_columns(cell) - cell.length` - because a progress-bar glyph or an already-embedded
|
|
163
|
+
# ellipsis costs more display columns than characters. Crediting overage only to columns
|
|
164
|
+
# flagged `bar_columns:` (the old rule) misses a padded cell that carries an ellipsis in an
|
|
165
|
+
# ordinary text column - exactly what ReportScreen.fit_row_cell hands roadmap_state's Intent
|
|
166
|
+
# column - so the assembled row can still land over the bound even though every column width
|
|
167
|
+
# was computed correctly. The real cost is per ROW, not per column: `rows_of_cells` is an
|
|
168
|
+
# array of rows, each an array of already-stripped, not-yet-padded/truncated cell strings, and
|
|
169
|
+
# this returns the worst row's total overage - the one number ScreenPaint.paint_data_table,
|
|
170
|
+
# ReportScreen.fit_table_block, and ReportScreen.fit_field_table_block all reserve out of their
|
|
171
|
+
# budget, so the three renderers spend one rule rather than three copies that can drift.
|
|
172
|
+
def row_display_overage(rows_of_cells)
|
|
173
|
+
rows_of_cells.map { |cells| cells.sum { |c| display_columns(c.to_s) - c.to_s.length } }.max || 0
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Registers a screen kind's opener grammar (a Regexp or a callable taking
|
|
177
|
+
# the stripped opener line and returning truthy/falsy), plus an optional
|
|
178
|
+
# `paint:` lambda for a kind that needs its own palette (`call(text,
|
|
179
|
+
# color:, width:, markdown_safe:)`). Idempotent by kind: registering the
|
|
180
|
+
# same kind again replaces its entry rather than adding a second one.
|
|
181
|
+
def register(kind, opener:, paint: nil)
|
|
182
|
+
@registry[kind.to_sym] = { opener: opener, paint: paint }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Every registered kind's name, shipped and caller-added alike.
|
|
186
|
+
def kinds
|
|
187
|
+
@registry.keys
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# The first registered kind whose opener matches `text` (an already
|
|
191
|
+
# stripped, single line), or nil.
|
|
192
|
+
def opener_kind(text)
|
|
193
|
+
@registry.find { |_, entry| opener_matches?(entry[:opener], text) }&.first
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def opener_matches?(opener, text)
|
|
197
|
+
opener.respond_to?(:call) ? !!opener.call(text) : !!opener.match?(text)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# The classifier both paint and region_end share. `idx`/`opener_idx` give
|
|
201
|
+
# the positional rule its footing: the line right after a title is the meta
|
|
202
|
+
# line (delivered/delay print one), recognizable by its " · " separators.
|
|
203
|
+
def classify(line, idx: nil, opener_idx: nil)
|
|
204
|
+
text = line.chomp
|
|
205
|
+
stripped = text.strip
|
|
206
|
+
return :blank if stripped.empty?
|
|
207
|
+
return :opener if text == stripped && !opener_kind(stripped).nil?
|
|
208
|
+
return :table if text.lstrip.start_with?("|")
|
|
209
|
+
return :bold if BOLD_LEAD_RE.match?(stripped) && text == stripped
|
|
210
|
+
return :meta if idx && opener_idx && idx == opener_idx + 1 && stripped.include?(" · ")
|
|
211
|
+
return :meta if RESCUE_CARD_RE.match?(stripped)
|
|
212
|
+
return :indented if text.start_with?(" ")
|
|
213
|
+
return :field if FIELD_LINE_RE.match?(text)
|
|
214
|
+
return :step if STEP_LINE_RE.match?(text)
|
|
215
|
+
return :timeline if TIMELINE_RE.match?(text)
|
|
216
|
+
return :count if COUNT_LINE_RE.match?(stripped) || SKIP_NOTE_RE.match?(stripped)
|
|
217
|
+
return :closer if ["None", "not recorded", "No intents in delivery.", "No intents delivered in this session."].include?(stripped)
|
|
218
|
+
:unknown
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Where the screen region ends inside a larger message (B10): walk from the
|
|
222
|
+
# opener while every line classifies; the first unknown line - ordinary
|
|
223
|
+
# prose, a prose bullet - is the boundary. Never consumes past the screen.
|
|
224
|
+
#
|
|
225
|
+
# 331a1: `opener_idx` tracks the NEAREST preceding opener, not the first
|
|
226
|
+
# one in the message. A reply can carry several screens back to back - the
|
|
227
|
+
# roster is a table then ten cards, the session report is many delivered
|
|
228
|
+
# screens - and `classify`'s positional rule is "the line right after a
|
|
229
|
+
# title", which means the title above it. Testing every line against the
|
|
230
|
+
# message's first opener made the timestamp under the SECOND screen
|
|
231
|
+
# :unknown, and the region stopped there: 30 lines of a 350-line session
|
|
232
|
+
# report painted, the rest reaching the terminal as plain Markdown.
|
|
233
|
+
def region_end(lines, start_idx)
|
|
234
|
+
i = start_idx + 1
|
|
235
|
+
opener_idx = start_idx
|
|
236
|
+
while i < lines.length
|
|
237
|
+
kind = classify(lines[i], idx: i, opener_idx: opener_idx)
|
|
238
|
+
break if kind == :unknown
|
|
239
|
+
opener_idx = i if kind == :opener
|
|
240
|
+
# A bare "**Section**" head belongs to the screen only when what follows
|
|
241
|
+
# is still grammar; "**What this means**" over prose bullets is the
|
|
242
|
+
# model's own commentary and stays outside, unsplit (B10).
|
|
243
|
+
if kind == :bold && bare_bold?(lines[i]) && !grammar_follows?(lines, i, start_idx)
|
|
244
|
+
break
|
|
245
|
+
end
|
|
246
|
+
i += 1
|
|
247
|
+
end
|
|
248
|
+
# Trailing blanks belong to the message, not the screen.
|
|
249
|
+
i -= 1 while i > start_idx + 1 && lines[i - 1].strip.empty?
|
|
250
|
+
i
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# The first line the grammar rejects, walking from the opener under the same
|
|
254
|
+
# nearest-opener rule region_end uses. Returns [index, line] or nil when the
|
|
255
|
+
# whole run classifies. 331a1: this is what the opt-in hook trace reports,
|
|
256
|
+
# so a live run says which line stopped the region instead of leaving it to
|
|
257
|
+
# be guessed from a terminal capture.
|
|
258
|
+
def first_rejected(lines, start_idx)
|
|
259
|
+
opener_idx = start_idx
|
|
260
|
+
((start_idx + 1)...lines.length).each do |i|
|
|
261
|
+
kind = classify(lines[i], idx: i, opener_idx: opener_idx)
|
|
262
|
+
return [i, lines[i].to_s.rstrip] if kind == :unknown
|
|
263
|
+
|
|
264
|
+
opener_idx = i if kind == :opener
|
|
265
|
+
end
|
|
266
|
+
nil
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def bare_bold?(line)
|
|
270
|
+
m = BOLD_LEAD_RE.match(line.strip)
|
|
271
|
+
m && m[2].to_s.strip.empty?
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def grammar_follows?(lines, idx, opener_idx)
|
|
275
|
+
j = idx + 1
|
|
276
|
+
j += 1 while j < lines.length && lines[j].strip.empty?
|
|
277
|
+
return false if j >= lines.length
|
|
278
|
+
kind = classify(lines[j], idx: j, opener_idx: opener_idx)
|
|
279
|
+
kind != :unknown && kind != :opener
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# The painter. Returns the ANSI (or plain re-laid, when color: false) text,
|
|
283
|
+
# or nil when the input does not open with a screen title or carries a line
|
|
284
|
+
# outside the grammar - the caller's cue to print the original untouched.
|
|
285
|
+
def paint(text, color: true, width: A::DEFAULT_WIDTH, markdown_safe: false)
|
|
286
|
+
lines = text.to_s.lines
|
|
287
|
+
first_idx = lines.index { |l| !l.strip.empty? }
|
|
288
|
+
return nil if first_idx.nil?
|
|
289
|
+
|
|
290
|
+
kind = opener_kind(lines[first_idx].strip)
|
|
291
|
+
# Intent 330 (D7/O3.26): a screen that is nothing but a single known
|
|
292
|
+
# closer line (e.g. "No intents delivered in this session.") has no
|
|
293
|
+
# opener to require - it is already the whole, honest message.
|
|
294
|
+
return nil unless kind || classify(lines[first_idx]) == :closer
|
|
295
|
+
|
|
296
|
+
# Intent 331a (D6): a registered kind MAY supply its own paint lambda;
|
|
297
|
+
# when it does, this whole call delegates to it instead of the shared
|
|
298
|
+
# pipeline below. No shipped kind does.
|
|
299
|
+
if kind
|
|
300
|
+
custom = @registry[kind][:paint]
|
|
301
|
+
return custom.call(text, color: color, width: width, markdown_safe: markdown_safe) if custom
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
out = +""
|
|
305
|
+
table = []
|
|
306
|
+
ok = true
|
|
307
|
+
indent_run = 0
|
|
308
|
+
|
|
309
|
+
flush = lambda do
|
|
310
|
+
next if table.empty?
|
|
311
|
+
out << paint_table(table, color: color, width: width, markdown_safe: markdown_safe)
|
|
312
|
+
table.clear
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# 331a1: the same nearest-opener rule region_end uses - a reply carrying
|
|
316
|
+
# several screens must classify each one's meta line against its own
|
|
317
|
+
# title, not against the first title in the message.
|
|
318
|
+
opener_idx = first_idx
|
|
319
|
+
|
|
320
|
+
lines.each_with_index do |line, idx|
|
|
321
|
+
kind = classify(line, idx: idx, opener_idx: opener_idx)
|
|
322
|
+
opener_idx = idx if kind == :opener
|
|
323
|
+
# Intent 317a1 (O6, D8): the run counter tracks consecutive :indented
|
|
324
|
+
# lines so only the SECOND and later lines under a heading like
|
|
325
|
+
# "**Asked**" grey as a note; a :table line (which `next`s below) must
|
|
326
|
+
# reset it too, so a later unrelated indented block starts fresh.
|
|
327
|
+
indent_run = kind == :indented ? indent_run + 1 : 0
|
|
328
|
+
if kind == :table
|
|
329
|
+
table << line.strip
|
|
330
|
+
next
|
|
331
|
+
end
|
|
332
|
+
flush.call
|
|
333
|
+
case kind
|
|
334
|
+
when :opener
|
|
335
|
+
t = clean(line.strip.sub(/\A## /, ""), markdown_safe)
|
|
336
|
+
out << A.fit(t, width) { |s| A.styled(s, color, A::BOLD, A::NEARWHITE) } << "\n"
|
|
337
|
+
when :meta
|
|
338
|
+
out << A.fit(clean(line.strip, markdown_safe), width) { |s| A.styled(s, color, A::MIDGREY) } << "\n"
|
|
339
|
+
when :bold
|
|
340
|
+
m = BOLD_LEAD_RE.match(line.strip)
|
|
341
|
+
head = A.styled(clean(m[1], markdown_safe), color, A::BOLD, A::NEARWHITE)
|
|
342
|
+
out << head << clean(m[2], markdown_safe) << "\n"
|
|
343
|
+
when :indented
|
|
344
|
+
text = A.fit_plain(clean(line.chomp, markdown_safe), width)
|
|
345
|
+
out << (indent_run > 1 ? A.styled(text, color, A::MIDGREY) : text) << "\n"
|
|
346
|
+
when :field
|
|
347
|
+
m = FIELD_LINE_RE.match(line.chomp)
|
|
348
|
+
out << A.styled(m[1].ljust(8), color, A::BOLD) << " " << clean(m[3], markdown_safe) << "\n"
|
|
349
|
+
when :step
|
|
350
|
+
m = STEP_LINE_RE.match(line.chomp)
|
|
351
|
+
badge = A.status_cell(m[2] == "done", color)
|
|
352
|
+
out << "#{m[1].ljust(4)} [#{badge}] #{A.fit_plain(clean(m[3], markdown_safe), width - 12)}\n"
|
|
353
|
+
when :timeline
|
|
354
|
+
# Intent 317a1 (O5, D6, D7): the time carries no escape at all; the
|
|
355
|
+
# kind label carries the color - amber for the review turning point,
|
|
356
|
+
# teal for a landed commit, mid-grey (no bold) otherwise.
|
|
357
|
+
m = TIMELINE_RE.match(line.chomp)
|
|
358
|
+
codes = case m[2]
|
|
359
|
+
when "Review" then [A::BOLD, A::AMBER]
|
|
360
|
+
when "Commit" then [A::BOLD, A::TEAL]
|
|
361
|
+
else [A::MIDGREY]
|
|
362
|
+
end
|
|
363
|
+
out << m[1] << " " << A.styled(m[2].ljust(6), color, *codes) << " " << clean(m[3], markdown_safe) << "\n"
|
|
364
|
+
when :count
|
|
365
|
+
out << A.fit(line.strip, width) { |s| A.styled(s, color, A::MIDGREY) } << "\n"
|
|
366
|
+
when :closer
|
|
367
|
+
out << A.styled(line.strip, color, A::MIDGREY) << "\n"
|
|
368
|
+
when :blank
|
|
369
|
+
out << "\n"
|
|
370
|
+
else
|
|
371
|
+
ok = false
|
|
372
|
+
break
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
flush.call
|
|
376
|
+
return nil unless ok
|
|
377
|
+
|
|
378
|
+
paint_bars(out.gsub(/\n{3,}/, "\n\n"), color)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# --- tables -----------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
SEPARATOR_RE = /\A\|[\s:|-]+\|?\z/.freeze
|
|
384
|
+
|
|
385
|
+
def cells_of(row)
|
|
386
|
+
row.split("|", -1).map(&:strip)[1..-2].to_a
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Intent 331f1 (finding A3): the ONE classifier, called by both this painter and
|
|
390
|
+
# ReportScreen.fit_table_block's field-vs-data dispatch, so a table is never fitted by one
|
|
391
|
+
# rule and painted by another. A block is a field table when EVERY non-separator,
|
|
392
|
+
# non-blank-scaffold row's first cell is bold - not just the first row (the old rule):
|
|
393
|
+
# a data table whose header cell happens to be bold (e.g. "**Kind**") still has ordinary,
|
|
394
|
+
# unbold data rows underneath it, so it stays a data table. A block with nothing left after
|
|
395
|
+
# stripping separators/scaffolding classifies as neither (false).
|
|
396
|
+
def field_table?(rows)
|
|
397
|
+
candidates = rows.reject { |r| SEPARATOR_RE.match?(r) || r.gsub(/[\s|]/, "").empty? }
|
|
398
|
+
return false if candidates.empty?
|
|
399
|
+
candidates.all? { |r| cells_of(r).first.to_s.start_with?("**") }
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# A field table ("| | | |" scaffold, "| **Key** | value | note |" rows)
|
|
403
|
+
# re-lays as the intent screen's three-column field block: bold key,
|
|
404
|
+
# value, and a mid-grey note that shares the line when it fits, dropping
|
|
405
|
+
# to its own line only as a fallback (D9-D11). A data table re-lays as
|
|
406
|
+
# padded columns with a bold header, done/open cells colored, no pipes
|
|
407
|
+
# anywhere.
|
|
408
|
+
def paint_table(rows, color:, width:, markdown_safe:)
|
|
409
|
+
rows = rows.reject { |r| SEPARATOR_RE.match?(r) || r.gsub(/[\s|]/, "").empty? }
|
|
410
|
+
return "" if rows.empty?
|
|
411
|
+
|
|
412
|
+
if field_table?(rows)
|
|
413
|
+
return paint_field_table(rows, color: color, width: width, markdown_safe: markdown_safe)
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
paint_data_table(rows, color: color, width: width, markdown_safe: markdown_safe)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Intent 317a1 (O3, D9-D11, D14, D15): the same three-column geometry as
|
|
420
|
+
# IntentScreenAnsi.render's field-row loop, on the SAME implementation
|
|
421
|
+
# (D12) - `IntentScreenAnsi.field_table_lines` - so the two renderers
|
|
422
|
+
# cannot drift apart. Notes arrive as plain text here, so `visible_width`
|
|
423
|
+
# equals `length`, but the shared helper is used anyway so both renderers
|
|
424
|
+
# read identically.
|
|
425
|
+
def paint_field_table(rows, color:, width:, markdown_safe:)
|
|
426
|
+
key_w = rows.map { |r| cells_of(r).first.to_s.gsub("*", "").length }.max
|
|
427
|
+
field_rows = rows.map do |row|
|
|
428
|
+
key, value, note = cells_of(row)
|
|
429
|
+
key = key.to_s.gsub("*", "")
|
|
430
|
+
[key, clean(value.to_s, markdown_safe), clean(note.to_s, markdown_safe)]
|
|
431
|
+
end
|
|
432
|
+
A.field_table_lines(field_rows, width: width, color: color, key_width: key_w)
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# Intent 317a1 (O4, D3-D5): a data table's kind and note columns are chosen
|
|
436
|
+
# by the header, never by position; a cell whose text is exactly "not
|
|
437
|
+
# recorded" greys wherever it appears. `widths[ci] || 0` (rather than a
|
|
438
|
+
# bare `widths[ci]`) is the ragged-row guard: `ReportScreen.escape` writes
|
|
439
|
+
# `\|` while `cells_of` still splits on every `|`, so a row can carry more
|
|
440
|
+
# cells than its header without either side ever raising.
|
|
441
|
+
#
|
|
442
|
+
# Intent 331f (finding 1, post-exec review): padding every cell to its column's max across
|
|
443
|
+
# ALL rows can paint a row wider than `width` even when every plain row on its own measured
|
|
444
|
+
# under the bound (D7 is on the RENDERED row) - a row short in one column but maximal in
|
|
445
|
+
# another paints wider than its own plain row ever was. `width` bounds the column widths the
|
|
446
|
+
# same way ReportScreen.fit_table_block bounds a markdown row's, through the one shared
|
|
447
|
+
# shrink, before any cell is padded or joined.
|
|
448
|
+
def paint_data_table(rows, color:, width:, markdown_safe:)
|
|
449
|
+
grid = rows.map { |r| cells_of(r).map { |c| clean(c, markdown_safe) } }
|
|
450
|
+
ncols = grid.first.length
|
|
451
|
+
widths = (0...ncols).map { |i| grid.map { |r| r[i].to_s.length }.max }
|
|
452
|
+
bar_columns = (0...ncols).map { |i| grid.any? { |r| r[i].to_s =~ PROGRESS_BAR_CHARS_RE } }
|
|
453
|
+
# Intent 331f1 (post-exec review, P2/P3): the shared row-overage rule, not a per-column bar
|
|
454
|
+
# credit - see ScreenPaint.row_display_overage's own comment for why the bar-only credit
|
|
455
|
+
# missed a padded cell carrying an ellipsis.
|
|
456
|
+
overage = row_display_overage(grid)
|
|
457
|
+
# Intent 331f1, S3 (brief 4): per-column minimums - never below the header cell, never
|
|
458
|
+
# below a natural width of 10 or less (the id case).
|
|
459
|
+
header_len = (0...ncols).map { |i| grid.first[i].to_s.length }
|
|
460
|
+
floors = (0...ncols).map { |i| bar_columns[i] ? widths[i] : column_floor(header_len[i], widths[i]) }
|
|
461
|
+
budget = width - (2 + 2 * (ncols - 1)) - overage
|
|
462
|
+
widths = shrink_column_widths(widths, budget, bar_columns: bar_columns, floors: floors)
|
|
463
|
+
kind_col = grid.first.first == "Kind" ? 0 : nil
|
|
464
|
+
note_col = grid.first.index { |h| NOTE_HEADERS.include?(h) }
|
|
465
|
+
|
|
466
|
+
out = +""
|
|
467
|
+
grid.each_with_index do |cols, ri|
|
|
468
|
+
last_ci = cols.length - 1
|
|
469
|
+
texts = cols.each_with_index.map do |cell, ci|
|
|
470
|
+
text = cell.to_s
|
|
471
|
+
w = widths[ci]
|
|
472
|
+
text = truncate_on_word_boundary(text, w) if w && text.length > w
|
|
473
|
+
ci == last_ci ? text : text.ljust(w || 0)
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Intent 331f1 (post-exec review, P4): the row-level backstop. Even with every column
|
|
477
|
+
# pinned at its floor, the assembled PLAIN row can still exceed `width` once padding/bar/
|
|
478
|
+
# ellipsis overage is counted - shrink the widest SHRINKABLE cell's PLAIN TEXT (never a
|
|
479
|
+
# bar column, and always before styling, since truncating an already-styled string cuts
|
|
480
|
+
# ANSI escapes mid-sequence) until the row fits or nothing is left to shrink.
|
|
481
|
+
loop do
|
|
482
|
+
row_dw = display_columns((" " + texts.join(" ")).rstrip)
|
|
483
|
+
break if row_dw <= width
|
|
484
|
+
excess = row_dw - width
|
|
485
|
+
shrinkable = (0...ncols).reject { |ci| bar_columns[ci] }
|
|
486
|
+
break if shrinkable.empty?
|
|
487
|
+
target = shrinkable.max_by { |ci| display_columns(texts[ci]) }
|
|
488
|
+
cur_dw = display_columns(texts[target])
|
|
489
|
+
# A bare ellipsis alone already costs 2 display columns; below that floor there is
|
|
490
|
+
# nothing left to cut. Also bail the moment a cut makes no progress (a text already at
|
|
491
|
+
# or under the ellipsis floor re-truncates to the same "…" forever) - a row this wide
|
|
492
|
+
# even at every floor is the documented extreme case, not an infinite loop.
|
|
493
|
+
break if cur_dw <= 2
|
|
494
|
+
shrunk = truncate_on_word_boundary(texts[target].rstrip, [cur_dw - excess, 2].max)
|
|
495
|
+
break if display_columns(shrunk) >= cur_dw
|
|
496
|
+
texts[target] = shrunk
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
cells = texts.each_with_index.map do |padded, ci|
|
|
500
|
+
cell = cols[ci]
|
|
501
|
+
# An empty cell never gets styled (317a1 post-exec review, finding
|
|
502
|
+
# 1): `A.styled("", ...)` still emits a color-open/RESET pair around
|
|
503
|
+
# nothing visible, and that hides the join separator's own trailing
|
|
504
|
+
# spaces from `.rstrip` below, on the very line the reader sees.
|
|
505
|
+
next padded if cell.to_s.empty?
|
|
506
|
+
if ri.zero?
|
|
507
|
+
A.styled(padded, color, A::BOLD)
|
|
508
|
+
elsif ci == kind_col && EVIDENCE_PROOF_KINDS.include?(cell)
|
|
509
|
+
A.styled(padded, color, A::TEAL, A::BOLD)
|
|
510
|
+
elsif ci == kind_col && EVIDENCE_DEVIATION_KINDS.include?(cell)
|
|
511
|
+
A.styled(padded, color, A::AMBER, A::BOLD)
|
|
512
|
+
elsif ci == note_col
|
|
513
|
+
A.styled(padded, color, A::MIDGREY)
|
|
514
|
+
elsif cell == "done"
|
|
515
|
+
A.styled(padded, color, A::TEAL)
|
|
516
|
+
elsif cell == "open"
|
|
517
|
+
A.styled(padded, color, A::AMBER)
|
|
518
|
+
elsif cell == NOT_RECORDED
|
|
519
|
+
A.styled(padded, color, A::MIDGREY)
|
|
520
|
+
else
|
|
521
|
+
padded
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
out << " " << cells.join(" ").rstrip << "\n"
|
|
525
|
+
end
|
|
526
|
+
out
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def paint_bars(text, color)
|
|
530
|
+
return text unless color
|
|
531
|
+
text.gsub(/█+/) { |run| "#{A::TEAL}#{run}#{A::RESET}" }
|
|
532
|
+
.gsub(/░+/) { |run| "#{A::MIDGREY}#{run}#{A::RESET}" }
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def clean(text, markdown_safe)
|
|
536
|
+
markdown_safe ? A.clean(text) : text
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# --- the five shipped kinds (intent 331a, D6) -----------------------------
|
|
540
|
+
#
|
|
541
|
+
# Each opener is a strict subset of OPENER_RE, decomposed by shape rather
|
|
542
|
+
# than by any per-kind palette: `intent`/`state` share the exact template
|
|
543
|
+
# line (templates/intent-screen.md and templates/report-state.md both open
|
|
544
|
+
# with "## ▶ {{id}} · {{name}}"); `delivered` narrows to the "## ✔ id ·
|
|
545
|
+
# name · delivered" shape report_screen.rb emits; `roster` and `delay`
|
|
546
|
+
# cover every bare (no "## ") glyph line, which is exactly ScreenPaint's
|
|
547
|
+
# original, single OPENER_RE decomposed into its "## "-prefixed half and
|
|
548
|
+
# its bare half - the union is unchanged, so no existing screen stops
|
|
549
|
+
# being recognized.
|
|
550
|
+
register(:intent, opener: /\A## [▶✔] .+ · /)
|
|
551
|
+
register(:state, opener: /\A## [▶✔] .+ · /)
|
|
552
|
+
register(:delivered, opener: /\A## ✔ .+ · /)
|
|
553
|
+
register(:roster, opener: /\A▶ .+ · /)
|
|
554
|
+
register(:delay, opener: /\A✔ .+ · /)
|
|
555
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "../screen_paint"
|
|
5
|
+
|
|
6
|
+
# The dashboard screen kind (intent 331d, D4). Its opener is a stricter
|
|
7
|
+
# subset of the already-registered :intent opener (screen_paint.rb:337,
|
|
8
|
+
# registered first), so ScreenPaint.opener_kind never actually answers
|
|
9
|
+
# :dashboard on a live paint call - that is deliberate (R2): :dashboard is
|
|
10
|
+
# tested on its OWN grammar (the OPENER constant below), never on
|
|
11
|
+
# opener_kind's answer, which cannot fail meaningfully here. No custom paint
|
|
12
|
+
# lambda (R3): every line of the screen classifies under the shared
|
|
13
|
+
# field-table/data-table grammar ScreenPaint.paint already carries.
|
|
14
|
+
module Screens
|
|
15
|
+
module Dashboard
|
|
16
|
+
OPENER = /\A## ▶ (?:global|project:[a-z0-9][a-z0-9_-]*) · dashboard\z/.freeze
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
ScreenPaint.register(:dashboard, opener: Screens::Dashboard::OPENER)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "../screen_paint"
|
|
5
|
+
|
|
6
|
+
# Intent 331b (D2/D4): the plan screen's grammar - "## ▶ {id} · {name} ·
|
|
7
|
+
# plan" - registers through 331a's kind registry, a file under
|
|
8
|
+
# scripts/lib/screens/ rather than a diff to screen_paint.rb. F1 (spec.md):
|
|
9
|
+
# opener_kind answers the FIRST registered match, and :intent's own opener
|
|
10
|
+
# (/\A## [▶✔] .+ · /) already matches this title, so this kind's opener is
|
|
11
|
+
# never actually reached - it still names the kind's home and its true
|
|
12
|
+
# grammar, and that is fine, because paint: nil is what D4's "with the state
|
|
13
|
+
# palette" asks for anyway: the shared pipeline branches on LINE SHAPE, never
|
|
14
|
+
# on kind, and no shipped kind carries its own palette. Narrowing :intent's
|
|
15
|
+
# opener from this file to reach a :plan-specific paint lambda is exactly
|
|
16
|
+
# the forbidden loophole spec.md's F1 names - it would silently un-paint the
|
|
17
|
+
# intent and state screens for every other caller, so it never happens here.
|
|
18
|
+
ScreenPaint.register(:plan, opener: /\A## ▶ .+ · plan\z/, paint: nil)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "../screen_paint"
|
|
5
|
+
|
|
6
|
+
# Intent 331c (D7): the roadmap screen kinds. A new screen kind is a file, never a diff to
|
|
7
|
+
# screen_paint.rb (331a's registry, D6): registers `:roadmap_plan`, `:roadmap_state`, and
|
|
8
|
+
# `:roadmap_delivered`, openers that are strict subsets of the shipped intent/delivered openers
|
|
9
|
+
# (`## ▶ ... · roadmap · plan`, `## ▶ ... · roadmap`, `## ✔ ... · roadmap · delivered`), exactly
|
|
10
|
+
# as 331a's five shipped kinds already overlap each other. No `paint:` lambda is supplied: the
|
|
11
|
+
# palette stays IntentScreenAnsi's shared pipeline (the 318 ceiling) - screen_paint.rb's body is
|
|
12
|
+
# not touched by this file.
|
|
13
|
+
ScreenPaint.register(:roadmap_plan, opener: /\A## ▶ .+ · roadmap · plan\z/)
|
|
14
|
+
ScreenPaint.register(:roadmap_state, opener: /\A## ▶ .+ · roadmap\z/)
|
|
15
|
+
ScreenPaint.register(:roadmap_delivered, opener: /\A## ✔ .+ · roadmap · delivered\z/)
|