@zalom/plastic 2.0.0-alpha.13 → 2.0.0-alpha.14

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 (41) hide show
  1. package/hooks/message-display +31 -2
  2. package/package.json +1 -1
  3. package/scripts/dashboard.rb +238 -8
  4. package/scripts/doctor.rb +291 -4
  5. package/scripts/lib/dashboard_screen.rb +40 -0
  6. package/scripts/lib/doctor_core.rb +97 -2
  7. package/scripts/lib/hook_replay.rb +128 -0
  8. package/scripts/lib/installer_core.rb +23 -3
  9. package/scripts/lib/message_display.rb +151 -37
  10. package/scripts/lib/report_screen.rb +820 -18
  11. package/scripts/lib/roadmap_queue.rb +19 -2
  12. package/scripts/lib/roadmap_savepoint.rb +36 -7
  13. package/scripts/lib/savepoint.rb +12 -0
  14. package/scripts/lib/screen_paint.rb +240 -11
  15. package/scripts/lib/screens/dashboard.rb +20 -0
  16. package/scripts/lib/screens/plan.rb +18 -0
  17. package/scripts/lib/screens/roadmap.rb +15 -0
  18. package/scripts/lib/verify_intent.rb +33 -0
  19. package/scripts/report-screen +41 -7
  20. package/scripts/savepoint-note +11 -9
  21. package/skills/auto/SKILL.md +9 -9
  22. package/skills/auto/references/human-report-contract.md +79 -8
  23. package/skills/dashboard/SKILL.md +13 -2
  24. package/skills/dashboard/templates/dashboard-global.md +1 -1
  25. package/skills/dashboard/templates/dashboard-project.md +2 -2
  26. package/skills/doctor/SKILL.md +10 -4
  27. package/skills/intent-continuing/SKILL.md +19 -21
  28. package/skills/intent-continuing/references/board-fill.md +9 -0
  29. package/skills/intent-ending/SKILL.md +6 -4
  30. package/skills/intent-executing/SKILL.md +2 -0
  31. package/skills/intent-speccing/SKILL.md +7 -4
  32. package/skills/roadmap/SKILL.md +9 -0
  33. package/skills/roadmap/references/file-format.md +10 -0
  34. package/templates/dashboard-screen.md +22 -0
  35. package/templates/display-fixture.md +21 -0
  36. package/templates/intent-screen.md +1 -1
  37. package/templates/report-plan.md +15 -0
  38. package/templates/report-roadmap-delivered.md +10 -0
  39. package/templates/report-roadmap-plan.md +9 -0
  40. package/templates/report-roadmap-state.md +9 -0
  41. package/templates/report-state.md +1 -1
@@ -34,7 +34,9 @@ class RoadmapQueue
34
34
  # Entry line parser, anchored on the status vocabulary rather than end of line, so a trailing
35
35
  # parenthetical ("delivering (owner ruling...)") does not defeat the match. Accepts the em
36
36
  # dash or a hyphen as the separator; roadmap .md files are store-internal and use the em dash.
37
- ENTRY = /\A-\s*\[([ xX])\]\s+(\S+)\s+.*?[—-]\s*(queued|delivering|delivered|abandoned|blocked)\b/.freeze
37
+ # Intent 331c: group 3 captures the entry's own title text (between the id and the status
38
+ # separator), so a screen reader has it without a second parser; group 4 (was 3) is the status.
39
+ ENTRY = /\A-\s*\[([ xX])\]\s+(\S+)\s+(.*?)[—-]\s*(queued|delivering|delivered|abandoned|blocked)\b/.freeze
38
40
 
39
41
  WAVE_HEADING = /\A###\s+(.+?)\s*\z/.freeze
40
42
 
@@ -57,6 +59,21 @@ class RoadmapQueue
57
59
  analyze(mode: "which")
58
60
  end
59
61
 
62
+ # Intent 331c (D6/R1): the one public reader for ONE roadmap's parsed, INDEX-reconciled shape,
63
+ # so a screen never carries a second parser that can drift from this class's own grammar. Same
64
+ # reconciliation (`reconcile`) and the same frontier selection (`frontier_for`, R17) `queue`/
65
+ # `which` use for the whole tier, scoped to the single file at `path`.
66
+ def roadmap(path)
67
+ parsed = reconcile([parse_roadmap(path)]).first
68
+ {
69
+ slug: parsed[:slug],
70
+ path: parsed[:path],
71
+ grouping: RoadmapSavepoint.grouping_heading(File.read(path)),
72
+ batches: parsed[:waves],
73
+ frontier: frontier_for(parsed),
74
+ }
75
+ end
76
+
60
77
  private
61
78
 
62
79
  def analyze(mode:)
@@ -124,7 +141,7 @@ class RoadmapQueue
124
141
  current = { heading: m[1], entries: [] }
125
142
  waves << current
126
143
  elsif current && (em = stripped.match(ENTRY))
127
- current[:entries] << { id: em[2], raw_status: em[3].downcase }
144
+ current[:entries] << { id: em[2], text: em[3].strip, raw_status: em[4].downcase }
128
145
  end
129
146
  end
130
147
  waves
@@ -66,6 +66,25 @@ module RoadmapSavepoint
66
66
  File.read(ledger_path).each_line.filter_map { |line| parse_pair(line) }
67
67
  end
68
68
 
69
+ # Public (intent 331c): a roadmap's own ledger, parsed into `[Time, event, detail]` triples in
70
+ # file order - so a screen reader never re-derives the "<iso> <event> <detail>" line shape.
71
+ # `roadmap_path` is the roadmap `.md` file, never the `.savepoint.md` sibling directly (mirrors
72
+ # `ledger_path_for`'s own convention). No paired ledger file -> `[]`, never an invented event.
73
+ def ledger_entries(roadmap_path)
74
+ ledger_path = ledger_path_for(roadmap_path)
75
+ return [] unless File.exist?(ledger_path)
76
+
77
+ File.readlines(ledger_path).filter_map do |line|
78
+ parts = line.strip.split(/\s{2,}/, 3)
79
+ next nil unless parts.length == 3
80
+ begin
81
+ [Time.iso8601(parts[0]), parts[1], parts[2]]
82
+ rescue ArgumentError
83
+ nil
84
+ end
85
+ end
86
+ end
87
+
69
88
  def parse_pair(line)
70
89
  parts = line.strip.split(/\s{2,}/)
71
90
  parts.length >= 3 ? [parts[1], parts[2]] : nil
@@ -141,11 +160,13 @@ module RoadmapSavepoint
141
160
  end
142
161
  private_class_method :parse_log_time
143
162
 
163
+ # Public (intent 331c): the Log table on a roadmap's `delivered` screen classifies every
164
+ # `## Log` line through this same keyword vocabulary, so a screen reader never grows a second
165
+ # copy of KEYWORD_TABLE.
144
166
  def classify_event(text)
145
167
  hit = KEYWORD_TABLE.find { |regex, _event| text =~ regex }
146
168
  hit && hit[1]
147
169
  end
148
- private_class_method :classify_event
149
170
 
150
171
  WAVE_ENTRY = /\A-\s*\[([ xX])\]\s+(\S+)\s+.+—\s*(\S+)\s*\z/.freeze
151
172
 
@@ -217,13 +238,21 @@ module RoadmapSavepoint
217
238
  # already calling `ledger_path_for`), so this is the smaller diff than a new shared module.
218
239
  # Raises MissingGroupingHeading, naming the offending path, when neither heading is present.
219
240
  def grouping_section_body(text, path: nil)
220
- GROUPING_HEADINGS.each do |heading|
221
- m = text.match(/^##\s+#{Regexp.escape(heading)}\s*$(.*?)(?=^##\s|\z)/m)
222
- return m[1] if m
241
+ heading = grouping_heading(text)
242
+ unless heading
243
+ raise MissingGroupingHeading,
244
+ "#{path || '(unknown roadmap file)'}: found neither '## Batches' (canonical) nor " \
245
+ "'## Waves' (legacy) grouping heading"
223
246
  end
224
- raise MissingGroupingHeading,
225
- "#{path || '(unknown roadmap file)'}: found neither '## Batches' (canonical) nor " \
226
- "'## Waves' (legacy) grouping heading"
247
+ text.match(/^##\s+#{Regexp.escape(heading)}\s*$(.*?)(?=^##\s|\z)/m)[1]
248
+ end
249
+
250
+ # Public (intent 331c): "Batches" or "Waves", whichever grouping heading `text` carries - the
251
+ # one owner of that label so a screen's own field row (and its entries table's column header)
252
+ # never hand-picks between them a second way. nil when neither heading is present (mirrors
253
+ # grouping_section_body's own detection, one call site cheaper than two).
254
+ def grouping_heading(text)
255
+ GROUPING_HEADINGS.find { |heading| text.match?(/^##\s+#{Regexp.escape(heading)}\s*$/) }
227
256
  end
228
257
 
229
258
  # Stable dedup on the `(event, detail)` pair, keeping the first occurrence in the given
@@ -227,6 +227,18 @@ module Savepoint
227
227
  append_savepoint_line(intent_dir, "Commit", text, now)
228
228
  end
229
229
 
230
+ # The day the Report kind (below) shipped. `doctor`'s intent_reports_printed_check reads
231
+ # this so it never re-litigates a ledger recorded before the kind existed (intent 331f, R6).
232
+ REPORT_KIND_SINCE = "2026-09-05"
233
+
234
+ # Append a `Report` line: one per report screen printed (intent 331f, D2/D3). Same primitive
235
+ # as append_review_savepoint/append_commit_savepoint above, so the line shape, dedup, and
236
+ # timestamp format never drift. `savepoint_milestone` maps FILENAMES only, so a Report line
237
+ # is never mistaken for a file-landing lifecycle line by construction.
238
+ def self.append_report_savepoint(intent_dir, text, now: Time.now)
239
+ append_savepoint_line(intent_dir, "Report", text, now)
240
+ end
241
+
230
242
  TERMINAL_DISPOSITIONS = %w[delivered abandoned].freeze
231
243
 
232
244
  # Append the terminal bookend `Done delivered|abandoned`, written by the
@@ -17,11 +17,21 @@ require_relative "intent_screen_ansi"
17
17
  # markdown_safe are caller arguments, exactly like IntentScreenAnsi before it
18
18
  # (316a1); the 318 ceiling holds - the palette is IntentScreenAnsi's, no new
19
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.
20
28
  module ScreenPaint
21
29
  A = IntentScreenAnsi
22
30
 
23
31
  # A screen's first line: "## ▶ id · name", "## ✔ id · name · delivered",
24
- # "▶ In delivery · ...", "✔ id · name · delivered in ...".
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.
25
35
  OPENER_RE = /\A(?:## )?[▶✔] .+ · /.freeze
26
36
 
27
37
  FIELD_LINE_RE = /\A(Stage|Next|Changed|Lead|Progress)(\s{2,})(.*)\z/.freeze
@@ -35,11 +45,145 @@ module ScreenPaint
35
45
  # text is exactly "not recorded" greys wherever it appears.
36
46
  EVIDENCE_PROOF_KINDS = %w[suite red ship doctor deposits verdict].freeze
37
47
  EVIDENCE_DEVIATION_KINDS = %w[deviates].freeze
38
- NOTE_HEADERS = %w[Source Why].freeze
48
+ # "Reason" is the current header (D5, intent 331f); "Why" stays too so a screen captured
49
+ # before the rename still paints (the header map's own forgiving-reader guarantee).
50
+ NOTE_HEADERS = %w[Source Why Reason].freeze
39
51
  NOT_RECORDED = "not recorded"
40
52
 
53
+ # Intent 331f (finding 1, post-exec review): the width bound a column shrink never crosses,
54
+ # and the glyphs that mark a column as a progress bar, never itself shrunk. Shared with
55
+ # ReportScreen.fit_table_block (report_screen.rb requires this file, not the other way
56
+ # around) so a markdown row and a painted row bound their columns the same way.
57
+ FIT_COLUMN_FLOOR = 8
58
+ PROGRESS_BAR_CHARS_RE = /[█░]/.freeze
59
+
60
+ @registry = {}
61
+
41
62
  module_function
42
63
 
64
+ # Intent 331f1 (spec.md's acceptance rule): the conservative terminal cost used ONLY to
65
+ # enforce the 115-column bound - ANSI escapes stripped, then every character at or above
66
+ # U+1100 (the East Asian Wide/Ambiguous threshold; a block glyph like "█"/"░" sits well past
67
+ # it) counts two columns, everything else one. `IntentScreenAnsi.visible_width` stays a
68
+ # plain ANSI-stripped `.length` because it drives PADDING, not the bound - a terminal that
69
+ # draws "█" one column wide would misalign if padding used this conservative cost.
70
+ # Over-bounding only ever makes a row narrower, never wraps one, so using this rule for the
71
+ # bound alone is always safe.
72
+ WIDE_CODEPOINT_MIN = 0x1100
73
+
74
+ def display_columns(text)
75
+ text.to_s.gsub(A::ANSI_RE, "").each_char.sum { |c| c.ord >= WIDE_CODEPOINT_MIN ? 2 : 1 }
76
+ end
77
+
78
+ # Truncate `text` to at most `max_chars` DISPLAY COLUMNS, cutting at the last whitespace at
79
+ # or before the limit (never mid-word) and appending a single ellipsis when truncation
80
+ # happens. The one shared implementation (intent 331f, finding 1; intent 331f1 finding A1):
81
+ # ReportScreen.truncate_on_word_boundary and dashboard.rb's own helper of the same name both
82
+ # delegate here. The ellipsis (U+2026) itself sits above WIDE_CODEPOINT_MIN, so it costs TWO
83
+ # display columns even though it is one character - the budget reserves that display width,
84
+ # not `ellipsis.length`, or every truncated cell lands one column over (finding A1). Any
85
+ # ellipsis already trailing the word-boundary slice is dropped before the fresh one is
86
+ # appended, so a value truncated twice (the assembled-row backstop truncating a cell that
87
+ # was already cut) never stacks a second ellipsis onto the first.
88
+ def truncate_on_word_boundary(text, max_chars)
89
+ t = text.to_s
90
+ return t if display_columns(t) <= max_chars
91
+ ellipsis = A::ELLIPSIS
92
+ budget = [max_chars - display_columns(ellipsis), 0].max
93
+ # Walk characters accumulating DISPLAY columns, not a raw character index: the row-level
94
+ # backstop calls this on an ALREADY-ASSEMBLED row that can carry several prior per-cell
95
+ # ellipses (each one two display columns for one character), so a plain `t[0, limit]`
96
+ # character slice can under-cut and still land over budget once its own wide characters
97
+ # are counted.
98
+ cols = 0
99
+ cut_at = 0
100
+ t.each_char do |c|
101
+ w = c.ord >= WIDE_CODEPOINT_MIN ? 2 : 1
102
+ break if cols + w > budget
103
+ cols += w
104
+ cut_at += 1
105
+ end
106
+ slice = t[0, cut_at]
107
+ cut = slice.rindex(/\s/)
108
+ slice = slice[0, cut] if cut && cut.positive?
109
+ slice = slice.rstrip
110
+ slice = slice.chomp(ellipsis) while slice.end_with?(ellipsis)
111
+ "#{slice}#{ellipsis}"
112
+ end
113
+
114
+ # Shrinks a row of column `widths` until their sum fits `budget`: the widest shrinkable
115
+ # column loses one column at a time, ties break toward the leftmost column, no column ever
116
+ # drops below its own `floors[i]` (FIT_COLUMN_FLOOR for every column when `floors` is
117
+ # omitted, the original behavior), and a column flagged in `bar_columns` (it carries a
118
+ # progress bar) is never touched regardless of its floor. The one shared shrink (intent
119
+ # 331f, finding 1; intent 331f1, S3): ReportScreen.fit_table_block's markdown row and
120
+ # ScreenPaint.paint_data_table's painted row both bound through this rather than carrying
121
+ # two loops that could drift apart. Returns a new array; the caller's own `widths` is left
122
+ # untouched.
123
+ def shrink_column_widths(widths, budget, bar_columns:, floors: nil)
124
+ floors ||= Array.new(widths.length, FIT_COLUMN_FLOOR)
125
+ widths = widths.dup
126
+ loop do
127
+ break if widths.sum <= budget
128
+ candidates = widths.each_index.select { |ci| !bar_columns[ci] && widths[ci] > floors[ci] }
129
+ break if candidates.empty?
130
+ target = candidates.max_by { |ci| [widths[ci], -ci] }
131
+ widths[target] -= 1
132
+ end
133
+ widths
134
+ end
135
+
136
+ # Intent 331f1, S3 (brief 4): the per-column floor a data-table shrink never crosses - a
137
+ # column never shrinks below its own header cell, below its natural width when that is at
138
+ # most 10 columns (the id case: an id column is already narrow, so "natural width" IS its
139
+ # floor and a uniform 8-column floor could still crush it), or below a bar cell (bar columns
140
+ # are handled separately, by `bar_columns:` above, but a caller may still pass a natural
141
+ # width here too - `[header_len, base].max` never fights that).
142
+ def column_floor(header_len, natural_width)
143
+ base = natural_width <= 10 ? natural_width : FIT_COLUMN_FLOOR
144
+ [header_len, base].max
145
+ end
146
+
147
+ # Intent 331f1 (post-execution review, findings P2/P3): a padded cell renders as its column
148
+ # width (characters, from `ljust`) plus that cell's own DISPLAY overage -
149
+ # `display_columns(cell) - cell.length` - because a progress-bar glyph or an already-embedded
150
+ # ellipsis costs more display columns than characters. Crediting overage only to columns
151
+ # flagged `bar_columns:` (the old rule) misses a padded cell that carries an ellipsis in an
152
+ # ordinary text column - exactly what ReportScreen.fit_row_cell hands roadmap_state's Intent
153
+ # column - so the assembled row can still land over the bound even though every column width
154
+ # was computed correctly. The real cost is per ROW, not per column: `rows_of_cells` is an
155
+ # array of rows, each an array of already-stripped, not-yet-padded/truncated cell strings, and
156
+ # this returns the worst row's total overage - the one number ScreenPaint.paint_data_table,
157
+ # ReportScreen.fit_table_block, and ReportScreen.fit_field_table_block all reserve out of their
158
+ # budget, so the three renderers spend one rule rather than three copies that can drift.
159
+ def row_display_overage(rows_of_cells)
160
+ rows_of_cells.map { |cells| cells.sum { |c| display_columns(c.to_s) - c.to_s.length } }.max || 0
161
+ end
162
+
163
+ # Registers a screen kind's opener grammar (a Regexp or a callable taking
164
+ # the stripped opener line and returning truthy/falsy), plus an optional
165
+ # `paint:` lambda for a kind that needs its own palette (`call(text,
166
+ # color:, width:, markdown_safe:)`). Idempotent by kind: registering the
167
+ # same kind again replaces its entry rather than adding a second one.
168
+ def register(kind, opener:, paint: nil)
169
+ @registry[kind.to_sym] = { opener: opener, paint: paint }
170
+ end
171
+
172
+ # Every registered kind's name, shipped and caller-added alike.
173
+ def kinds
174
+ @registry.keys
175
+ end
176
+
177
+ # The first registered kind whose opener matches `text` (an already
178
+ # stripped, single line), or nil.
179
+ def opener_kind(text)
180
+ @registry.find { |_, entry| opener_matches?(entry[:opener], text) }&.first
181
+ end
182
+
183
+ def opener_matches?(opener, text)
184
+ opener.respond_to?(:call) ? !!opener.call(text) : !!opener.match?(text)
185
+ end
186
+
43
187
  # The classifier both paint and region_end share. `idx`/`opener_idx` give
44
188
  # the positional rule its footing: the line right after a title is the meta
45
189
  # line (delivered/delay print one), recognizable by its " · " separators.
@@ -47,7 +191,7 @@ module ScreenPaint
47
191
  text = line.chomp
48
192
  stripped = text.strip
49
193
  return :blank if stripped.empty?
50
- return :opener if OPENER_RE.match?(stripped) && text == stripped
194
+ return :opener if text == stripped && !opener_kind(stripped).nil?
51
195
  return :table if text.lstrip.start_with?("|")
52
196
  return :bold if BOLD_LEAD_RE.match?(stripped) && text == stripped
53
197
  return :meta if idx && opener_idx && idx == opener_idx + 1 && stripped.include?(" · ")
@@ -101,10 +245,20 @@ module ScreenPaint
101
245
  lines = text.to_s.lines
102
246
  first_idx = lines.index { |l| !l.strip.empty? }
103
247
  return nil if first_idx.nil?
248
+
249
+ kind = opener_kind(lines[first_idx].strip)
104
250
  # Intent 330 (D7/O3.26): a screen that is nothing but a single known
105
251
  # closer line (e.g. "No intents delivered in this session.") has no
106
252
  # opener to require - it is already the whole, honest message.
107
- return nil unless %i[opener closer].include?(classify(lines[first_idx]))
253
+ return nil unless kind || classify(lines[first_idx]) == :closer
254
+
255
+ # Intent 331a (D6): a registered kind MAY supply its own paint lambda;
256
+ # when it does, this whole call delegates to it instead of the shared
257
+ # pipeline below. No shipped kind does.
258
+ if kind
259
+ custom = @registry[kind][:paint]
260
+ return custom.call(text, color: color, width: width, markdown_safe: markdown_safe) if custom
261
+ end
108
262
 
109
263
  out = +""
110
264
  table = []
@@ -185,8 +339,17 @@ module ScreenPaint
185
339
  row.split("|", -1).map(&:strip)[1..-2].to_a
186
340
  end
187
341
 
342
+ # Intent 331f1 (finding A3): the ONE classifier, called by both this painter and
343
+ # ReportScreen.fit_table_block's field-vs-data dispatch, so a table is never fitted by one
344
+ # rule and painted by another. A block is a field table when EVERY non-separator,
345
+ # non-blank-scaffold row's first cell is bold - not just the first row (the old rule):
346
+ # a data table whose header cell happens to be bold (e.g. "**Kind**") still has ordinary,
347
+ # unbold data rows underneath it, so it stays a data table. A block with nothing left after
348
+ # stripping separators/scaffolding classifies as neither (false).
188
349
  def field_table?(rows)
189
- rows.first&.gsub(/[\s|]/, "") == "" || cells_of(rows.first).first.to_s.start_with?("**")
350
+ candidates = rows.reject { |r| SEPARATOR_RE.match?(r) || r.gsub(/[\s|]/, "").empty? }
351
+ return false if candidates.empty?
352
+ candidates.all? { |r| cells_of(r).first.to_s.start_with?("**") }
190
353
  end
191
354
 
192
355
  # A field table ("| | | |" scaffold, "| **Key** | value | note |" rows)
@@ -199,11 +362,11 @@ module ScreenPaint
199
362
  rows = rows.reject { |r| SEPARATOR_RE.match?(r) || r.gsub(/[\s|]/, "").empty? }
200
363
  return "" if rows.empty?
201
364
 
202
- if cells_of(rows.first).first.to_s.start_with?("**")
365
+ if field_table?(rows)
203
366
  return paint_field_table(rows, color: color, width: width, markdown_safe: markdown_safe)
204
367
  end
205
368
 
206
- paint_data_table(rows, color: color, markdown_safe: markdown_safe)
369
+ paint_data_table(rows, color: color, width: width, markdown_safe: markdown_safe)
207
370
  end
208
371
 
209
372
  # Intent 317a1 (O3, D9-D11, D14, D15): the same three-column geometry as
@@ -228,17 +391,66 @@ module ScreenPaint
228
391
  # bare `widths[ci]`) is the ragged-row guard: `ReportScreen.escape` writes
229
392
  # `\|` while `cells_of` still splits on every `|`, so a row can carry more
230
393
  # cells than its header without either side ever raising.
231
- def paint_data_table(rows, color:, markdown_safe:)
394
+ #
395
+ # Intent 331f (finding 1, post-exec review): padding every cell to its column's max across
396
+ # ALL rows can paint a row wider than `width` even when every plain row on its own measured
397
+ # under the bound (D7 is on the RENDERED row) - a row short in one column but maximal in
398
+ # another paints wider than its own plain row ever was. `width` bounds the column widths the
399
+ # same way ReportScreen.fit_table_block bounds a markdown row's, through the one shared
400
+ # shrink, before any cell is padded or joined.
401
+ def paint_data_table(rows, color:, width:, markdown_safe:)
232
402
  grid = rows.map { |r| cells_of(r).map { |c| clean(c, markdown_safe) } }
233
- widths = grid.first.each_index.map { |i| grid.map { |r| r[i].to_s.length }.max }
403
+ ncols = grid.first.length
404
+ widths = (0...ncols).map { |i| grid.map { |r| r[i].to_s.length }.max }
405
+ bar_columns = (0...ncols).map { |i| grid.any? { |r| r[i].to_s =~ PROGRESS_BAR_CHARS_RE } }
406
+ # Intent 331f1 (post-exec review, P2/P3): the shared row-overage rule, not a per-column bar
407
+ # credit - see ScreenPaint.row_display_overage's own comment for why the bar-only credit
408
+ # missed a padded cell carrying an ellipsis.
409
+ overage = row_display_overage(grid)
410
+ # Intent 331f1, S3 (brief 4): per-column minimums - never below the header cell, never
411
+ # below a natural width of 10 or less (the id case).
412
+ header_len = (0...ncols).map { |i| grid.first[i].to_s.length }
413
+ floors = (0...ncols).map { |i| bar_columns[i] ? widths[i] : column_floor(header_len[i], widths[i]) }
414
+ budget = width - (2 + 2 * (ncols - 1)) - overage
415
+ widths = shrink_column_widths(widths, budget, bar_columns: bar_columns, floors: floors)
234
416
  kind_col = grid.first.first == "Kind" ? 0 : nil
235
417
  note_col = grid.first.index { |h| NOTE_HEADERS.include?(h) }
236
418
 
237
419
  out = +""
238
420
  grid.each_with_index do |cols, ri|
239
421
  last_ci = cols.length - 1
240
- cells = cols.each_with_index.map do |cell, ci|
241
- padded = ci == last_ci ? cell.to_s : cell.to_s.ljust(widths[ci] || 0)
422
+ texts = cols.each_with_index.map do |cell, ci|
423
+ text = cell.to_s
424
+ w = widths[ci]
425
+ text = truncate_on_word_boundary(text, w) if w && text.length > w
426
+ ci == last_ci ? text : text.ljust(w || 0)
427
+ end
428
+
429
+ # Intent 331f1 (post-exec review, P4): the row-level backstop. Even with every column
430
+ # pinned at its floor, the assembled PLAIN row can still exceed `width` once padding/bar/
431
+ # ellipsis overage is counted - shrink the widest SHRINKABLE cell's PLAIN TEXT (never a
432
+ # bar column, and always before styling, since truncating an already-styled string cuts
433
+ # ANSI escapes mid-sequence) until the row fits or nothing is left to shrink.
434
+ loop do
435
+ row_dw = display_columns((" " + texts.join(" ")).rstrip)
436
+ break if row_dw <= width
437
+ excess = row_dw - width
438
+ shrinkable = (0...ncols).reject { |ci| bar_columns[ci] }
439
+ break if shrinkable.empty?
440
+ target = shrinkable.max_by { |ci| display_columns(texts[ci]) }
441
+ cur_dw = display_columns(texts[target])
442
+ # A bare ellipsis alone already costs 2 display columns; below that floor there is
443
+ # nothing left to cut. Also bail the moment a cut makes no progress (a text already at
444
+ # or under the ellipsis floor re-truncates to the same "…" forever) - a row this wide
445
+ # even at every floor is the documented extreme case, not an infinite loop.
446
+ break if cur_dw <= 2
447
+ shrunk = truncate_on_word_boundary(texts[target].rstrip, [cur_dw - excess, 2].max)
448
+ break if display_columns(shrunk) >= cur_dw
449
+ texts[target] = shrunk
450
+ end
451
+
452
+ cells = texts.each_with_index.map do |padded, ci|
453
+ cell = cols[ci]
242
454
  # An empty cell never gets styled (317a1 post-exec review, finding
243
455
  # 1): `A.styled("", ...)` still emits a color-open/RESET pair around
244
456
  # nothing visible, and that hides the join separator's own trailing
@@ -276,4 +488,21 @@ module ScreenPaint
276
488
  def clean(text, markdown_safe)
277
489
  markdown_safe ? A.clean(text) : text
278
490
  end
491
+
492
+ # --- the five shipped kinds (intent 331a, D6) -----------------------------
493
+ #
494
+ # Each opener is a strict subset of OPENER_RE, decomposed by shape rather
495
+ # than by any per-kind palette: `intent`/`state` share the exact template
496
+ # line (templates/intent-screen.md and templates/report-state.md both open
497
+ # with "## ▶ {{id}} · {{name}}"); `delivered` narrows to the "## ✔ id ·
498
+ # name · delivered" shape report_screen.rb emits; `roster` and `delay`
499
+ # cover every bare (no "## ") glyph line, which is exactly ScreenPaint's
500
+ # original, single OPENER_RE decomposed into its "## "-prefixed half and
501
+ # its bare half - the union is unchanged, so no existing screen stops
502
+ # being recognized.
503
+ register(:intent, opener: /\A## [▶✔] .+ · /)
504
+ register(:state, opener: /\A## [▶✔] .+ · /)
505
+ register(:delivered, opener: /\A## ✔ .+ · /)
506
+ register(:roster, opener: /\A▶ .+ · /)
507
+ register(:delay, opener: /\A✔ .+ · /)
279
508
  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/)
@@ -70,6 +70,10 @@ module VerifyIntent
70
70
  lines.concat(diffstat_lines)
71
71
  checks[:diffstat] = diffstat_check
72
72
 
73
+ report_lines_out, report_check = run_report_lines_check(intent_dir: intent_dir)
74
+ lines.concat(report_lines_out)
75
+ checks[:report] = report_check
76
+
73
77
  if Worktree.blank?(suite)
74
78
  checks[:suite] = { status: "skipped" }
75
79
  else
@@ -235,6 +239,35 @@ module VerifyIntent
235
239
  end
236
240
  end
237
241
 
242
+ # --- check: the Report savepoint lines (intent 331f, F17) ------------------------
243
+
244
+ REPORT_LINE_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s{2,}(\S+)\s{2,}(.+?)\s*\z/.freeze
245
+
246
+ # Every `Report`-kind savepoint line for this intent, oldest first: [timestamp, text].
247
+ # Never fails or gates anything (D3: the diffstat check already prints a summary block,
248
+ # this folds into the same verdict so verify-intent surfaces them too) - a delivery with
249
+ # no Report line is visible to `doctor`'s intent_reports_printed_check instead.
250
+ def report_lines(intent_dir)
251
+ path = File.join(intent_dir, "savepoint.md")
252
+ return [] unless File.exist?(path)
253
+
254
+ File.readlines(path).filter_map do |line|
255
+ m = line.strip.match(REPORT_LINE_RE)
256
+ next nil unless m && m[2] == "Report"
257
+ [m[1], m[3]]
258
+ end
259
+ end
260
+
261
+ def run_report_lines_check(intent_dir:)
262
+ entries = report_lines(intent_dir)
263
+ if entries.empty?
264
+ [["report lines: none recorded"], { status: "pass", lines: [] }]
265
+ else
266
+ lines = ["report lines:"] + entries.map { |ts, text| "#{ts} Report #{text}" }
267
+ [lines, { status: "pass", lines: entries }]
268
+ end
269
+ end
270
+
238
271
  # --- check 4: the optional suite --------------------------------------------------
239
272
 
240
273
  # The supplied command is very likely a ruby command, so it is spawned with RUBYOPT
@@ -2,19 +2,21 @@
2
2
  # encoding: UTF-8
3
3
  # frozen_string_literal: true
4
4
 
5
- # report-screen - the four delivery-report screens: mid-delivery state,
6
- # post-delivery delivered, and delay (intent 317), plus session, the whole of
7
- # one session's delivered work followed by the roster (intent 330). Each fills
8
- # from the record via scripts/lib/report_screen.rb; no number here is written
9
- # by eye.
5
+ # report-screen - the pre-delivery plan (intent 331b), the four delivery
6
+ # reports: mid-delivery state, post-delivery delivered, and delay (intent
7
+ # 317), plus session, the whole of one session's delivered work followed by
8
+ # the roster (intent 330). Each fills from the record via
9
+ # scripts/lib/report_screen.rb; no number here is written by eye.
10
10
  #
11
11
  # Usage:
12
+ # report-screen plan <intent_dir> [--ansi]
12
13
  # report-screen state <intent_dir> [--changed "<text>"] [--ansi]
13
14
  # report-screen state --all <store_root> [--changed "<text>"] [--ansi]
14
15
  # report-screen delivered <intent_dir> [--ansi] [--repo <dir>]
15
16
  # report-screen delay <intent_dir> [--ansi]
16
17
  # report-screen session <tier_root> [--session <id>] [--since <iso>]
17
18
  # [--ledger-root <dir>] [--ansi]
19
+ # report-screen roadmap <roadmap.md> plan|state|delivered [--ansi] [--store-root <dir>]
18
20
  #
19
21
  # --ansi delegates to ScreenPaint (intent 317a, D1), the parser/re-layouter
20
22
  # in the shared TUI core. Selection is by capability, never by harness:
@@ -34,6 +36,13 @@ require_relative "lib/intent_screen"
34
36
  require_relative "lib/screen_paint"
35
37
  require_relative "lib/session_ledger"
36
38
 
39
+ # Intent 331a (D6/R8): every caller-added screen kind file registers itself
40
+ # on load (ScreenPaint.register), so this glob is the ONLY wiring a new kind
41
+ # needs - no edit here, no edit to screen_paint.rb. Sorted for a
42
+ # deterministic load order; tolerates the directory being absent or empty
43
+ # (Dir.glob answers [] either way), which is this repo's own state today.
44
+ Dir.glob(File.join(__dir__, "lib", "screens", "*.rb")).sort.each { |f| require_relative f }
45
+
37
46
  def usage_abort(message)
38
47
  warn "report-screen: #{message}"
39
48
  exit 2
@@ -119,6 +128,7 @@ repo_flag = nil
119
128
  session_flag = nil
120
129
  since_flag = nil
121
130
  ledger_root_flag = nil
131
+ store_root_flag = nil
122
132
  positional = []
123
133
 
124
134
  while (arg = args.shift)
@@ -140,13 +150,15 @@ while (arg = args.shift)
140
150
  since_flag = args.shift or usage_abort("--since needs a value")
141
151
  when "--ledger-root"
142
152
  ledger_root_flag = args.shift or usage_abort("--ledger-root needs a path")
153
+ when "--store-root"
154
+ store_root_flag = args.shift or usage_abort("--store-root needs a path")
143
155
  else
144
156
  usage_abort("unknown flag #{arg.inspect}") if arg.start_with?("--") && arg != "--all"
145
157
  positional << arg
146
158
  end
147
159
  end
148
160
 
149
- usage_abort("usage: report-screen state|delivered|delay|session <intent_dir> [--changed \"<text>\"] [--ansi]") unless verb
161
+ usage_abort("usage: report-screen plan|state|delivered|delay|session <intent_dir> [--changed \"<text>\"] [--ansi]") unless verb
150
162
 
151
163
  all_mode = positional.delete("--all") ? true : false
152
164
  target = positional.first
@@ -160,6 +172,16 @@ def paint(text, ansi_enabled, _renderer_path = nil)
160
172
  end
161
173
 
162
174
  case verb
175
+ when "plan"
176
+ usage_abort("usage: report-screen plan <intent_dir> [--ansi]") unless target
177
+ intent_dir = File.expand_path(target)
178
+ usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
179
+ store_root = File.expand_path("../..", intent_dir)
180
+ template_path ||= File.expand_path("../templates/report-plan.md", __dir__)
181
+ usage_abort("template not found at #{template_path}") unless File.exist?(template_path)
182
+ out = ReportScreen.render_plan(intent_dir: intent_dir, store_root: store_root,
183
+ template: File.read(template_path))
184
+ $stdout.write paint(out, ansi_enabled)
163
185
  when "state"
164
186
  if all_mode
165
187
  usage_abort("usage: report-screen state --all <store_root>") unless target
@@ -233,8 +255,20 @@ when "session"
233
255
  note: note, now: now, painter: ->(text) { paint(text, ansi_enabled) }
234
256
  )
235
257
  $stdout.write out
258
+ when "roadmap"
259
+ usage_abort("usage: report-screen roadmap <roadmap.md> plan|state|delivered [--ansi] [--store-root <dir>]") if positional.empty?
260
+ roadmap_path = File.expand_path(positional[0])
261
+ usage_abort("#{roadmap_path} does not exist") unless File.exist?(roadmap_path)
262
+ sub_verb = positional[1]
263
+ usage_abort("usage: report-screen roadmap <roadmap.md> plan|state|delivered") unless sub_verb
264
+ unless %w[plan state delivered].include?(sub_verb)
265
+ usage_abort("unknown sub-verb #{sub_verb.inspect} (use plan|state|delivered)")
266
+ end
267
+ roadmap_store_root = store_root_flag ? File.expand_path(store_root_flag) : nil
268
+ out = ReportScreen.render_roadmap(path: roadmap_path, verb: sub_verb, store_root: roadmap_store_root)
269
+ $stdout.write paint(out, ansi_enabled)
236
270
  else
237
- usage_abort("unknown verb #{verb.inspect} (use state|delivered|delay|session)")
271
+ usage_abort("unknown verb #{verb.inspect} (use plan|state|delivered|delay|session|roadmap)")
238
272
  end
239
273
 
240
274
  exit 0