@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.
Files changed (127) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/bin/test +24 -4
  4. package/hooks/call-budget +4 -0
  5. package/hooks/hooks.json +24 -0
  6. package/hooks/message-display +134 -0
  7. package/hooks/statusline +4 -27
  8. package/package.json +2 -2
  9. package/scripts/agent-report +8 -2
  10. package/scripts/dashboard.rb +297 -18
  11. package/scripts/doctor.rb +652 -5
  12. package/scripts/end-intent +219 -0
  13. package/scripts/hook-call-budget +222 -0
  14. package/scripts/hook-capture +25 -113
  15. package/scripts/hook-message-display +81 -0
  16. package/scripts/hook-record +12 -4
  17. package/scripts/hook-session-start +45 -7
  18. package/scripts/index-projection +74 -0
  19. package/scripts/intent-screen +77 -0
  20. package/scripts/lib/action_graph_shim.rb +277 -0
  21. package/scripts/lib/arm.rb +26 -1
  22. package/scripts/lib/atomic_write.rb +31 -0
  23. package/scripts/lib/compact_instructions.rb +5 -5
  24. package/scripts/lib/core_integrity.rb +71 -0
  25. package/scripts/lib/dashboard_screen.rb +40 -0
  26. package/scripts/lib/day_summary.rb +7 -2
  27. package/scripts/lib/doctor_core.rb +102 -5
  28. package/scripts/lib/doctor_session_ledger.rb +52 -0
  29. package/scripts/lib/graph_edges.rb +137 -0
  30. package/scripts/lib/graph_file.rb +246 -0
  31. package/scripts/lib/graph_tree.rb +98 -0
  32. package/scripts/lib/guarded_append.rb +155 -0
  33. package/scripts/lib/hook_registry.rb +28 -2
  34. package/scripts/lib/hook_replay.rb +211 -0
  35. package/scripts/lib/index_projection.rb +201 -0
  36. package/scripts/lib/installer_core.rb +141 -6
  37. package/scripts/lib/intent_screen.rb +309 -0
  38. package/scripts/lib/intent_screen_ansi.rb +262 -0
  39. package/scripts/lib/message_display.rb +586 -0
  40. package/scripts/lib/meter_watch.rb +179 -0
  41. package/scripts/lib/node_file.rb +214 -0
  42. package/scripts/lib/node_ids.rb +99 -0
  43. package/scripts/lib/node_ledger.rb +377 -0
  44. package/scripts/lib/node_packet.rb +908 -0
  45. package/scripts/lib/node_return.rb +199 -0
  46. package/scripts/lib/node_worktree.rb +337 -0
  47. package/scripts/lib/outcome_report.rb +440 -0
  48. package/scripts/lib/packet_wrapper.rb +132 -0
  49. package/scripts/lib/ready_set.rb +462 -0
  50. package/scripts/lib/release_guard.rb +16 -0
  51. package/scripts/lib/report_screen.rb +1967 -0
  52. package/scripts/lib/roadmap_graph.rb +210 -0
  53. package/scripts/lib/roadmap_migration.rb +95 -0
  54. package/scripts/lib/roadmap_queue.rb +155 -5
  55. package/scripts/lib/roadmap_render.rb +150 -0
  56. package/scripts/lib/roadmap_savepoint.rb +62 -12
  57. package/scripts/lib/runner_absorb.rb +620 -0
  58. package/scripts/lib/runner_answer.rb +206 -0
  59. package/scripts/lib/runner_core.rb +194 -0
  60. package/scripts/lib/runner_dispatch.rb +506 -0
  61. package/scripts/lib/runner_policy.rb +173 -0
  62. package/scripts/lib/runner_proposals.rb +275 -0
  63. package/scripts/lib/runner_rewind.rb +201 -0
  64. package/scripts/lib/runner_sweep.rb +231 -0
  65. package/scripts/lib/savepoint.rb +149 -12
  66. package/scripts/lib/screen_paint.rb +555 -0
  67. package/scripts/lib/screens/dashboard.rb +20 -0
  68. package/scripts/lib/screens/plan.rb +18 -0
  69. package/scripts/lib/screens/roadmap.rb +15 -0
  70. package/scripts/lib/session_git.rb +49 -18
  71. package/scripts/lib/session_ledger.rb +128 -0
  72. package/scripts/lib/session_usage.rb +190 -0
  73. package/scripts/lib/verify_intent.rb +33 -0
  74. package/scripts/lib/work_graph_validator.rb +201 -0
  75. package/scripts/meter-watch +57 -0
  76. package/scripts/node-packet +92 -0
  77. package/scripts/node-transition +291 -0
  78. package/scripts/outcome-report +74 -0
  79. package/scripts/plastic-lock +8 -1
  80. package/scripts/read-config +3 -3
  81. package/scripts/ready-set +126 -0
  82. package/scripts/release-check +118 -0
  83. package/scripts/report-screen +281 -0
  84. package/scripts/roadmap-graph +119 -0
  85. package/scripts/roadmap-savepoint +7 -0
  86. package/scripts/runner +397 -0
  87. package/scripts/savepoint-note +69 -0
  88. package/scripts/session-usage +56 -0
  89. package/scripts/spawn-preamble +9 -2
  90. package/scripts/validate-work-graph +39 -0
  91. package/scripts/verify-intent +2 -1
  92. package/skills/auto/SKILL.md +41 -34
  93. package/skills/auto/references/human-report-contract.md +136 -54
  94. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  95. package/skills/dashboard/SKILL.md +13 -2
  96. package/skills/dashboard/templates/dashboard-global.md +1 -1
  97. package/skills/dashboard/templates/dashboard-project.md +2 -2
  98. package/skills/doctor/SKILL.md +10 -4
  99. package/skills/intent-continuing/SKILL.md +51 -41
  100. package/skills/intent-continuing/references/board-fill.md +9 -0
  101. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  102. package/skills/intent-continuing/references/context-management.md +1 -1
  103. package/skills/intent-ending/SKILL.md +36 -16
  104. package/skills/intent-executing/SKILL.md +21 -5
  105. package/skills/intent-executing/implementer-prompt.md +6 -1
  106. package/skills/intent-speccing/SKILL.md +7 -4
  107. package/skills/releasing/SKILL.md +39 -0
  108. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  109. package/skills/releasing/references/release-lines.md +1 -1
  110. package/skills/roadmap/SKILL.md +26 -0
  111. package/skills/roadmap/references/file-format.md +10 -0
  112. package/templates/config.yml +3 -3
  113. package/templates/dashboard-screen.md +22 -0
  114. package/templates/display-fixture.md +21 -0
  115. package/templates/graph.md +16 -0
  116. package/templates/intent-screen.md +17 -0
  117. package/templates/node-decision.md +11 -0
  118. package/templates/node-research.md +11 -0
  119. package/templates/node-verify.md +13 -0
  120. package/templates/node-work.md +22 -0
  121. package/templates/outcome.md +19 -1
  122. package/templates/report-plan.md +15 -0
  123. package/templates/report-roadmap-delivered.md +10 -0
  124. package/templates/report-roadmap-plan.md +9 -0
  125. package/templates/report-roadmap-state.md +9 -0
  126. package/templates/report-state.md +11 -0
  127. 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/)