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

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 +55 -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 +211 -0
  8. package/scripts/lib/installer_core.rb +23 -3
  9. package/scripts/lib/message_display.rb +267 -47
  10. package/scripts/lib/report_screen.rb +837 -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
@@ -14,6 +14,9 @@ require "date"
14
14
  require_relative "intent_screen"
15
15
  require_relative "lock"
16
16
  require_relative "session_ledger"
17
+ require_relative "roadmap_queue"
18
+ require_relative "roadmap_savepoint"
19
+ require_relative "screen_paint"
17
20
 
18
21
  module ReportScreen
19
22
  NOT_RECORDED = "not recorded"
@@ -59,6 +62,330 @@ module ReportScreen
59
62
  text.to_s.gsub("|", "\\|")
60
63
  end
61
64
 
65
+ # --- width bound (D7, intent 331f) --------------------------------------------
66
+ #
67
+ # ReportScreen.fit_screen(text, limit:) is the one shared pass every public render entry
68
+ # point in this file (and dashboard.rb's screen renderer) calls last, so no rendered row
69
+ # ever passes the limit. Input unchanged byte for byte when nothing is over the limit.
70
+
71
+ FIT_SCREEN_DEFAULT_LIMIT = 115
72
+ # The column floor and the progress-bar glyph regex are ScreenPaint's own (intent 331f,
73
+ # finding 1): ScreenPaint.paint_data_table shrinks a painted row's columns through the same
74
+ # rule this file's own fit_table_block uses, so both aliases point at the one definition
75
+ # rather than carrying a second copy that could drift.
76
+ FIT_SCREEN_COLUMN_FLOOR = ScreenPaint::FIT_COLUMN_FLOOR
77
+ PROGRESS_BAR_CHARS_RE = ScreenPaint::PROGRESS_BAR_CHARS_RE
78
+
79
+ # Truncate `text` to at most `max_chars`, cutting at the last whitespace at or before the
80
+ # limit (never mid-word) and appending a single ellipsis when truncation happens. The one
81
+ # shared implementation now lives on ScreenPaint (intent 331f, finding 1); dashboard.rb's own
82
+ # helper of the same name delegates here, and this delegates onward so neither caller's own
83
+ # name has to change.
84
+ def self.truncate_on_word_boundary(text, max_chars)
85
+ ScreenPaint.truncate_on_word_boundary(text, max_chars)
86
+ end
87
+
88
+ # Split on every pipe, escaped or not - the SAME rule ScreenPaint.cells_of uses (R3), so the
89
+ # fitter and the painter can never count a row's columns differently. Raw (unstripped) cells,
90
+ # so callers can still tell a padded column from an unpadded one.
91
+ def self.raw_cells_of(row)
92
+ row.split("|", -1)[1..-2].to_a
93
+ end
94
+
95
+ # Where a title ends (D8, orchestrator ruling 2026-09-05). A title ends at the first colon
96
+ # FOLLOWED BY A SPACE, which is how a person writes a label before its explanation. Any
97
+ # colon would also cut inside a URL or a clock time and leave a name no reader recognizes:
98
+ # zlatkocodes intent 4 opens "About page redesign and header navigation order. Rebuild
99
+ # https://zlatkocodes.com/about/ ... styling: ..." and used to render as "... Rebuild https".
100
+ # A title can carry both boundaries, and then the earlier one is the name: zlatkocodes 4 also
101
+ # has a real label colon, 130 characters in, long after its opening sentence ends. With
102
+ # neither boundary the title is the whole line.
103
+ # A line that opens with its colon has no label to take, so it falls back the same way. The
104
+ # one implementation: dashboard.rb reads titles through this rather than splitting again.
105
+ TITLE_LABEL_RE = /\A(.*?): /m.freeze
106
+ TITLE_SENTENCE_RE = /\A(.*?[.!?])(?:\s|\z)/m.freeze
107
+
108
+ def self.title_before_colon(text, max: 120)
109
+ line = text.to_s.strip
110
+ candidates = [TITLE_LABEL_RE, TITLE_SENTENCE_RE].filter_map { |re| line[re, 1]&.strip }
111
+ .reject(&:empty?)
112
+ truncate_on_word_boundary(candidates.min_by(&:length) || line, max)
113
+ end
114
+
115
+ # Intent 331f1 (RC1): every bound check below measures in DISPLAY COLUMNS
116
+ # (ScreenPaint.display_columns - ANSI stripped, a character at or above U+1100 counts two),
117
+ # not String#length - a bar row can pass a character-count check while still over the real
118
+ # 115-column bound, which is exactly why the suite stayed green while real screens rendered
119
+ # over it (spec.md's defect 3/4).
120
+ def self.fit_screen(text, limit: FIT_SCREEN_DEFAULT_LIMIT)
121
+ lines = text.to_s.lines
122
+ return text if lines.all? { |l| ScreenPaint.display_columns(l.chomp) <= limit }
123
+
124
+ out = +""
125
+ i = 0
126
+ while i < lines.length
127
+ if lines[i].lstrip.start_with?("|")
128
+ block = []
129
+ while i < lines.length && lines[i].lstrip.start_with?("|")
130
+ block << lines[i]
131
+ i += 1
132
+ end
133
+ out << fit_table_block(block, limit)
134
+ else
135
+ out << fit_plain_line(lines[i], limit)
136
+ i += 1
137
+ end
138
+ end
139
+ out
140
+ end
141
+
142
+ def self.fit_plain_line(line, limit)
143
+ body = line.chomp
144
+ return line if ScreenPaint.display_columns(body) <= limit
145
+ ending = line[body.length..].to_s
146
+ "#{truncate_on_word_boundary(body, limit)}#{ending}"
147
+ end
148
+
149
+ # Intent 331f1 (finding A3/A5): field tables and data tables get their own fitters
150
+ # (ScreenPaint.field_table? is the ONE classifier both this and the painter use), and the
151
+ # block-level guard above already lets an already-fitting block - every row already at or
152
+ # under `limit` in display columns, exactly what ReportScreen.fit_row_cell/
153
+ # roadmap_state_entries_table already produce for the roadmap Batches table - through
154
+ # untouched, so a table-wide shrink never re-truncates a row a caller already sized
155
+ # correctly (A5).
156
+ def self.fit_table_block(block, limit)
157
+ return block.join if block.all? { |l| ScreenPaint.display_columns(l.chomp) <= limit }
158
+
159
+ rows = block.map(&:chomp)
160
+ return fit_field_table_block(block, limit) if ScreenPaint.field_table?(rows)
161
+
162
+ is_sep = rows.map { |r| r.match?(ScreenPaint::SEPARATOR_RE) }
163
+ raw_rows = rows.map { |r| raw_cells_of(r) }
164
+ ncols = raw_rows.map(&:length).max.to_i
165
+ return block.join if ncols.zero?
166
+
167
+ stripped_cols = Array.new(ncols) { [] }
168
+ stripped_rows = []
169
+ header_idx = raw_rows.each_index.find { |ri| !is_sep[ri] }
170
+ raw_rows.each_with_index do |cells, ri|
171
+ next if is_sep[ri]
172
+ row = (0...ncols).map { |ci| cells[ci].to_s.strip }
173
+ row.each_with_index { |c, ci| stripped_cols[ci] << c }
174
+ stripped_rows << row
175
+ end
176
+ widths = stripped_cols.map { |col| col.map(&:length).max.to_i }
177
+
178
+ bar_column = Array.new(ncols) { |ci| stripped_cols[ci].any? { |c| c =~ PROGRESS_BAR_CHARS_RE } }
179
+ # Intent 331f1 (post-exec review, P3): the shared row-overage rule (ScreenPaint.
180
+ # row_display_overage), not a per-column bar credit - the same fix as paint_data_table's
181
+ # own P2, so the two renderers cannot drift apart on what "fits" means.
182
+ overage = ScreenPaint.row_display_overage(stripped_rows)
183
+ # Intent 331f1, S3 (brief 4): per-column minimums - never below the header cell, never
184
+ # below a natural width of 10 or less (the id case).
185
+ header_len = Array.new(ncols) { |ci| header_idx ? raw_rows[header_idx][ci].to_s.strip.length : 0 }
186
+ floors = (0...ncols).map { |ci| bar_column[ci] ? widths[ci] : ScreenPaint.column_floor(header_len[ci], widths[ci]) }
187
+
188
+ # A column is "padded" when at least one non-last, non-separator raw cell carries more
189
+ # than the one mandatory space before its closing pipe - the ljust convention several
190
+ # tables in this file already use (state_rows, roster). Only such a column is re-padded
191
+ # after a shrink; an unpadded table stays unpadded.
192
+ padded_column = Array.new(ncols) do |ci|
193
+ next false if ci == ncols - 1
194
+ raw_rows.each_with_index.any? { |cells, ri| !is_sep[ri] && cells[ci].to_s.end_with?(" ") }
195
+ end
196
+
197
+ budget = limit - (4 + 3 * (ncols - 1)) - overage
198
+ widths = ScreenPaint.shrink_column_widths(widths, budget, bar_columns: bar_column, floors: floors)
199
+
200
+ # 331f1a (D1/D2, plan-review ruling): a separator row passes through byte-identical
201
+ # whenever its OWN unfitted input already fits the bound - rebuilding it from the
202
+ # shrunk widths (with the `[w, 3].max` floor below) is what made it assemble wider
203
+ # than any data row in the first place, landing it as the only row the backstop ever
204
+ # cut (or, when the rebuilt form happened to still fit, wider than its own "---"
205
+ # input, which D1 forbids just as much). Only when even the unmodified input cannot
206
+ # fit does the old rebuild-and-backstop path apply - the bound wins there, which is
207
+ # exactly what test_fit_screen_backstops_an_unshrinkable_row and
208
+ # test_unshrinkable_data_table_is_still_bounded pin. A blank "| | | |" scaffold
209
+ # reaching this branch is classified as a separator by the same regex, so it gets
210
+ # the identical pass-through rule.
211
+ fitted_rows = raw_rows.each_with_index.map do |cells, ri|
212
+ if is_sep[ri]
213
+ original = rows[ri]
214
+ if ScreenPaint.display_columns(original) <= limit
215
+ original
216
+ else
217
+ "| #{widths.map { |w| "-" * [w, 3].max }.join(" | ")} |"
218
+ end
219
+ else
220
+ rendered = cells.each_with_index.map do |c, ci|
221
+ next c.to_s.strip if ci >= ncols
222
+ value = c.to_s.strip
223
+ value = truncate_on_word_boundary(value, widths[ci]) if value.length > widths[ci]
224
+ padded_column[ci] && ci != ncols - 1 ? value.ljust(widths[ci]) : value
225
+ end
226
+ "| #{rendered.join(' | ')} |"
227
+ end
228
+ end
229
+
230
+ # F28: the unconditional backstop. Every shrinkable column may already sit at its floor
231
+ # and the assembled row can still be over the limit; truncate the whole row on a word
232
+ # boundary rather than let it survive past 115 - a data table's separator row included
233
+ # (test_fit_screen_backstops_an_unshrinkable_row), unlike the field-table fitter's own
234
+ # separator, which always passes through untouched (W2). A separator already passed
235
+ # through byte-identical above never trips this (it already fits by construction).
236
+ fitted_rows.map! { |r| ScreenPaint.display_columns(r) > limit ? truncate_on_word_boundary(r, limit) : r }
237
+
238
+ "#{fitted_rows.join("\n")}\n"
239
+ end
240
+
241
+ # Intent 331f1 (S2, design): the field table's own fitter - a "| | | |" scaffold or
242
+ # "| --- | --- | --- |" separator row passes through byte for byte; the label column
243
+ # (first cell) takes its natural width and never shrinks or truncates; the VALUE column
244
+ # shrinks first, down to a floor of max(24, the widest bar cell in that column) so a
245
+ # progress bar is never cut; only then does the NOTE column shrink, and when what is left
246
+ # for it falls under ScreenPaint::FIT_COLUMN_FLOOR (8) columns the note is dropped whole
247
+ # (never squeezed to "in…") and the value reclaims the freed room, back up to its own
248
+ # natural width. A value that still cannot fit ends with an ellipsis; the value floor is
249
+ # never crossed even then, so the row may still exceed `limit` in that extreme case -
250
+ # there is no row-level backstop here (that backstop is the data-table branch's own, and
251
+ # it must never touch a field table's label cell).
252
+ #
253
+ # Intent 331f1 (post-exec review, P1): `label_w`/`value_w`/`note_w` and `budget` are character
254
+ # counts spent against the 115 DISPLAY-column bound - a bar row's glyphs (2 columns each) or
255
+ # an embedded ellipsis cost more display columns than characters, so a row can pass this
256
+ # arithmetic while still landing well over the real bound. `ScreenPaint.row_display_overage`
257
+ # reserves the worst row's own overage up front (P1-P3's shared fix); a fresh ellipsis this
258
+ # function's OWN truncation adds where none existed before can still leave a small residual,
259
+ # which the corrective loop below closes by re-measuring the actual assembled row and shrinking
260
+ # note (then value, never below its floor) by the exact excess.
261
+ #
262
+ # Intent 331f1 (P5): the label (and, when flagged, the value) column is re-padded exactly the
263
+ # way `fit_table_block`'s own `padded_column` rule would - ljust in CHARACTERS, never display
264
+ # columns, so a terminal drawing a bar glyph one column wide stays aligned - restoring the
265
+ # alignment a fitted field table lost.
266
+ def self.fit_field_table_block(block, limit)
267
+ return block.join if block.all? { |l| ScreenPaint.display_columns(l.chomp) <= limit }
268
+
269
+ rows = block.map(&:chomp)
270
+ is_sep = rows.map { |r| r.match?(ScreenPaint::SEPARATOR_RE) }
271
+ content_idx = rows.each_index.reject { |ri| is_sep[ri] }
272
+ return block.join if content_idx.empty?
273
+
274
+ raw_content = content_idx.map { |ri| raw_cells_of(rows[ri]) }
275
+ parsed = content_idx.map { |ri| ScreenPaint.cells_of(rows[ri]) }
276
+ ncols = parsed.map(&:length).max.to_i
277
+ return block.join if ncols.zero?
278
+
279
+ label_w = parsed.map { |c| c[0].to_s.length }.max.to_i
280
+ value_texts = parsed.map { |c| c[1].to_s }
281
+ natural_value_w = value_texts.map(&:length).max.to_i
282
+ bar_value_w = value_texts.select { |v| v =~ PROGRESS_BAR_CHARS_RE }.map(&:length).max.to_i
283
+ value_floor = [24, bar_value_w].max
284
+ value_w = natural_value_w
285
+
286
+ has_note = ncols > 2 && parsed.any? { |c| !c[2].to_s.empty? }
287
+ note_texts = has_note ? parsed.map { |c| c[2].to_s } : []
288
+ note_w = note_texts.map(&:length).max.to_i
289
+
290
+ gaps = ncols - 1
291
+ overage = ScreenPaint.row_display_overage(parsed.map { |c| (0...ncols).map { |ci| c[ci].to_s } })
292
+ budget = limit - (4 + 3 * gaps) - overage
293
+ overflow = (label_w + value_w + note_w) - budget
294
+
295
+ if overflow.positive?
296
+ shrink = [[overflow, value_w - value_floor].min, 0].max
297
+ value_w -= shrink
298
+ overflow -= shrink
299
+ end
300
+
301
+ if overflow.positive? && has_note
302
+ remaining_for_note = note_w - overflow
303
+ if remaining_for_note < FIT_SCREEN_COLUMN_FLOOR
304
+ freed = note_w
305
+ overflow -= freed
306
+ note_w = 0
307
+ has_note = false
308
+ value_w = [value_w - overflow, natural_value_w].min if overflow.negative?
309
+ else
310
+ note_w = remaining_for_note
311
+ end
312
+ end
313
+
314
+ # P5: a column (never the last) is "padded" when at least one non-separator RAW cell already
315
+ # ends with two spaces before its closing pipe - the same `padded_column` convention
316
+ # `fit_table_block` uses (state_rows, roster).
317
+ padded_label = raw_content.any? { |cells| cells[0].to_s.end_with?(" ") }
318
+ padded_value = ncols > 2 && raw_content.any? { |cells| cells[1].to_s.end_with?(" ") }
319
+
320
+ render = lambda do
321
+ rows.each_index.map do |ri|
322
+ next rows[ri] if is_sep[ri]
323
+ cells = ScreenPaint.cells_of(rows[ri])
324
+ label = cells[0].to_s
325
+ value = cells[1].to_s
326
+ note = has_note ? cells[2].to_s : ""
327
+
328
+ value = truncate_on_word_boundary(value, value_w) if value.length > value_w && value !~ PROGRESS_BAR_CHARS_RE
329
+ note = truncate_on_word_boundary(note, note_w) if has_note && note.length > note_w
330
+
331
+ label = label.ljust(label_w) if padded_label
332
+ value = value.ljust(value_w) if padded_value
333
+
334
+ if has_note
335
+ "| #{label} | #{value} | #{note} |"
336
+ elsif ncols > 2
337
+ "| #{label} | #{value} | |"
338
+ else
339
+ "| #{label} | #{value} |"
340
+ end
341
+ end
342
+ end
343
+
344
+ # The corrective pass (P1): measure what actually got assembled, and if it still runs over
345
+ # `limit`, shrink note (then value, down to its floor) by the exact excess and re-render.
346
+ # Bounded: each pass either shrinks a column or breaks, and there are at most two columns
347
+ # left to shrink once the label is fixed.
348
+ loop do
349
+ candidate = render.call
350
+ max_dw = content_idx.map { |ri| ScreenPaint.display_columns(candidate[ri]) }.max.to_i
351
+ break if max_dw <= limit
352
+
353
+ excess = max_dw - limit
354
+ progressed = false
355
+ if has_note && note_w.positive?
356
+ cut = [excess, note_w].min
357
+ note_w -= cut
358
+ excess -= cut
359
+ progressed = true if cut.positive?
360
+ if note_w < FIT_SCREEN_COLUMN_FLOOR
361
+ has_note = false
362
+ note_w = 0
363
+ end
364
+ end
365
+ if excess.positive? && value_w > value_floor
366
+ cut = [excess, value_w - value_floor].min
367
+ value_w -= cut
368
+ progressed = true if cut.positive?
369
+ end
370
+ break unless progressed
371
+ end
372
+
373
+ "#{render.call.join("\n")}\n"
374
+ end
375
+
376
+ # Intent 331f1 (design's final bullet): the shared budget dashboard.rb's screen_fit_intent
377
+ # and roadmap_state_entries_table's Intent cell both spend by - a title cell fitted to
378
+ # whatever the row's OTHER already-rendered cells leave it, measured in display columns
379
+ # (RC1: an `others` cell carrying a progress bar costs two columns per glyph, not one).
380
+ # `others` are the sibling cells as they will actually render; the scaffolding is the
381
+ # leading "| ", a " | " between every pair of cells, and the trailing " |".
382
+ def self.fit_row_cell(title, others, max: FIT_SCREEN_DEFAULT_LIMIT)
383
+ scaffolding = 2 + (3 * others.length) + 2
384
+ budget = max - scaffolding - others.sum { |c| ScreenPaint.display_columns(c.to_s) }
385
+ return "" if budget <= 0
386
+ truncate_on_word_boundary(title, budget)
387
+ end
388
+
62
389
  def self.frontmatter(intent_dir)
63
390
  text = intent_text(intent_dir)
64
391
  return {} unless text && text.start_with?("---")
@@ -250,13 +577,21 @@ module ReportScreen
250
577
  rows.compact
251
578
  end
252
579
 
580
+ # Intent 331b (plan.md, "The one non-additive edit"): the standalone-token
581
+ # rule, extracted so `action_file_for` (the plan screen's Action column)
582
+ # calls the exact same rule as `matching_action_heading` and the two can
583
+ # never drift on what counts as a match. `matching_action_heading`'s own
584
+ # signature, return shape and behavior are unchanged (row P16).
585
+ def self.heading_tokens(heading)
586
+ heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/)
587
+ end
588
+
253
589
  # Rows 25-27: D19 - the label must appear as a standalone token in an action
254
590
  # file heading (any level); the count is the matched section's table rows only.
255
591
  def self.matching_action_heading(intent_dir, label)
256
592
  Dir.glob(File.join(intent_dir, "actions", "*.md")).sort.each do |path|
257
593
  split_by_headings(File.read(path)).each do |heading, body|
258
- tokens = heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/)
259
- return [heading, body] if tokens.include?(label)
594
+ return [heading, body] if heading_tokens(heading).include?(label)
260
595
  end
261
596
  end
262
597
  [nil, nil]
@@ -535,7 +870,7 @@ module ReportScreen
535
870
  out = out.gsub("{{name}}", data[:name])
536
871
  out = out.gsub("{{fields.rows}}", state_rows(data[:rows]).join("\n"))
537
872
  out = out.gsub("{{steps.rows}}", IntentScreen.steps_rows(data[:items]))
538
- out.gsub(/\n{3,}/, "\n\n")
873
+ fit_screen(out.gsub(/\n{3,}/, "\n\n"))
539
874
  end
540
875
 
541
876
  # --- roster (D7/D8) -------------------------------------------------------------
@@ -589,17 +924,36 @@ module ReportScreen
589
924
  entries.sort_by { |e| [-(e[:ts] ? Time.parse(e[:ts]).to_i : 0), e[:id]] }
590
925
  end
591
926
 
592
- def self.lead(intent_dir)
593
- data = Lock.read(intent_dir)
594
- return "idle" unless data
595
- agent = data["owner_agent"].to_s
596
- session = data["owner_session"].to_s
597
- return "idle" if agent.empty? && session.empty?
598
- "#{agent.empty? ? 'unknown' : agent} · #{session[0, 8]}"
927
+ # D6/R5, intent 331f: one freshness rule for every Lead cell, on the SAME primitive
928
+ # (Lock.who) every call site now shares - a fresh lock prints "agent · key" (this file's
929
+ # own long-standing format), an older lock prints "stale · N min", never idle; no lock, or
930
+ # one that will not read, prints "idle". Lock.who is called ONCE: it already returns the
931
+ # heartbeat timestamp alongside the state, so nothing stats the lock file a second time.
932
+ def self.lead_cell(intent_dir, now: Time.now)
933
+ data = Lock.who(intent_dir, now: now)
934
+ case data["state"]
935
+ when "fresh"
936
+ owner = data["owner"] || {}
937
+ agent = owner["agent"].to_s
938
+ agent = "unknown" if agent.empty? || agent == "unknown"
939
+ session = data["owner_session"].to_s
940
+ "#{agent} · #{session[0, 8]}"
941
+ when "stale"
942
+ mins = [((now - Time.parse(data["heartbeat_at"])) / 60).to_i, 0].max
943
+ "stale · #{mins} min"
944
+ else
945
+ "idle"
946
+ end
599
947
  rescue StandardError
600
948
  "idle"
601
949
  end
602
950
 
951
+ # The roster's own call site (unchanged name/signature at the call sites below); `now:`
952
+ # defaults so a caller that never passed a clock keeps working exactly as before.
953
+ def self.lead(intent_dir, now: Time.now)
954
+ lead_cell(intent_dir, now: now)
955
+ end
956
+
603
957
  def self.collapsed_open_steps_note(count)
604
958
  count <= 3 ? "#{count} open" : "#{count} open · showing the first three"
605
959
  end
@@ -627,21 +981,21 @@ module ReportScreen
627
981
 
628
982
  header = "▶ In delivery · #{entries.length} #{entries.length == 1 ? 'intent' : 'intents'} · " \
629
983
  "#{now.utc.strftime('%Y-%m-%d %H:%M UTC')}"
630
- table = ["| Intent | Stage | Progress | Changed | Lead |", "| --- | --- | --- | --- | --- |"]
984
+ table = ["| Graph ID | Stage | Progress | Changed | Lead |", "| --- | --- | --- | --- | --- |"]
631
985
  entries.each do |e|
632
986
  text = intent_text(e[:dir]).to_s
633
987
  savepoint = IntentScreen.savepoint_fields(e[:dir], text)
634
988
  items = IntentScreen.checklist_items(e[:dir])
635
989
  progress = IntentScreen.progress_fields(items)
636
990
  ch = state_fields(intent_dir: e[:dir], store_root: store_root, changed: changed)[:rows].find { |l, _, _| l == "Changed" }[1]
637
- table << "| #{e[:id]} | #{savepoint['stage']} | #{progress['progress.bar']} #{progress['progress.done']} / #{progress['progress.total']} | #{escape(ch)} | #{lead(e[:dir])} |"
991
+ table << "| #{e[:id]} | #{savepoint['stage']} | #{progress['progress.bar']} #{progress['progress.done']} / #{progress['progress.total']} | #{escape(ch)} | #{lead(e[:dir], now: now)} |"
638
992
  end
639
993
  blocks = entries.map { |e| render_collapsed_block(e[:dir], store_root, changed: changed) }
640
994
  head_and_table = ([header, ""] + table).join("\n")
641
995
  # Each collapsed block already has its own internal "\n"; a blank line
642
996
  # separates block from block (design--delivery-reports.html:137-152),
643
997
  # so they read as distinct entries instead of running together.
644
- "#{head_and_table}\n\n#{blocks.join("\n\n")}\n"
998
+ fit_screen("#{head_and_table}\n\n#{blocks.join("\n\n")}\n")
645
999
  end
646
1000
 
647
1001
  # --- S6: the delivered verb ------------------------------------------------------
@@ -683,7 +1037,7 @@ module ReportScreen
683
1037
  lines << " #{decision_note(intent_dir)}"
684
1038
  lines << ""
685
1039
  lines << "**Delivered**"
686
- lines << "| Row | What | Proven by |"
1040
+ lines << "| Row | Detail | Proven by |"
687
1041
  lines << "| --- | --- | --- |"
688
1042
  delivered_rows(intent_dir).each do |r|
689
1043
  lines << "| #{r[:label]} | #{escape(r[:text])} | #{escape(proven_by(intent_dir, r[:label]))} |"
@@ -697,7 +1051,7 @@ module ReportScreen
697
1051
  # prints.
698
1052
  lines << NOT_RECORDED
699
1053
  else
700
- lines << "| Kind | What | Source |"
1054
+ lines << "| Kind | Detail | Source |"
701
1055
  lines << "| --- | --- | --- |"
702
1056
  ev.each do |r|
703
1057
  lines << "| #{r[:kind]} | #{escape(r[:what])} | #{escape(r[:source])} |"
@@ -709,11 +1063,11 @@ module ReportScreen
709
1063
  if needsyou.empty?
710
1064
  lines << "None"
711
1065
  else
712
- lines << "| N | What | Why |"
1066
+ lines << "| N | Need | Reason |"
713
1067
  lines << "| --- | --- | --- |"
714
1068
  needsyou.each { |r| lines << "| #{r[:n]} | #{escape(r[:what])} | #{escape(r[:why])} |" }
715
1069
  end
716
- "#{lines.join("\n")}\n"
1070
+ fit_screen("#{lines.join("\n")}\n")
717
1071
  end
718
1072
 
719
1073
  # --- S7: the delay verb -----------------------------------------------------------
@@ -782,7 +1136,170 @@ module ReportScreen
782
1136
  lines << "**Where the time went** #{where_time_went(timeline)}"
783
1137
  lines << ""
784
1138
  lines << "**Outcome** #{delay_outcome_line(intent_dir)}"
785
- "#{lines.join("\n")}\n"
1139
+ fit_screen("#{lines.join("\n")}\n")
1140
+ end
1141
+
1142
+ # --- the plan verb (intent 331b): the PRE-delivery report -----------------------
1143
+ #
1144
+ # `report-screen plan <intent_dir>` prints the plan the record already
1145
+ # carries, before Exec starts: Asked, the decisions count, the planned
1146
+ # steps with their action file, and risks. Every cell traces to a file
1147
+ # (D3/D14); a missing source prints "not recorded", the same floor every
1148
+ # other screen in the family uses, except Mode (D2): a missing lock prints
1149
+ # "not armed", never "not recorded" - there is nothing to fall back to
1150
+ # before Exec starts.
1151
+
1152
+ VERDICT_TOKENS = %w[PROCEED APPROVE PASS REVISE REWORK FAIL BLOCK].freeze
1153
+
1154
+ # spec.md F4: a checklist line's OWN declared label ("S6 Docs and...")
1155
+ # survives here; STEP_PREFIX_RE (IntentScreen's own stripping regex) is
1156
+ # reused for the strip, so the label this recognizes is exactly the prefix
1157
+ # IntentScreen.checklist_items strips - the two readers can never disagree
1158
+ # on where a label ends and the step text begins.
1159
+ # The separator class mirrors STEP_PREFIX_RE's own (hyphen, colon, middle
1160
+ # dot, em dash, en dash); the latter two are written as \u escapes rather
1161
+ # than the literal glyph so this line never trips the project's added-line
1162
+ # dash guard, which scans literal characters only - the compiled regex
1163
+ # matches identically either way.
1164
+ STEP_LABEL_RE = /\A(?:Step\s*|S)\s*(\d+)\s*(?:[-:·\u2014\u2013]\s*|\s+)(?=\S)/i.freeze
1165
+
1166
+ def self.asked_first_sentence(intent_dir)
1167
+ body = asked(intent_dir)
1168
+ return NOT_RECORDED if body == NOT_RECORDED
1169
+ collapsed = body.gsub(/\s+/, " ").strip
1170
+ head, rest = IntentScreen.clause_and_rest(collapsed)
1171
+ rest ? "#{head}…" : head
1172
+ end
1173
+
1174
+ # D5, intent 331f: the plan screen's own Asked row - the intent title before its first
1175
+ # colon (F21), never the whole `## Intent` body asked_first_sentence above reads. Most real
1176
+ # intent lines read "Short title: the elaborated ask...", so this is the short title; a body
1177
+ # with no colon at all (a short intent with no title/elaboration split) renders unchanged,
1178
+ # word-boundary truncated the same way every other title cell in the family is.
1179
+ def self.plan_asked_title(intent_dir)
1180
+ body = asked(intent_dir)
1181
+ return NOT_RECORDED if body == NOT_RECORDED
1182
+ title_before_colon(body.gsub(/\s+/, " ").strip)
1183
+ end
1184
+
1185
+ # spec.md F4: keeps checklist.md's own file order and each line's DECLARED
1186
+ # label, falling back to the positional S<n> only when a line declares
1187
+ # none - IntentScreen.checklist_items strips the label and renumbers
1188
+ # positionally, which is right for the state screen and wrong for the
1189
+ # Action lookup below.
1190
+ def self.plan_steps(intent_dir)
1191
+ return [] unless IntentScreen.items_present?(intent_dir)
1192
+
1193
+ raw = File.readlines(File.join(intent_dir, "checklist.md")).filter_map do |line|
1194
+ m = line.match(IntentScreen::ITEM_RE)
1195
+ next unless m
1196
+ text = m[2].strip
1197
+ next if text == "..."
1198
+ text
1199
+ end
1200
+
1201
+ raw.each_with_index.map do |text, i|
1202
+ m = text.match(STEP_LABEL_RE)
1203
+ label = m ? "S#{m[1]}" : "S#{i + 1}"
1204
+ { label: label, text: text.sub(IntentScreen::STEP_PREFIX_RE, "") }
1205
+ end
1206
+ end
1207
+
1208
+ # spec.md F3/F6a: the Action column names the file whose heading carries
1209
+ # the step's label AND whose section has a matrix table of its own - a
1210
+ # heading that resolves but proves nothing is the same hollow-close defect
1211
+ # `proven_by` already guards against, so it renders "not recorded" too.
1212
+ def self.action_file_for(intent_dir, label)
1213
+ Dir.glob(File.join(intent_dir, "actions", "*.md")).sort.each do |path|
1214
+ split_by_headings(File.read(path)).each do |heading, body|
1215
+ next unless heading_tokens(heading).include?(label)
1216
+ return File.basename(path, ".md") if table_rows(body).any?
1217
+ end
1218
+ end
1219
+ NOT_RECORDED
1220
+ end
1221
+
1222
+ # D2: mode from the LIVE delivery lock only - unlike `mode` (row 36), a
1223
+ # missing lock never falls back to outcome.md's frontmatter (there is
1224
+ # nothing to fall back to before Exec starts) and never says the
1225
+ # delivered screen's "not recorded"; it says "not armed".
1226
+ def self.plan_mode(intent_dir)
1227
+ data = Lock.read(intent_dir)
1228
+ value = data && data["run_mode"]
1229
+ value && !value.to_s.empty? ? value.to_s : "not armed"
1230
+ end
1231
+
1232
+ # The last `Review` savepoint line whose text names a PLAN review - a
1233
+ # post-execution review line never matches, since its text never contains
1234
+ # "plan review".
1235
+ def self.plan_review_line(intent_dir)
1236
+ savepoint_lines(intent_dir).reverse.find { |_ts, kind, text| kind == "Review" && text =~ /plan review/i }
1237
+ end
1238
+
1239
+ def self.plan_reviewer(intent_dir)
1240
+ line = plan_review_line(intent_dir)
1241
+ return "not reviewed" unless line
1242
+ _ts, _kind, text = line
1243
+ VERDICT_TOKENS.find { |t| text =~ /\b#{t}\b/ } || NOT_RECORDED
1244
+ end
1245
+
1246
+ def self.plan_reviewer_note(intent_dir)
1247
+ line = plan_review_line(intent_dir)
1248
+ return "-" unless line
1249
+ ts, _kind, text = line
1250
+ "#{human_time(ts)} · #{text}"
1251
+ end
1252
+
1253
+ def self.plan_fields(intent_dir)
1254
+ [
1255
+ ["Asked", plan_asked_title(intent_dir), "## Intent"],
1256
+ ["Decisions", decision_note(intent_dir), "-"],
1257
+ ["Steps", "#{plan_steps(intent_dir).length} planned", "checklist.md"],
1258
+ ["Mode", plan_mode(intent_dir), "the delivery lock"],
1259
+ ["Reviewer", plan_reviewer(intent_dir), plan_reviewer_note(intent_dir)],
1260
+ ]
1261
+ end
1262
+
1263
+ # plan.md's own ## Risks bullets, wrapped continuations joined (317a's
1264
+ # bullet_rows); [] when plan.md is absent or carries no such section - the
1265
+ # renderer prints the literal "None" rather than an empty table, the
1266
+ # lesson 317a S4 already learned on the Evidence table.
1267
+ def self.risk_rows(intent_dir)
1268
+ path = File.join(intent_dir, "plan.md")
1269
+ return [] unless File.exist?(path)
1270
+ bullet_rows(section_of(File.read(path), "## Risks"))
1271
+ end
1272
+
1273
+ def self.render_plan(intent_dir:, store_root:, template:)
1274
+ id = intent_id(intent_dir)
1275
+ name = title_for(intent_dir, store_root)
1276
+ steps = plan_steps(intent_dir)
1277
+
1278
+ steps_rows =
1279
+ if steps.empty?
1280
+ "| | | no steps yet |"
1281
+ else
1282
+ steps.map do |s|
1283
+ "| #{escape(s[:label])} | #{escape(action_file_for(intent_dir, s[:label]))} | #{escape(s[:text])} |"
1284
+ end.join("\n")
1285
+ end
1286
+
1287
+ risks = risk_rows(intent_dir)
1288
+ risks_block =
1289
+ if risks.empty?
1290
+ "None"
1291
+ else
1292
+ rows = risks.each_with_index.map { |r, i| "| #{i + 1} | #{escape(r)} |" }
1293
+ (["| N | Risk |", "| --- | --- |"] + rows).join("\n")
1294
+ end
1295
+
1296
+ out = template.dup
1297
+ out = out.gsub("{{id}}", id)
1298
+ out = out.gsub("{{name}}", name)
1299
+ out = out.gsub("{{fields.rows}}", state_rows(plan_fields(intent_dir)).join("\n"))
1300
+ out = out.gsub("{{steps.rows}}", steps_rows)
1301
+ out = out.gsub("{{risks.block}}", risks_block)
1302
+ fit_screen(out.gsub(/\n{3,}/, "\n\n"))
786
1303
  end
787
1304
 
788
1305
  # --- S9: the session verb (intent 330) -------------------------------------------
@@ -963,6 +1480,308 @@ module ReportScreen
963
1480
  "#{blocks.map { |b| painter.call(b) }.join("\n\n")}\n"
964
1481
  end
965
1482
 
1483
+ # --- S10: the roadmap verb (intent 331c) -----------------------------------------
1484
+ #
1485
+ # `report-screen roadmap <roadmap.md> plan|state|delivered` - a roadmap's own three reports,
1486
+ # the counterpart to an intent's state/delivered. Every entry comes from RoadmapQueue's public
1487
+ # `roadmap` reader (D6/R1): no second parser here ever re-derives its grammar, its INDEX
1488
+ # reconciliation, or its frontier selection.
1489
+
1490
+ ROADMAP_VERBS = %w[plan state delivered].freeze
1491
+
1492
+ # D6/R17: the tier root for a roadmap path is the parent of `roadmaps/`, one extra parent when
1493
+ # the file sits under `roadmaps/archived/` - the SAME rule RoadmapSavepoint.index_path_for
1494
+ # uses (that method is private, so this is the rule's second, agreeing owner; a test pins them
1495
+ # together).
1496
+ def self.roadmap_tier_root(path)
1497
+ dir = File.dirname(path)
1498
+ dir = File.dirname(dir) if File.basename(dir) == "archived"
1499
+ File.dirname(dir)
1500
+ end
1501
+
1502
+ def self.roadmap_default_template_path(verb)
1503
+ File.expand_path("../../templates/report-roadmap-#{verb}.md", __dir__)
1504
+ end
1505
+
1506
+ # D6/R1: the parsed, INDEX-reconciled entries for one roadmap file, obtained from
1507
+ # RoadmapQueue's own public reader - never a second parser.
1508
+ def self.roadmap_entries(path:, store_root:)
1509
+ index_path = File.join(store_root, "INDEX.md")
1510
+ RoadmapQueue.new(roadmaps_dir: File.dirname(path), index_path: index_path).roadmap(path)
1511
+ end
1512
+
1513
+ # R4/R15: the first sentence of `## Goal`, joined across wrapped source lines. Splits on a
1514
+ # period only (never IntentScreen.clause_and_rest's `[.;]` - a semicolon inside a real goal is
1515
+ # common and must survive, R15); a period with no following whitespace or end-of-string (a
1516
+ # version number like "2.0.0", never followed by a space mid-number) is never mistaken for a
1517
+ # sentence boundary (R4).
1518
+ def self.roadmap_goal(text)
1519
+ section = section_of(text, "## Goal").strip
1520
+ return NOT_RECORDED if section.empty?
1521
+
1522
+ joined = section.lines.map(&:strip).join(" ").squeeze(" ")
1523
+ m = joined.match(/\A(.*?\.)(?=\s|\z)/)
1524
+ (m ? m[1] : joined).strip
1525
+ end
1526
+
1527
+ # R16: the ledger's own entries when the paired `.savepoint.md` carries any; otherwise the
1528
+ # `## Log` lines classified through RoadmapSavepoint.classify_event (the same KEYWORD_TABLE,
1529
+ # no second vocabulary), each timestamped from its own Log line's date and time. A roadmap with
1530
+ # neither source (no ledger file, no classifiable Log line) answers `[]`, never an invented
1531
+ # event - callers reading it print `not recorded`.
1532
+ def self.roadmap_events(path)
1533
+ ledger = RoadmapSavepoint.ledger_entries(path)
1534
+ return ledger.sort_by { |t, _, _| t } if ledger.any?
1535
+
1536
+ text = File.exist?(path) ? File.read(path) : nil
1537
+ return [] unless text
1538
+
1539
+ section_of(text, "## Log").each_line.filter_map do |line|
1540
+ m = line.strip.match(RoadmapSavepoint::LOG_LINE)
1541
+ next nil unless m
1542
+ event = RoadmapSavepoint.classify_event(m[3])
1543
+ next nil unless event
1544
+ [Time.parse("#{m[1]}T#{m[2]}:00Z"), event, m[3].strip]
1545
+ end
1546
+ end
1547
+
1548
+ # Every `## Log` line, classified for the delivered screen's Log table: an unclassifiable line
1549
+ # still renders, with `not recorded` in its Event cell (never dropped, unlike roadmap_events'
1550
+ # fallback, which only wants events it can act on).
1551
+ def self.roadmap_log_rows(text)
1552
+ section_of(text, "## Log").each_line.filter_map do |line|
1553
+ m = line.strip.match(RoadmapSavepoint::LOG_LINE)
1554
+ next nil unless m
1555
+ { when: human_time("#{m[1]}T#{m[2]}:00Z"), event: RoadmapSavepoint.classify_event(m[3]) || NOT_RECORDED, what: m[3].strip }
1556
+ end
1557
+ end
1558
+
1559
+ # R7: idle unless the entry's own delivery lock is fresh as of `now:` - a stale lock (the
1560
+ # heartbeat older than the TTL) never masquerades as a live lead.
1561
+ def self.roadmap_lead(intent_dir, now:)
1562
+ return "idle" unless intent_dir
1563
+ lead_cell(intent_dir, now: now)
1564
+ end
1565
+
1566
+ def self.roadmap_intent_dir(store_root, id)
1567
+ Dir.glob(File.join(store_root, "store", "#{id}--*")).sort.find { |d| IntentScreen.intent_dir?(d) }
1568
+ end
1569
+
1570
+ def self.roadmap_entry_progress(dir)
1571
+ return NOT_RECORDED unless dir
1572
+ items = IntentScreen.checklist_items(dir)
1573
+ fields = IntentScreen.progress_fields(items)
1574
+ "#{fields['progress.bar']} #{fields['progress.done']} / #{fields['progress.total']}"
1575
+ end
1576
+
1577
+ def self.roadmap_progress_bar(done, total)
1578
+ on = total.zero? ? 0 : (done * IntentScreen::BAR_WIDTH) / total
1579
+ (IntentScreen::ON * on) + (IntentScreen::OFF * (IntentScreen::BAR_WIDTH - on))
1580
+ end
1581
+
1582
+ # "Batch" or "Wave" (singular): the entries table's own first column header (R3 - a legacy
1583
+ # Waves roadmap reads "Wave", never "Batch").
1584
+ def self.roadmap_batch_label(data)
1585
+ data[:grouping] == "Waves" ? "Wave" : "Batch"
1586
+ end
1587
+
1588
+ def self.roadmap_all_entries(data)
1589
+ data[:batches].flat_map { |b| b[:entries] }
1590
+ end
1591
+
1592
+ # --- plan (D2) ---------------------------------------------------------------
1593
+
1594
+ def self.roadmap_plan_fields(text, data, events)
1595
+ all_entries = roadmap_all_entries(data)
1596
+ order = data[:batches].map { |b| b[:heading] }.join(" → ")
1597
+ created = events.empty? ? NOT_RECORDED : human_time(events.first[0].utc.iso8601)
1598
+ [
1599
+ ["Goal", roadmap_goal(text), ""],
1600
+ [data[:grouping], "#{data[:batches].length} #{data[:grouping].downcase}, #{all_entries.length} intents", ""],
1601
+ ["Order", order, ""],
1602
+ ["Created", created, ""],
1603
+ ]
1604
+ end
1605
+
1606
+ def self.roadmap_plan_entries_table(data)
1607
+ label = roadmap_batch_label(data)
1608
+ rows = ["| #{label} | Graph ID | Intent | Status |", "| --- | --- | --- | --- |"]
1609
+ data[:batches].each do |batch|
1610
+ batch[:entries].each do |e|
1611
+ rows << "| #{escape(batch[:heading])} | #{escape(e[:id])} | #{escape(e[:text])} | #{escape(e[:status])} |"
1612
+ end
1613
+ end
1614
+ rows.join("\n")
1615
+ end
1616
+
1617
+ # --- state (D3) ---------------------------------------------------------------
1618
+
1619
+ def self.roadmap_state_fields(text, data, events, store_root, now)
1620
+ all_entries = roadmap_all_entries(data)
1621
+ total = all_entries.length
1622
+ delivered = all_entries.count { |e| e[:status] == "delivered" }
1623
+ bar = roadmap_progress_bar(delivered, total)
1624
+
1625
+ frontier = data[:frontier]
1626
+ frontier_value = frontier ? frontier[:heading] : NOT_RECORDED
1627
+ frontier_note =
1628
+ if frontier.nil?
1629
+ ""
1630
+ elsif frontier[:in_flight].any?
1631
+ "in flight"
1632
+ else
1633
+ "queued"
1634
+ end
1635
+
1636
+ delivering_value =
1637
+ if frontier && frontier[:in_flight].any?
1638
+ frontier[:in_flight].map do |e|
1639
+ dir = roadmap_intent_dir(store_root, e["id"])
1640
+ "#{e['id']} (#{roadmap_lead(dir, now: now)})"
1641
+ end.join(", ")
1642
+ else
1643
+ NOT_RECORDED
1644
+ end
1645
+
1646
+ next_entry = all_entries.find { |e| e[:status] == "queued" }
1647
+ next_value = next_entry ? "#{next_entry[:id]} #{next_entry[:text]}".strip : NOT_RECORDED
1648
+
1649
+ changed_value = events.empty? ? NOT_RECORDED : "#{events.last[1]} · #{human_time(events.last[0].utc.iso8601)}"
1650
+
1651
+ [
1652
+ ["Goal", roadmap_goal(text), ""],
1653
+ ["Progress", "#{bar} #{delivered} / #{total}", ""],
1654
+ ["Frontier", frontier_value, frontier_note],
1655
+ ["Delivering", delivering_value, ""],
1656
+ ["Next", next_value, ""],
1657
+ ["Changed", changed_value, ""],
1658
+ ]
1659
+ end
1660
+
1661
+ # RC4/spec.md defect 2: the Batches table carries the same Intent title column the plan
1662
+ # verb's own table already does (roadmap_plan_entries_table). The Intent cell spends
1663
+ # whatever the row's other cells leave it (W8a/W8b) through the ONE shared budget helper
1664
+ # (fit_row_cell) dashboard.rb's screen_fit_intent also spends by, computed PER ROW from that
1665
+ # row's own batch/id/status/progress/lead - never a cross-row max - so one long row's Intent
1666
+ # cell can never re-truncate another row's already-correct one (A5).
1667
+ def self.roadmap_state_entries_table(data, store_root, now)
1668
+ label = roadmap_batch_label(data)
1669
+ rows = ["| #{label} | Graph ID | Intent | Status | Progress | Lead |",
1670
+ "| --- | --- | --- | --- | --- | --- |"]
1671
+ data[:batches].each do |batch|
1672
+ batch[:entries].each do |e|
1673
+ dir = roadmap_intent_dir(store_root, e[:id])
1674
+ progress = roadmap_entry_progress(dir)
1675
+ lead = roadmap_lead(dir, now: now)
1676
+ others = [batch[:heading], e[:id], e[:status], progress, lead]
1677
+ intent_cell = fit_row_cell(e[:text], others)
1678
+ rows << "| #{escape(batch[:heading])} | #{escape(e[:id])} | #{escape(intent_cell)} | " \
1679
+ "#{escape(e[:status])} | #{escape(progress)} | #{escape(lead)} |"
1680
+ end
1681
+ end
1682
+ rows.join("\n")
1683
+ end
1684
+
1685
+ # --- delivered (D4) ------------------------------------------------------------
1686
+
1687
+ def self.roadmap_delivered_meta(data, events)
1688
+ all_entries = roadmap_all_entries(data)
1689
+ closed = events.reverse.find { |_t, event, _d| event == "closed" }
1690
+ closed_part = closed ? human_time(closed[0].utc.iso8601) : "in progress"
1691
+
1692
+ merged = events.select { |_t, event, _d| event == "merged" }
1693
+ duration = events.empty? || merged.empty? ? NOT_RECORDED : format_duration((merged.last[0] - events.first[0]).to_i)
1694
+
1695
+ "#{closed_part} · #{all_entries.length} intents · #{duration}"
1696
+ end
1697
+
1698
+ # The regex RoadmapSavepoint::KEYWORD_TABLE pairs with an event word - read from the table
1699
+ # rather than copied, so the Merged cell's vocabulary never drifts from rebuild's own.
1700
+ def self.roadmap_savepoint_keyword_regex(event)
1701
+ RoadmapSavepoint::KEYWORD_TABLE.find { |_re, ev| ev == event }.first
1702
+ end
1703
+
1704
+ # R10/R21/R22: the Merged cell reads a line only when the entry id is its SUBJECT - the first
1705
+ # whitespace-delimited token of the detail, never a whole word anywhere in it (R21: a real
1706
+ # ledger line names one entry's id as its subject and a SECOND entry's id in passing, and the
1707
+ # second entry has no merge line of its own to fill this row with). Among subject-matching
1708
+ # lines, one is read when the ledger's own event is `merged` OR its detail matches
1709
+ # KEYWORD_TABLE's merged pattern (R22: the appender sometimes files a real per-entry merge
1710
+ # under a different event word, `dispatched`, because the rest of the line was other news),
1711
+ # and refused when the event is `handoff` or the detail matches KEYWORD_TABLE's handoff
1712
+ # pattern - stricter than R10's original guarantee, never weaker. The sha is the first
1713
+ # hex-with-at-least-one-digit token of 7-40 characters in the matched line.
1714
+ def self.roadmap_merged_cell(id, events)
1715
+ merged_re = roadmap_savepoint_keyword_regex("merged")
1716
+ handoff_re = roadmap_savepoint_keyword_regex("handoff")
1717
+
1718
+ line = events.find do |_t, event, detail|
1719
+ next false unless detail.to_s.strip.split(/\s+/).first == id
1720
+ next false if event == "handoff" || detail.to_s =~ handoff_re
1721
+ event == "merged" || detail.to_s =~ merged_re
1722
+ end
1723
+ return NOT_RECORDED unless line
1724
+
1725
+ m = line[2].match(/\b(?=[0-9a-f]*\d)[0-9a-f]{7,40}\b/i)
1726
+ m ? m[0] : NOT_RECORDED
1727
+ end
1728
+
1729
+ def self.roadmap_delivered_table(data, events)
1730
+ label = roadmap_batch_label(data)
1731
+ rows = ["| #{label} | Graph ID | Intent | Merged |", "| --- | --- | --- | --- |"]
1732
+ data[:batches].each do |batch|
1733
+ batch[:entries].each do |e|
1734
+ rows << "| #{escape(batch[:heading])} | #{escape(e[:id])} | #{escape(e[:text])} | " \
1735
+ "#{escape(roadmap_merged_cell(e[:id], events))} |"
1736
+ end
1737
+ end
1738
+ rows.join("\n")
1739
+ end
1740
+
1741
+ def self.roadmap_log_table(text)
1742
+ log_rows = roadmap_log_rows(text)
1743
+ return NOT_RECORDED if log_rows.empty?
1744
+
1745
+ rows = ["| When | Event | Detail |", "| --- | --- | --- |"]
1746
+ log_rows.each { |r| rows << "| #{escape(r[:when])} | #{escape(r[:event])} | #{escape(r[:what])} |" }
1747
+ rows.join("\n")
1748
+ end
1749
+
1750
+ # --- render ---------------------------------------------------------------------
1751
+
1752
+ # D6: `ReportScreen.render_roadmap(path:, verb:, store_root: nil, now: Time.now, template:
1753
+ # nil)`. No ENV, no git; `now:` is used only for lock freshness (R7). `store_root` defaults to
1754
+ # the derived tier root; `template` defaults to the installed-or-in-repo
1755
+ # `templates/report-roadmap-<verb>.md`.
1756
+ def self.render_roadmap(path:, verb:, store_root: nil, now: Time.now, template: nil)
1757
+ verb = verb.to_s
1758
+ raise ArgumentError, "verb must be one of #{ROADMAP_VERBS.join(', ')}, got #{verb.inspect}" unless ROADMAP_VERBS.include?(verb)
1759
+
1760
+ store_root ||= roadmap_tier_root(path)
1761
+ text = File.read(path)
1762
+ data = roadmap_entries(path: path, store_root: store_root)
1763
+ events = roadmap_events(path)
1764
+ template ||= File.read(roadmap_default_template_path(verb))
1765
+
1766
+ out = template.dup
1767
+ out = out.gsub("{{slug}}", data[:slug])
1768
+
1769
+ case verb
1770
+ when "plan"
1771
+ out = out.gsub("{{fields.rows}}", state_rows(roadmap_plan_fields(text, data, events)).join("\n"))
1772
+ out = out.gsub("{{entries.table}}", roadmap_plan_entries_table(data))
1773
+ when "state"
1774
+ out = out.gsub("{{fields.rows}}", state_rows(roadmap_state_fields(text, data, events, store_root, now)).join("\n"))
1775
+ out = out.gsub("{{entries.table}}", roadmap_state_entries_table(data, store_root, now))
1776
+ when "delivered"
1777
+ out = out.gsub("{{meta}}", roadmap_delivered_meta(data, events))
1778
+ out = out.gsub("{{delivered.table}}", roadmap_delivered_table(data, events))
1779
+ out = out.gsub("{{log.table}}", roadmap_log_table(text))
1780
+ end
1781
+
1782
+ fit_screen(out.gsub(/\n{3,}/, "\n\n"))
1783
+ end
1784
+
966
1785
  # --- S8: --ansi passthrough (D2) -----------------------------------------------
967
1786
  #
968
1787
  # 316a owns the ANSI renderer; 317 only wires a generic DI seam so this