@zalom/plastic 2.0.0-alpha.12 → 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 (42) 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 +1139 -35
  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 +244 -12
  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/session_ledger.rb +4 -0
  19. package/scripts/lib/verify_intent.rb +33 -0
  20. package/scripts/report-screen +127 -10
  21. package/scripts/savepoint-note +11 -9
  22. package/skills/auto/SKILL.md +13 -12
  23. package/skills/auto/references/human-report-contract.md +83 -8
  24. package/skills/dashboard/SKILL.md +13 -2
  25. package/skills/dashboard/templates/dashboard-global.md +1 -1
  26. package/skills/dashboard/templates/dashboard-project.md +2 -2
  27. package/skills/doctor/SKILL.md +10 -4
  28. package/skills/intent-continuing/SKILL.md +28 -26
  29. package/skills/intent-continuing/references/board-fill.md +9 -0
  30. package/skills/intent-ending/SKILL.md +6 -4
  31. package/skills/intent-executing/SKILL.md +2 -0
  32. package/skills/intent-speccing/SKILL.md +7 -4
  33. package/skills/roadmap/SKILL.md +9 -0
  34. package/skills/roadmap/references/file-format.md +10 -0
  35. package/templates/dashboard-screen.md +22 -0
  36. package/templates/display-fixture.md +21 -0
  37. package/templates/intent-screen.md +1 -1
  38. package/templates/report-plan.md +15 -0
  39. package/templates/report-roadmap-delivered.md +10 -0
  40. package/templates/report-roadmap-plan.md +9 -0
  41. package/templates/report-roadmap-state.md +9 -0
  42. package/templates/report-state.md +1 -1
@@ -10,8 +10,13 @@
10
10
  # renderer path as `renderer_path:` (D2).
11
11
  require "time"
12
12
  require "json"
13
+ require "date"
13
14
  require_relative "intent_screen"
14
15
  require_relative "lock"
16
+ require_relative "session_ledger"
17
+ require_relative "roadmap_queue"
18
+ require_relative "roadmap_savepoint"
19
+ require_relative "screen_paint"
15
20
 
16
21
  module ReportScreen
17
22
  NOT_RECORDED = "not recorded"
@@ -57,6 +62,313 @@ module ReportScreen
57
62
  text.to_s.gsub("|", "\\|")
58
63
  end
59
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
+ fitted_rows = raw_rows.each_with_index.map do |cells, ri|
201
+ if is_sep[ri]
202
+ "| #{widths.map { |w| "-" * [w, 3].max }.join(" | ")} |"
203
+ else
204
+ rendered = cells.each_with_index.map do |c, ci|
205
+ next c.to_s.strip if ci >= ncols
206
+ value = c.to_s.strip
207
+ value = truncate_on_word_boundary(value, widths[ci]) if value.length > widths[ci]
208
+ padded_column[ci] && ci != ncols - 1 ? value.ljust(widths[ci]) : value
209
+ end
210
+ "| #{rendered.join(' | ')} |"
211
+ end
212
+ end
213
+
214
+ # F28: the unconditional backstop. Every shrinkable column may already sit at its floor
215
+ # and the assembled row can still be over the limit; truncate the whole row on a word
216
+ # boundary rather than let it survive past 115 - a data table's separator row included
217
+ # (test_fit_screen_backstops_an_unshrinkable_row), unlike the field-table fitter's own
218
+ # separator, which always passes through untouched (W2).
219
+ fitted_rows.map! { |r| ScreenPaint.display_columns(r) > limit ? truncate_on_word_boundary(r, limit) : r }
220
+
221
+ "#{fitted_rows.join("\n")}\n"
222
+ end
223
+
224
+ # Intent 331f1 (S2, design): the field table's own fitter - a "| | | |" scaffold or
225
+ # "| --- | --- | --- |" separator row passes through byte for byte; the label column
226
+ # (first cell) takes its natural width and never shrinks or truncates; the VALUE column
227
+ # shrinks first, down to a floor of max(24, the widest bar cell in that column) so a
228
+ # progress bar is never cut; only then does the NOTE column shrink, and when what is left
229
+ # for it falls under ScreenPaint::FIT_COLUMN_FLOOR (8) columns the note is dropped whole
230
+ # (never squeezed to "in…") and the value reclaims the freed room, back up to its own
231
+ # natural width. A value that still cannot fit ends with an ellipsis; the value floor is
232
+ # never crossed even then, so the row may still exceed `limit` in that extreme case -
233
+ # there is no row-level backstop here (that backstop is the data-table branch's own, and
234
+ # it must never touch a field table's label cell).
235
+ #
236
+ # Intent 331f1 (post-exec review, P1): `label_w`/`value_w`/`note_w` and `budget` are character
237
+ # counts spent against the 115 DISPLAY-column bound - a bar row's glyphs (2 columns each) or
238
+ # an embedded ellipsis cost more display columns than characters, so a row can pass this
239
+ # arithmetic while still landing well over the real bound. `ScreenPaint.row_display_overage`
240
+ # reserves the worst row's own overage up front (P1-P3's shared fix); a fresh ellipsis this
241
+ # function's OWN truncation adds where none existed before can still leave a small residual,
242
+ # which the corrective loop below closes by re-measuring the actual assembled row and shrinking
243
+ # note (then value, never below its floor) by the exact excess.
244
+ #
245
+ # Intent 331f1 (P5): the label (and, when flagged, the value) column is re-padded exactly the
246
+ # way `fit_table_block`'s own `padded_column` rule would - ljust in CHARACTERS, never display
247
+ # columns, so a terminal drawing a bar glyph one column wide stays aligned - restoring the
248
+ # alignment a fitted field table lost.
249
+ def self.fit_field_table_block(block, limit)
250
+ return block.join if block.all? { |l| ScreenPaint.display_columns(l.chomp) <= limit }
251
+
252
+ rows = block.map(&:chomp)
253
+ is_sep = rows.map { |r| r.match?(ScreenPaint::SEPARATOR_RE) }
254
+ content_idx = rows.each_index.reject { |ri| is_sep[ri] }
255
+ return block.join if content_idx.empty?
256
+
257
+ raw_content = content_idx.map { |ri| raw_cells_of(rows[ri]) }
258
+ parsed = content_idx.map { |ri| ScreenPaint.cells_of(rows[ri]) }
259
+ ncols = parsed.map(&:length).max.to_i
260
+ return block.join if ncols.zero?
261
+
262
+ label_w = parsed.map { |c| c[0].to_s.length }.max.to_i
263
+ value_texts = parsed.map { |c| c[1].to_s }
264
+ natural_value_w = value_texts.map(&:length).max.to_i
265
+ bar_value_w = value_texts.select { |v| v =~ PROGRESS_BAR_CHARS_RE }.map(&:length).max.to_i
266
+ value_floor = [24, bar_value_w].max
267
+ value_w = natural_value_w
268
+
269
+ has_note = ncols > 2 && parsed.any? { |c| !c[2].to_s.empty? }
270
+ note_texts = has_note ? parsed.map { |c| c[2].to_s } : []
271
+ note_w = note_texts.map(&:length).max.to_i
272
+
273
+ gaps = ncols - 1
274
+ overage = ScreenPaint.row_display_overage(parsed.map { |c| (0...ncols).map { |ci| c[ci].to_s } })
275
+ budget = limit - (4 + 3 * gaps) - overage
276
+ overflow = (label_w + value_w + note_w) - budget
277
+
278
+ if overflow.positive?
279
+ shrink = [[overflow, value_w - value_floor].min, 0].max
280
+ value_w -= shrink
281
+ overflow -= shrink
282
+ end
283
+
284
+ if overflow.positive? && has_note
285
+ remaining_for_note = note_w - overflow
286
+ if remaining_for_note < FIT_SCREEN_COLUMN_FLOOR
287
+ freed = note_w
288
+ overflow -= freed
289
+ note_w = 0
290
+ has_note = false
291
+ value_w = [value_w - overflow, natural_value_w].min if overflow.negative?
292
+ else
293
+ note_w = remaining_for_note
294
+ end
295
+ end
296
+
297
+ # P5: a column (never the last) is "padded" when at least one non-separator RAW cell already
298
+ # ends with two spaces before its closing pipe - the same `padded_column` convention
299
+ # `fit_table_block` uses (state_rows, roster).
300
+ padded_label = raw_content.any? { |cells| cells[0].to_s.end_with?(" ") }
301
+ padded_value = ncols > 2 && raw_content.any? { |cells| cells[1].to_s.end_with?(" ") }
302
+
303
+ render = lambda do
304
+ rows.each_index.map do |ri|
305
+ next rows[ri] if is_sep[ri]
306
+ cells = ScreenPaint.cells_of(rows[ri])
307
+ label = cells[0].to_s
308
+ value = cells[1].to_s
309
+ note = has_note ? cells[2].to_s : ""
310
+
311
+ value = truncate_on_word_boundary(value, value_w) if value.length > value_w && value !~ PROGRESS_BAR_CHARS_RE
312
+ note = truncate_on_word_boundary(note, note_w) if has_note && note.length > note_w
313
+
314
+ label = label.ljust(label_w) if padded_label
315
+ value = value.ljust(value_w) if padded_value
316
+
317
+ if has_note
318
+ "| #{label} | #{value} | #{note} |"
319
+ elsif ncols > 2
320
+ "| #{label} | #{value} | |"
321
+ else
322
+ "| #{label} | #{value} |"
323
+ end
324
+ end
325
+ end
326
+
327
+ # The corrective pass (P1): measure what actually got assembled, and if it still runs over
328
+ # `limit`, shrink note (then value, down to its floor) by the exact excess and re-render.
329
+ # Bounded: each pass either shrinks a column or breaks, and there are at most two columns
330
+ # left to shrink once the label is fixed.
331
+ loop do
332
+ candidate = render.call
333
+ max_dw = content_idx.map { |ri| ScreenPaint.display_columns(candidate[ri]) }.max.to_i
334
+ break if max_dw <= limit
335
+
336
+ excess = max_dw - limit
337
+ progressed = false
338
+ if has_note && note_w.positive?
339
+ cut = [excess, note_w].min
340
+ note_w -= cut
341
+ excess -= cut
342
+ progressed = true if cut.positive?
343
+ if note_w < FIT_SCREEN_COLUMN_FLOOR
344
+ has_note = false
345
+ note_w = 0
346
+ end
347
+ end
348
+ if excess.positive? && value_w > value_floor
349
+ cut = [excess, value_w - value_floor].min
350
+ value_w -= cut
351
+ progressed = true if cut.positive?
352
+ end
353
+ break unless progressed
354
+ end
355
+
356
+ "#{render.call.join("\n")}\n"
357
+ end
358
+
359
+ # Intent 331f1 (design's final bullet): the shared budget dashboard.rb's screen_fit_intent
360
+ # and roadmap_state_entries_table's Intent cell both spend by - a title cell fitted to
361
+ # whatever the row's OTHER already-rendered cells leave it, measured in display columns
362
+ # (RC1: an `others` cell carrying a progress bar costs two columns per glyph, not one).
363
+ # `others` are the sibling cells as they will actually render; the scaffolding is the
364
+ # leading "| ", a " | " between every pair of cells, and the trailing " |".
365
+ def self.fit_row_cell(title, others, max: FIT_SCREEN_DEFAULT_LIMIT)
366
+ scaffolding = 2 + (3 * others.length) + 2
367
+ budget = max - scaffolding - others.sum { |c| ScreenPaint.display_columns(c.to_s) }
368
+ return "" if budget <= 0
369
+ truncate_on_word_boundary(title, budget)
370
+ end
371
+
60
372
  def self.frontmatter(intent_dir)
61
373
  text = intent_text(intent_dir)
62
374
  return {} unless text && text.start_with?("---")
@@ -73,10 +385,52 @@ module ReportScreen
73
385
  Array(frontmatter(intent_dir)["tags"]).map(&:to_s).include?("research")
74
386
  end
75
387
 
388
+ # Intent 330 (D12): the shared fence walker feeding split_by_headings AND
389
+ # table_rows. A line matching \A\s{0,3}(```+|~~~+) while closed opens a
390
+ # fence and remembers the marker character and its length; while open, a
391
+ # line whose marker is the SAME character and at least as long, with only
392
+ # whitespace after it, closes the fence. Inside a fence every line is body
393
+ # - a leading "#" or a leading "|" included. A four-space-indented block is
394
+ # deliberately never a fence (the cap is 0-3 leading whitespace chars),
395
+ # which is the CommonMark indented-code case, out of scope on purpose
396
+ # (D12's stated limit). Yields [line, fenced] for every line, in order.
397
+ FENCE_LINE_RE = /\A\s{0,3}(`{3,}|~{3,})/.freeze
398
+
399
+ def self.each_fence_line(text)
400
+ return enum_for(:each_fence_line, text) unless block_given?
401
+
402
+ marker = nil # [character, length] of the currently open fence, or nil
403
+ text.to_s.each_line do |line|
404
+ if marker
405
+ yield line, true
406
+ m = line.match(FENCE_LINE_RE)
407
+ next unless m && m[1][0] == marker[0] && m[1].length >= marker[1]
408
+ next unless line.sub(FENCE_LINE_RE, "").strip.empty?
409
+
410
+ marker = nil
411
+ else
412
+ m = line.match(FENCE_LINE_RE)
413
+ if m
414
+ marker = [m[1][0], m[1].length]
415
+ yield line, true
416
+ else
417
+ yield line, false
418
+ end
419
+ end
420
+ end
421
+ end
422
+
76
423
  # Markdown pipe-table data rows (header + separator skipped), each an array
77
- # of trimmed cell strings. Tolerates leading prose before the table.
424
+ # of trimmed cell strings. Tolerates leading prose before the table. Fence-
425
+ # aware (D12/O1.7): a pipe row inside a fenced example is never counted.
78
426
  def self.table_rows(text)
79
- lines = text.to_s.lines.map(&:strip).select { |l| l.start_with?("|") }
427
+ lines = []
428
+ each_fence_line(text) do |line, fenced|
429
+ next if fenced
430
+
431
+ stripped = line.strip
432
+ lines << stripped if stripped.start_with?("|")
433
+ end
80
434
  sep_idx = lines.index { |l| l.match?(/\A\|[\s:|-]+\|?\z/) }
81
435
  return [] unless sep_idx
82
436
  lines[(sep_idx + 1)..].map { |l| l.split("|", -1).map(&:strip)[1..-2].to_a }
@@ -84,13 +438,14 @@ module ReportScreen
84
438
 
85
439
  # Every [heading_line, body] pair in a Markdown file, split on ANY heading
86
440
  # line (any level). Used by proven_by (D19) so a section's own matrix rows
87
- # are never confused with a sibling section's.
441
+ # are never confused with a sibling section's. Fence-aware (D12): a "#"
442
+ # line inside a fenced example never starts a new section.
88
443
  def self.split_by_headings(text)
89
444
  sections = []
90
445
  heading = nil
91
446
  body = +""
92
- text.to_s.each_line do |line|
93
- if line.start_with?("#")
447
+ each_fence_line(text) do |line, fenced|
448
+ if !fenced && line.start_with?("#")
94
449
  sections << [heading, body] if heading
95
450
  heading = line.strip
96
451
  body = +""
@@ -205,13 +560,21 @@ module ReportScreen
205
560
  rows.compact
206
561
  end
207
562
 
563
+ # Intent 331b (plan.md, "The one non-additive edit"): the standalone-token
564
+ # rule, extracted so `action_file_for` (the plan screen's Action column)
565
+ # calls the exact same rule as `matching_action_heading` and the two can
566
+ # never drift on what counts as a match. `matching_action_heading`'s own
567
+ # signature, return shape and behavior are unchanged (row P16).
568
+ def self.heading_tokens(heading)
569
+ heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/)
570
+ end
571
+
208
572
  # Rows 25-27: D19 - the label must appear as a standalone token in an action
209
573
  # file heading (any level); the count is the matched section's table rows only.
210
574
  def self.matching_action_heading(intent_dir, label)
211
575
  Dir.glob(File.join(intent_dir, "actions", "*.md")).sort.each do |path|
212
576
  split_by_headings(File.read(path)).each do |heading, body|
213
- tokens = heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/)
214
- return [heading, body] if tokens.include?(label)
577
+ return [heading, body] if heading_tokens(heading).include?(label)
215
578
  end
216
579
  end
217
580
  [nil, nil]
@@ -327,13 +690,64 @@ module ReportScreen
327
690
  line && line.match(/\b([0-9a-f]{7,40})\b/)[1]
328
691
  end
329
692
 
330
- def self.ship_row(_text, intent_dir, tag_reader)
693
+ # Intent 330 (D9): reads `flow: base:` from a project's project.yml when
694
+ # `intent_dir` sits in the installed project layout
695
+ # (<home>/projects/<slug>/store/<id--slug>); nil otherwise (a global-store
696
+ # intent, a project with no `flow:` key, or malformed YAML). Pure: no git,
697
+ # no shell-out, just the one file this intent's own layout already reads.
698
+ PROJECT_LAYOUT_RE = %r{\A(.*)/projects/([^/]+)/store/[^/]+\z}.freeze
699
+
700
+ def self.flow_base(intent_dir)
701
+ m = intent_dir.to_s.match(PROJECT_LAYOUT_RE)
702
+ return nil unless m
703
+
704
+ home, slug = m[1], m[2]
705
+ path = File.join(home, "projects", slug, "project.yml")
706
+ return nil unless File.exist?(path)
707
+
708
+ require "yaml"
709
+ data = YAML.safe_load(File.read(path))
710
+ return nil unless data.is_a?(Hash)
711
+
712
+ flow = data["flow"]
713
+ return nil unless flow.is_a?(Hash)
714
+
715
+ base = flow["base"]
716
+ base.is_a?(String) && !base.empty? ? base : nil
717
+ rescue StandardError
718
+ nil
719
+ end
720
+
721
+ # Intent 330 (D9/D10/D23): the ship row's WHAT cell is the merge sha, then
722
+ # " → <branch>" only when `branch_reader` answers one (never the "alpha"
723
+ # literal), then " · v<version>" or the existing not-recorded fallback. The
724
+ # Source cell names WHERE the branch came from (D23): project.yml when
725
+ # flow_base itself supplied that exact branch, else git refs, so the row
726
+ # never keeps the stale "git tags" literal for a branch git never answered.
727
+ def self.ship_row(_text, intent_dir, tag_reader, branch_reader: ->(_dir) { nil })
331
728
  sha = merge_sha(intent_dir)
332
729
  version = shipped_version(intent_dir) || tag_reader.call(intent_dir)
333
730
  return nil if sha.nil? && (version.nil? || version.to_s.empty?)
334
- ver_text = version && !version.to_s.empty? ? "v#{version.to_s.sub(/\Av/, '')}" : NOT_RECORDED
731
+ branch = branch_reader.call(intent_dir)
335
732
  sha_text = sha || NOT_RECORDED
336
- { kind: "ship", what: "#{sha_text} → alpha · #{ver_text}", source: "outcome.md; git tags" }
733
+ what = +sha_text
734
+ what << " → #{branch}" if branch && !branch.to_s.empty?
735
+ # D10: the version segment is omitted, not filled with NOT_RECORDED. A
736
+ # repository with no release line has no version, the header already
737
+ # carries the shipped identity, and naming the absence twice on one screen
738
+ # is the defect this intent was opened to remove, not a floor worth keeping.
739
+ what << " · v#{version.to_s.sub(/\Av/, '')}" if version && !version.to_s.empty?
740
+ # D14: the cell names every file the row actually came from. The branch and
741
+ # the version have different origins, so when both contributed, both are
742
+ # named rather than only the branch's.
743
+ sources = ["outcome.md"]
744
+ if branch && !branch.to_s.empty?
745
+ sources << (flow_base(intent_dir) == branch ? "project.yml" : "git refs")
746
+ end
747
+ sources << "git tags" if version && !version.to_s.empty? && shipped_version(intent_dir).nil?
748
+ sources << "git tags" if sources.length == 1
749
+ source = sources.join("; ")
750
+ { kind: "ship", what: what, source: source }
337
751
  end
338
752
 
339
753
  def self.doctor_row(text)
@@ -362,7 +776,7 @@ module ReportScreen
362
776
  { kind: "verdict", what: m[1].strip, source: "outcome.md" }
363
777
  end
364
778
 
365
- def self.evidence_rows(intent_dir, tag_reader: ->(_dir) { nil })
779
+ def self.evidence_rows(intent_dir, tag_reader: ->(_dir) { nil }, branch_reader: ->(_dir) { nil })
366
780
  text = outcome_text(intent_dir)
367
781
  return [] unless text
368
782
  verification = section_of(text, "## Verification")
@@ -370,7 +784,7 @@ module ReportScreen
370
784
  rows = []
371
785
  rows << suite_row(verification)
372
786
  rows << red_row(verification)
373
- rows << (research_intent?(intent_dir) ? nil : ship_row(text, intent_dir, tag_reader))
787
+ rows << (research_intent?(intent_dir) ? nil : ship_row(text, intent_dir, tag_reader, branch_reader: branch_reader))
374
788
  if research_intent?(intent_dir)
375
789
  rows << deposits_row(text)
376
790
  rows << verdict_row(text)
@@ -439,12 +853,16 @@ module ReportScreen
439
853
  out = out.gsub("{{name}}", data[:name])
440
854
  out = out.gsub("{{fields.rows}}", state_rows(data[:rows]).join("\n"))
441
855
  out = out.gsub("{{steps.rows}}", IntentScreen.steps_rows(data[:items]))
442
- out.gsub(/\n{3,}/, "\n\n")
856
+ fit_screen(out.gsub(/\n{3,}/, "\n\n"))
443
857
  end
444
858
 
445
859
  # --- roster (D7/D8) -------------------------------------------------------------
446
860
 
447
- def self.active_dirnames(index_path)
861
+ # The dirnames named under one "## <section_name>" heading of an INDEX.md.
862
+ # active_dirnames used to hardcode "Active"; intent 330's session verb (D22)
863
+ # reuses this to find Completed/Abandoned dirnames for the no-bookend
864
+ # footer, so the section is now a parameter.
865
+ def self.dirnames_in_section(index_path, section_name)
448
866
  return [] unless File.exist?(index_path)
449
867
  dirnames = []
450
868
  section = nil
@@ -453,13 +871,24 @@ module ReportScreen
453
871
  section = line[3..].strip
454
872
  next
455
873
  end
456
- next unless section == "Active"
874
+ next unless section == section_name
457
875
  m = line.match(%r{\(store/([^/]+)/})
458
876
  dirnames << m[1] if m
459
877
  end
460
878
  dirnames
461
879
  end
462
880
 
881
+ def self.active_dirnames(index_path)
882
+ dirnames_in_section(index_path, "Active")
883
+ end
884
+
885
+ # Intent 330 (D22): both terminal sections count as "completed" for the
886
+ # no-bookend footer - a closed intent the reader cannot expect a Done
887
+ # savepoint line from, since the convention predates end-intent writing it.
888
+ def self.completed_dirnames(index_path)
889
+ dirnames_in_section(index_path, "Completed") + dirnames_in_section(index_path, "Abandoned")
890
+ end
891
+
463
892
  def self.newest_savepoint_ts(intent_dir)
464
893
  lines = savepoint_lines(intent_dir)
465
894
  lines.last&.first
@@ -478,17 +907,36 @@ module ReportScreen
478
907
  entries.sort_by { |e| [-(e[:ts] ? Time.parse(e[:ts]).to_i : 0), e[:id]] }
479
908
  end
480
909
 
481
- def self.lead(intent_dir)
482
- data = Lock.read(intent_dir)
483
- return "idle" unless data
484
- agent = data["owner_agent"].to_s
485
- session = data["owner_session"].to_s
486
- return "idle" if agent.empty? && session.empty?
487
- "#{agent.empty? ? 'unknown' : agent} · #{session[0, 8]}"
910
+ # D6/R5, intent 331f: one freshness rule for every Lead cell, on the SAME primitive
911
+ # (Lock.who) every call site now shares - a fresh lock prints "agent · key" (this file's
912
+ # own long-standing format), an older lock prints "stale · N min", never idle; no lock, or
913
+ # one that will not read, prints "idle". Lock.who is called ONCE: it already returns the
914
+ # heartbeat timestamp alongside the state, so nothing stats the lock file a second time.
915
+ def self.lead_cell(intent_dir, now: Time.now)
916
+ data = Lock.who(intent_dir, now: now)
917
+ case data["state"]
918
+ when "fresh"
919
+ owner = data["owner"] || {}
920
+ agent = owner["agent"].to_s
921
+ agent = "unknown" if agent.empty? || agent == "unknown"
922
+ session = data["owner_session"].to_s
923
+ "#{agent} · #{session[0, 8]}"
924
+ when "stale"
925
+ mins = [((now - Time.parse(data["heartbeat_at"])) / 60).to_i, 0].max
926
+ "stale · #{mins} min"
927
+ else
928
+ "idle"
929
+ end
488
930
  rescue StandardError
489
931
  "idle"
490
932
  end
491
933
 
934
+ # The roster's own call site (unchanged name/signature at the call sites below); `now:`
935
+ # defaults so a caller that never passed a clock keeps working exactly as before.
936
+ def self.lead(intent_dir, now: Time.now)
937
+ lead_cell(intent_dir, now: now)
938
+ end
939
+
492
940
  def self.collapsed_open_steps_note(count)
493
941
  count <= 3 ? "#{count} open" : "#{count} open · showing the first three"
494
942
  end
@@ -516,21 +964,21 @@ module ReportScreen
516
964
 
517
965
  header = "▶ In delivery · #{entries.length} #{entries.length == 1 ? 'intent' : 'intents'} · " \
518
966
  "#{now.utc.strftime('%Y-%m-%d %H:%M UTC')}"
519
- table = ["| Intent | Stage | Progress | Changed | Lead |", "| --- | --- | --- | --- | --- |"]
967
+ table = ["| Graph ID | Stage | Progress | Changed | Lead |", "| --- | --- | --- | --- | --- |"]
520
968
  entries.each do |e|
521
969
  text = intent_text(e[:dir]).to_s
522
970
  savepoint = IntentScreen.savepoint_fields(e[:dir], text)
523
971
  items = IntentScreen.checklist_items(e[:dir])
524
972
  progress = IntentScreen.progress_fields(items)
525
973
  ch = state_fields(intent_dir: e[:dir], store_root: store_root, changed: changed)[:rows].find { |l, _, _| l == "Changed" }[1]
526
- table << "| #{e[:id]} | #{savepoint['stage']} | #{progress['progress.bar']} #{progress['progress.done']} / #{progress['progress.total']} | #{escape(ch)} | #{lead(e[:dir])} |"
974
+ table << "| #{e[:id]} | #{savepoint['stage']} | #{progress['progress.bar']} #{progress['progress.done']} / #{progress['progress.total']} | #{escape(ch)} | #{lead(e[:dir], now: now)} |"
527
975
  end
528
976
  blocks = entries.map { |e| render_collapsed_block(e[:dir], store_root, changed: changed) }
529
977
  head_and_table = ([header, ""] + table).join("\n")
530
978
  # Each collapsed block already has its own internal "\n"; a blank line
531
979
  # separates block from block (design--delivery-reports.html:137-152),
532
980
  # so they read as distinct entries instead of running together.
533
- "#{head_and_table}\n\n#{blocks.join("\n\n")}\n"
981
+ fit_screen("#{head_and_table}\n\n#{blocks.join("\n\n")}\n")
534
982
  end
535
983
 
536
984
  # --- S6: the delivered verb ------------------------------------------------------
@@ -541,39 +989,52 @@ module ReportScreen
541
989
  done ? human_time(done[0]) : NOT_RECORDED
542
990
  end
543
991
 
544
- def self.render_delivered(intent_dir:, tag_reader: ->(_dir) { nil })
992
+ # Intent 330 (D11): the header's last segment is the shipped identity, and
993
+ # says which kind it is - v<version> when a version is known, else
994
+ # "merge <sha>" (never a bare, ambiguous hash), else the exact NOT_RECORDED
995
+ # string when neither exists.
996
+ def self.header_ship_segment(intent_dir, tag_reader)
997
+ version = shipped_version(intent_dir) || tag_reader.call(intent_dir)
998
+ return "v#{version.to_s.sub(/\Av/, '')}" if version && !version.to_s.empty?
999
+
1000
+ sha = merge_sha(intent_dir)
1001
+ return "merge #{sha}" if sha && !sha.to_s.empty?
1002
+
1003
+ NOT_RECORDED
1004
+ end
1005
+
1006
+ def self.render_delivered(intent_dir:, tag_reader: ->(_dir) { nil }, branch_reader: ->(_dir) { nil })
545
1007
  id = intent_id(intent_dir)
546
1008
  name = title_for(intent_dir, default_store_root(intent_dir))
547
1009
  ts = delivered_timestamp(intent_dir)
548
1010
  m = mode(intent_dir)
549
1011
  dur = duration(intent_dir)
550
- version = shipped_version(intent_dir) || tag_reader.call(intent_dir)
551
- ver_text = version && !version.to_s.empty? ? "v#{version.to_s.sub(/\Av/, '')}" : NOT_RECORDED
1012
+ ship_segment = header_ship_segment(intent_dir, tag_reader)
552
1013
 
553
1014
  lines = []
554
1015
  lines << "## ✔ #{id} · #{name} · delivered"
555
- lines << "#{ts} · #{m} · #{dur} · #{ver_text}"
1016
+ lines << "#{ts} · #{m} · #{dur} · #{ship_segment}"
556
1017
  lines << ""
557
1018
  lines << "**Asked**"
558
1019
  lines << " #{asked(intent_dir)}"
559
1020
  lines << " #{decision_note(intent_dir)}"
560
1021
  lines << ""
561
1022
  lines << "**Delivered**"
562
- lines << "| Row | What | Proven by |"
1023
+ lines << "| Row | Detail | Proven by |"
563
1024
  lines << "| --- | --- | --- |"
564
1025
  delivered_rows(intent_dir).each do |r|
565
1026
  lines << "| #{r[:label]} | #{escape(r[:text])} | #{escape(proven_by(intent_dir, r[:label]))} |"
566
1027
  end
567
1028
  lines << ""
568
1029
  lines << "**Evidence**"
569
- ev = evidence_rows(intent_dir, tag_reader: tag_reader)
1030
+ ev = evidence_rows(intent_dir, tag_reader: tag_reader, branch_reader: branch_reader)
570
1031
  if ev.empty?
571
1032
  # 317a S4 (matrix S4a): a header-only table (319's live rendering) says
572
1033
  # nothing; the honest floor is the same phrase every other absent source
573
1034
  # prints.
574
1035
  lines << NOT_RECORDED
575
1036
  else
576
- lines << "| Kind | What | Source |"
1037
+ lines << "| Kind | Detail | Source |"
577
1038
  lines << "| --- | --- | --- |"
578
1039
  ev.each do |r|
579
1040
  lines << "| #{r[:kind]} | #{escape(r[:what])} | #{escape(r[:source])} |"
@@ -585,11 +1046,11 @@ module ReportScreen
585
1046
  if needsyou.empty?
586
1047
  lines << "None"
587
1048
  else
588
- lines << "| N | What | Why |"
1049
+ lines << "| N | Need | Reason |"
589
1050
  lines << "| --- | --- | --- |"
590
1051
  needsyou.each { |r| lines << "| #{r[:n]} | #{escape(r[:what])} | #{escape(r[:why])} |" }
591
1052
  end
592
- "#{lines.join("\n")}\n"
1053
+ fit_screen("#{lines.join("\n")}\n")
593
1054
  end
594
1055
 
595
1056
  # --- S7: the delay verb -----------------------------------------------------------
@@ -658,7 +1119,650 @@ module ReportScreen
658
1119
  lines << "**Where the time went** #{where_time_went(timeline)}"
659
1120
  lines << ""
660
1121
  lines << "**Outcome** #{delay_outcome_line(intent_dir)}"
661
- "#{lines.join("\n")}\n"
1122
+ fit_screen("#{lines.join("\n")}\n")
1123
+ end
1124
+
1125
+ # --- the plan verb (intent 331b): the PRE-delivery report -----------------------
1126
+ #
1127
+ # `report-screen plan <intent_dir>` prints the plan the record already
1128
+ # carries, before Exec starts: Asked, the decisions count, the planned
1129
+ # steps with their action file, and risks. Every cell traces to a file
1130
+ # (D3/D14); a missing source prints "not recorded", the same floor every
1131
+ # other screen in the family uses, except Mode (D2): a missing lock prints
1132
+ # "not armed", never "not recorded" - there is nothing to fall back to
1133
+ # before Exec starts.
1134
+
1135
+ VERDICT_TOKENS = %w[PROCEED APPROVE PASS REVISE REWORK FAIL BLOCK].freeze
1136
+
1137
+ # spec.md F4: a checklist line's OWN declared label ("S6 Docs and...")
1138
+ # survives here; STEP_PREFIX_RE (IntentScreen's own stripping regex) is
1139
+ # reused for the strip, so the label this recognizes is exactly the prefix
1140
+ # IntentScreen.checklist_items strips - the two readers can never disagree
1141
+ # on where a label ends and the step text begins.
1142
+ # The separator class mirrors STEP_PREFIX_RE's own (hyphen, colon, middle
1143
+ # dot, em dash, en dash); the latter two are written as \u escapes rather
1144
+ # than the literal glyph so this line never trips the project's added-line
1145
+ # dash guard, which scans literal characters only - the compiled regex
1146
+ # matches identically either way.
1147
+ STEP_LABEL_RE = /\A(?:Step\s*|S)\s*(\d+)\s*(?:[-:·\u2014\u2013]\s*|\s+)(?=\S)/i.freeze
1148
+
1149
+ def self.asked_first_sentence(intent_dir)
1150
+ body = asked(intent_dir)
1151
+ return NOT_RECORDED if body == NOT_RECORDED
1152
+ collapsed = body.gsub(/\s+/, " ").strip
1153
+ head, rest = IntentScreen.clause_and_rest(collapsed)
1154
+ rest ? "#{head}…" : head
1155
+ end
1156
+
1157
+ # D5, intent 331f: the plan screen's own Asked row - the intent title before its first
1158
+ # colon (F21), never the whole `## Intent` body asked_first_sentence above reads. Most real
1159
+ # intent lines read "Short title: the elaborated ask...", so this is the short title; a body
1160
+ # with no colon at all (a short intent with no title/elaboration split) renders unchanged,
1161
+ # word-boundary truncated the same way every other title cell in the family is.
1162
+ def self.plan_asked_title(intent_dir)
1163
+ body = asked(intent_dir)
1164
+ return NOT_RECORDED if body == NOT_RECORDED
1165
+ title_before_colon(body.gsub(/\s+/, " ").strip)
1166
+ end
1167
+
1168
+ # spec.md F4: keeps checklist.md's own file order and each line's DECLARED
1169
+ # label, falling back to the positional S<n> only when a line declares
1170
+ # none - IntentScreen.checklist_items strips the label and renumbers
1171
+ # positionally, which is right for the state screen and wrong for the
1172
+ # Action lookup below.
1173
+ def self.plan_steps(intent_dir)
1174
+ return [] unless IntentScreen.items_present?(intent_dir)
1175
+
1176
+ raw = File.readlines(File.join(intent_dir, "checklist.md")).filter_map do |line|
1177
+ m = line.match(IntentScreen::ITEM_RE)
1178
+ next unless m
1179
+ text = m[2].strip
1180
+ next if text == "..."
1181
+ text
1182
+ end
1183
+
1184
+ raw.each_with_index.map do |text, i|
1185
+ m = text.match(STEP_LABEL_RE)
1186
+ label = m ? "S#{m[1]}" : "S#{i + 1}"
1187
+ { label: label, text: text.sub(IntentScreen::STEP_PREFIX_RE, "") }
1188
+ end
1189
+ end
1190
+
1191
+ # spec.md F3/F6a: the Action column names the file whose heading carries
1192
+ # the step's label AND whose section has a matrix table of its own - a
1193
+ # heading that resolves but proves nothing is the same hollow-close defect
1194
+ # `proven_by` already guards against, so it renders "not recorded" too.
1195
+ def self.action_file_for(intent_dir, label)
1196
+ Dir.glob(File.join(intent_dir, "actions", "*.md")).sort.each do |path|
1197
+ split_by_headings(File.read(path)).each do |heading, body|
1198
+ next unless heading_tokens(heading).include?(label)
1199
+ return File.basename(path, ".md") if table_rows(body).any?
1200
+ end
1201
+ end
1202
+ NOT_RECORDED
1203
+ end
1204
+
1205
+ # D2: mode from the LIVE delivery lock only - unlike `mode` (row 36), a
1206
+ # missing lock never falls back to outcome.md's frontmatter (there is
1207
+ # nothing to fall back to before Exec starts) and never says the
1208
+ # delivered screen's "not recorded"; it says "not armed".
1209
+ def self.plan_mode(intent_dir)
1210
+ data = Lock.read(intent_dir)
1211
+ value = data && data["run_mode"]
1212
+ value && !value.to_s.empty? ? value.to_s : "not armed"
1213
+ end
1214
+
1215
+ # The last `Review` savepoint line whose text names a PLAN review - a
1216
+ # post-execution review line never matches, since its text never contains
1217
+ # "plan review".
1218
+ def self.plan_review_line(intent_dir)
1219
+ savepoint_lines(intent_dir).reverse.find { |_ts, kind, text| kind == "Review" && text =~ /plan review/i }
1220
+ end
1221
+
1222
+ def self.plan_reviewer(intent_dir)
1223
+ line = plan_review_line(intent_dir)
1224
+ return "not reviewed" unless line
1225
+ _ts, _kind, text = line
1226
+ VERDICT_TOKENS.find { |t| text =~ /\b#{t}\b/ } || NOT_RECORDED
1227
+ end
1228
+
1229
+ def self.plan_reviewer_note(intent_dir)
1230
+ line = plan_review_line(intent_dir)
1231
+ return "-" unless line
1232
+ ts, _kind, text = line
1233
+ "#{human_time(ts)} · #{text}"
1234
+ end
1235
+
1236
+ def self.plan_fields(intent_dir)
1237
+ [
1238
+ ["Asked", plan_asked_title(intent_dir), "## Intent"],
1239
+ ["Decisions", decision_note(intent_dir), "-"],
1240
+ ["Steps", "#{plan_steps(intent_dir).length} planned", "checklist.md"],
1241
+ ["Mode", plan_mode(intent_dir), "the delivery lock"],
1242
+ ["Reviewer", plan_reviewer(intent_dir), plan_reviewer_note(intent_dir)],
1243
+ ]
1244
+ end
1245
+
1246
+ # plan.md's own ## Risks bullets, wrapped continuations joined (317a's
1247
+ # bullet_rows); [] when plan.md is absent or carries no such section - the
1248
+ # renderer prints the literal "None" rather than an empty table, the
1249
+ # lesson 317a S4 already learned on the Evidence table.
1250
+ def self.risk_rows(intent_dir)
1251
+ path = File.join(intent_dir, "plan.md")
1252
+ return [] unless File.exist?(path)
1253
+ bullet_rows(section_of(File.read(path), "## Risks"))
1254
+ end
1255
+
1256
+ def self.render_plan(intent_dir:, store_root:, template:)
1257
+ id = intent_id(intent_dir)
1258
+ name = title_for(intent_dir, store_root)
1259
+ steps = plan_steps(intent_dir)
1260
+
1261
+ steps_rows =
1262
+ if steps.empty?
1263
+ "| | | no steps yet |"
1264
+ else
1265
+ steps.map do |s|
1266
+ "| #{escape(s[:label])} | #{escape(action_file_for(intent_dir, s[:label]))} | #{escape(s[:text])} |"
1267
+ end.join("\n")
1268
+ end
1269
+
1270
+ risks = risk_rows(intent_dir)
1271
+ risks_block =
1272
+ if risks.empty?
1273
+ "None"
1274
+ else
1275
+ rows = risks.each_with_index.map { |r, i| "| #{i + 1} | #{escape(r)} |" }
1276
+ (["| N | Risk |", "| --- | --- |"] + rows).join("\n")
1277
+ end
1278
+
1279
+ out = template.dup
1280
+ out = out.gsub("{{id}}", id)
1281
+ out = out.gsub("{{name}}", name)
1282
+ out = out.gsub("{{fields.rows}}", state_rows(plan_fields(intent_dir)).join("\n"))
1283
+ out = out.gsub("{{steps.rows}}", steps_rows)
1284
+ out = out.gsub("{{risks.block}}", risks_block)
1285
+ fit_screen(out.gsub(/\n{3,}/, "\n\n"))
1286
+ end
1287
+
1288
+ # --- S9: the session verb (intent 330) -------------------------------------------
1289
+ #
1290
+ # `report-screen session <tier_root>` - the delivered screens for every intent
1291
+ # this session completed, oldest first, then the state --all roster (D1).
1292
+ # Membership is the savepoint Done bookend inside [window_start, now] (D2),
1293
+ # never the delivery lock (a dispatched lead's derived auto- key is not the
1294
+ # owner's session id). The pure functions below take the clock and the
1295
+ # ledger root as arguments (D8): no Time.now, no git, no ENV read here.
1296
+
1297
+ # <home> for a tier root, by the same layout discriminator IntentScreen
1298
+ # uses elsewhere: a project tier root's parent directory is "projects".
1299
+ def self.home_for_tier_root(tier_root)
1300
+ File.basename(File.dirname(tier_root)) == "projects" ? File.expand_path("../..", tier_root) : tier_root
1301
+ end
1302
+
1303
+ # D18: <home>/store/.sessions, derived from the tier root through the SAME
1304
+ # discriminator - deriving it unconditionally from tier_root would answer
1305
+ # "/Users" for the global tier (~/.plastic itself has no "store" segment
1306
+ # to strip).
1307
+ def self.default_ledger_root(tier_root)
1308
+ File.join(home_for_tier_root(tier_root), "store", ".sessions")
1309
+ end
1310
+
1311
+ # D5: "global" is <home> itself; any other slug is <home>/projects/<slug>.
1312
+ def self.store_for_slug(home, slug)
1313
+ slug == "global" ? home : File.join(home, "projects", slug)
1314
+ end
1315
+
1316
+ # D4: the newest valid day directory that is not in the future, when
1317
+ # `today`'s own day directory does not exist. No ledger at all (D3.13)
1318
+ # answers `today` unchanged rather than raising - there is simply nothing
1319
+ # to scan, not an error.
1320
+ def self.fallback_day(ledger_root, today)
1321
+ return today if Dir.exist?(File.join(ledger_root, today))
1322
+ return today unless Dir.exist?(ledger_root)
1323
+
1324
+ candidates = Dir.children(ledger_root).select { |d| SessionLedger.valid_day_id?(d) && d <= today }
1325
+ candidates.max || today
1326
+ end
1327
+
1328
+ # D17: the visible note printed above the screens when no session id was
1329
+ # given at all, so the whole-day, tier-only fallback never looks like a
1330
+ # real, narrower answer.
1331
+ # D17: shaped as a screen opener ("▶ ... · ...") on purpose. The note is the
1332
+ # first line of the reply, and both ScreenPaint's OPENER_RE and the
1333
+ # MessageDisplay hook's first-character gate require that shape; a plain
1334
+ # sentence here would leave the whole session report unpainted.
1335
+ def self.window_note(day, reason)
1336
+ "▶ Window · the whole of #{Date.strptime(day, '%Y%m%d').iso8601} · #{reason}"
1337
+ end
1338
+
1339
+ # True when the day ledger actually carries a line for this session, across
1340
+ # the same two day directories the window search reads. The CLI asks so it
1341
+ # can tell "no session id given" apart from "this session id matches no
1342
+ # ledger line": D17 exists to stop the second one answering silently, and a
1343
+ # resumed background job carries exactly that kind of unmatched id.
1344
+ def self.session_tagged?(ledger_root:, session:, now:)
1345
+ return false if session.nil? || session.to_s.strip.empty?
1346
+
1347
+ short = SessionLedger.short_session_id(session)
1348
+ today = SessionLedger.day_id(now)
1349
+ yesterday = SessionLedger.day_id(now - 86_400)
1350
+ [yesterday, today].any? do |d|
1351
+ session_ledger_lines(ledger_root, d).any? { |l| l[:session] == short }
1352
+ end
1353
+ end
1354
+
1355
+ # D4: local midnight of `day`, converted to UTC, using `sample_now`'s OWN
1356
+ # utc_offset - never a literal UTC midnight, and never the machine's
1357
+ # ambient zone outside what the injected clock itself carries.
1358
+ def self.local_midnight_utc(day, sample_now)
1359
+ date = Date.strptime(day, "%Y%m%d")
1360
+ Time.new(date.year, date.month, date.day, 0, 0, 0, sample_now.utc_offset)
1361
+ end
1362
+
1363
+ # One day's session-tagged savepoint lines: "{ts} {Event} [{session}]
1364
+ # [{slug}] {summary}" (SessionLedger.savepoint_line's own shape). Missing
1365
+ # file, or a line that does not match, is silently skipped.
1366
+ SESSION_LEDGER_LINE_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s{2,}\S+\s{2,}\[([^\]]*)\]\s\[([^\]]*)\]/.freeze
1367
+
1368
+ def self.session_ledger_lines(ledger_root, day)
1369
+ path = File.join(ledger_root, day, "savepoint.md")
1370
+ return [] unless File.exist?(path)
1371
+
1372
+ File.readlines(path).filter_map do |line|
1373
+ m = line.match(SESSION_LEDGER_LINE_RE)
1374
+ m ? { ts: m[1], session: m[2], slug: m[3] } : nil
1375
+ end
1376
+ end
1377
+
1378
+ def self.store_intent_dirs(store)
1379
+ Dir.glob(File.join(store, "store", "*")).select { |d| IntentScreen.intent_dir?(d) }
1380
+ end
1381
+
1382
+ def self.last_done_ts(intent_dir)
1383
+ lines = savepoint_lines(intent_dir)
1384
+ done = lines.reverse.find { |_ts, kind, _text| kind == "Done" }
1385
+ done ? Time.parse(done[0]) : nil
1386
+ end
1387
+
1388
+ # D2/D3/D4/D5/D22: the intent directories completed inside the session's
1389
+ # window, oldest Done bookend first, plus the count of completed intents
1390
+ # (D22: Completed or Abandoned in INDEX.md) that carry no Done bookend at
1391
+ # all and so cannot be placed in any window.
1392
+ def self.session_delivered_dirs(ledger_root:, tier_root:, session:, since:, now:)
1393
+ today = SessionLedger.day_id(now)
1394
+ yesterday = SessionLedger.day_id(now - 86_400)
1395
+ short = session && !session.to_s.strip.empty? ? SessionLedger.short_session_id(session) : nil
1396
+
1397
+ tagged = short ? [yesterday, today].flat_map { |d| session_ledger_lines(ledger_root, d) }
1398
+ .select { |l| l[:session] == short } : []
1399
+ slugs = tagged.map { |l| l[:slug] }.uniq
1400
+
1401
+ window_start =
1402
+ if since
1403
+ Time.parse(since.to_s)
1404
+ elsif tagged.any?
1405
+ tagged.map { |l| Time.parse(l[:ts]) }.min
1406
+ else
1407
+ local_midnight_utc(fallback_day(ledger_root, today), now)
1408
+ end
1409
+
1410
+ home = home_for_tier_root(tier_root)
1411
+ stores = ([tier_root] + slugs.map { |s| store_for_slug(home, s) }).uniq
1412
+ stores = stores.select { |s| File.exist?(File.join(s, "INDEX.md")) }
1413
+
1414
+ entries = []
1415
+ skipped = 0
1416
+ stores.each do |store|
1417
+ completed = completed_dirnames(File.join(store, "INDEX.md"))
1418
+ store_intent_dirs(store).each do |dir|
1419
+ done_ts = last_done_ts(dir)
1420
+ if done_ts
1421
+ entries << [dir, done_ts] if done_ts >= window_start && done_ts <= now
1422
+ elsif completed.include?(File.basename(dir))
1423
+ skipped += 1
1424
+ end
1425
+ end
1426
+ end
1427
+
1428
+ [entries.sort_by { |_dir, ts| ts }.map(&:first), skipped]
1429
+ end
1430
+
1431
+ # D1/D7/D21/D22: one delivered screen per directory (oldest first, one
1432
+ # blank line apart), the roster last, and the skipped-count footer between
1433
+ # them when non-zero. `painter` is applied to each block SEPARATELY (D21):
1434
+ # a screen ScreenPaint cannot parse falls back to its own plain text
1435
+ # without touching its neighbours; the default is the identity function,
1436
+ # so a caller that never paints gets the plain screens verbatim. A
1437
+ # directory whose delivered screen cannot be rendered (O3.28) never sinks
1438
+ # the rest of the report.
1439
+ def self.render_session(dirs:, skipped:, store_root:, tag_reader: ->(_dir) { nil },
1440
+ branch_reader: ->(_dir) { nil }, note: nil, changed: nil,
1441
+ now: Time.now, painter: ->(text) { text })
1442
+ blocks = []
1443
+ blocks << note if note && !note.to_s.empty?
1444
+
1445
+ if dirs.empty?
1446
+ blocks << "No intents delivered in this session."
1447
+ else
1448
+ dirs.each do |dir|
1449
+ blocks << begin
1450
+ render_delivered(intent_dir: dir, tag_reader: tag_reader, branch_reader: branch_reader).chomp
1451
+ rescue StandardError => e
1452
+ "## #{intent_id(dir)} · could not render (#{e.message})"
1453
+ end
1454
+ end
1455
+ end
1456
+
1457
+ if skipped.positive?
1458
+ blocks << "#{skipped} completed intent#{skipped == 1 ? '' : 's'} skipped: no Done bookend in savepoint.md."
1459
+ end
1460
+
1461
+ blocks << render_roster(store_root, changed: changed, now: now).chomp
1462
+
1463
+ "#{blocks.map { |b| painter.call(b) }.join("\n\n")}\n"
1464
+ end
1465
+
1466
+ # --- S10: the roadmap verb (intent 331c) -----------------------------------------
1467
+ #
1468
+ # `report-screen roadmap <roadmap.md> plan|state|delivered` - a roadmap's own three reports,
1469
+ # the counterpart to an intent's state/delivered. Every entry comes from RoadmapQueue's public
1470
+ # `roadmap` reader (D6/R1): no second parser here ever re-derives its grammar, its INDEX
1471
+ # reconciliation, or its frontier selection.
1472
+
1473
+ ROADMAP_VERBS = %w[plan state delivered].freeze
1474
+
1475
+ # D6/R17: the tier root for a roadmap path is the parent of `roadmaps/`, one extra parent when
1476
+ # the file sits under `roadmaps/archived/` - the SAME rule RoadmapSavepoint.index_path_for
1477
+ # uses (that method is private, so this is the rule's second, agreeing owner; a test pins them
1478
+ # together).
1479
+ def self.roadmap_tier_root(path)
1480
+ dir = File.dirname(path)
1481
+ dir = File.dirname(dir) if File.basename(dir) == "archived"
1482
+ File.dirname(dir)
1483
+ end
1484
+
1485
+ def self.roadmap_default_template_path(verb)
1486
+ File.expand_path("../../templates/report-roadmap-#{verb}.md", __dir__)
1487
+ end
1488
+
1489
+ # D6/R1: the parsed, INDEX-reconciled entries for one roadmap file, obtained from
1490
+ # RoadmapQueue's own public reader - never a second parser.
1491
+ def self.roadmap_entries(path:, store_root:)
1492
+ index_path = File.join(store_root, "INDEX.md")
1493
+ RoadmapQueue.new(roadmaps_dir: File.dirname(path), index_path: index_path).roadmap(path)
1494
+ end
1495
+
1496
+ # R4/R15: the first sentence of `## Goal`, joined across wrapped source lines. Splits on a
1497
+ # period only (never IntentScreen.clause_and_rest's `[.;]` - a semicolon inside a real goal is
1498
+ # common and must survive, R15); a period with no following whitespace or end-of-string (a
1499
+ # version number like "2.0.0", never followed by a space mid-number) is never mistaken for a
1500
+ # sentence boundary (R4).
1501
+ def self.roadmap_goal(text)
1502
+ section = section_of(text, "## Goal").strip
1503
+ return NOT_RECORDED if section.empty?
1504
+
1505
+ joined = section.lines.map(&:strip).join(" ").squeeze(" ")
1506
+ m = joined.match(/\A(.*?\.)(?=\s|\z)/)
1507
+ (m ? m[1] : joined).strip
1508
+ end
1509
+
1510
+ # R16: the ledger's own entries when the paired `.savepoint.md` carries any; otherwise the
1511
+ # `## Log` lines classified through RoadmapSavepoint.classify_event (the same KEYWORD_TABLE,
1512
+ # no second vocabulary), each timestamped from its own Log line's date and time. A roadmap with
1513
+ # neither source (no ledger file, no classifiable Log line) answers `[]`, never an invented
1514
+ # event - callers reading it print `not recorded`.
1515
+ def self.roadmap_events(path)
1516
+ ledger = RoadmapSavepoint.ledger_entries(path)
1517
+ return ledger.sort_by { |t, _, _| t } if ledger.any?
1518
+
1519
+ text = File.exist?(path) ? File.read(path) : nil
1520
+ return [] unless text
1521
+
1522
+ section_of(text, "## Log").each_line.filter_map do |line|
1523
+ m = line.strip.match(RoadmapSavepoint::LOG_LINE)
1524
+ next nil unless m
1525
+ event = RoadmapSavepoint.classify_event(m[3])
1526
+ next nil unless event
1527
+ [Time.parse("#{m[1]}T#{m[2]}:00Z"), event, m[3].strip]
1528
+ end
1529
+ end
1530
+
1531
+ # Every `## Log` line, classified for the delivered screen's Log table: an unclassifiable line
1532
+ # still renders, with `not recorded` in its Event cell (never dropped, unlike roadmap_events'
1533
+ # fallback, which only wants events it can act on).
1534
+ def self.roadmap_log_rows(text)
1535
+ section_of(text, "## Log").each_line.filter_map do |line|
1536
+ m = line.strip.match(RoadmapSavepoint::LOG_LINE)
1537
+ next nil unless m
1538
+ { when: human_time("#{m[1]}T#{m[2]}:00Z"), event: RoadmapSavepoint.classify_event(m[3]) || NOT_RECORDED, what: m[3].strip }
1539
+ end
1540
+ end
1541
+
1542
+ # R7: idle unless the entry's own delivery lock is fresh as of `now:` - a stale lock (the
1543
+ # heartbeat older than the TTL) never masquerades as a live lead.
1544
+ def self.roadmap_lead(intent_dir, now:)
1545
+ return "idle" unless intent_dir
1546
+ lead_cell(intent_dir, now: now)
1547
+ end
1548
+
1549
+ def self.roadmap_intent_dir(store_root, id)
1550
+ Dir.glob(File.join(store_root, "store", "#{id}--*")).sort.find { |d| IntentScreen.intent_dir?(d) }
1551
+ end
1552
+
1553
+ def self.roadmap_entry_progress(dir)
1554
+ return NOT_RECORDED unless dir
1555
+ items = IntentScreen.checklist_items(dir)
1556
+ fields = IntentScreen.progress_fields(items)
1557
+ "#{fields['progress.bar']} #{fields['progress.done']} / #{fields['progress.total']}"
1558
+ end
1559
+
1560
+ def self.roadmap_progress_bar(done, total)
1561
+ on = total.zero? ? 0 : (done * IntentScreen::BAR_WIDTH) / total
1562
+ (IntentScreen::ON * on) + (IntentScreen::OFF * (IntentScreen::BAR_WIDTH - on))
1563
+ end
1564
+
1565
+ # "Batch" or "Wave" (singular): the entries table's own first column header (R3 - a legacy
1566
+ # Waves roadmap reads "Wave", never "Batch").
1567
+ def self.roadmap_batch_label(data)
1568
+ data[:grouping] == "Waves" ? "Wave" : "Batch"
1569
+ end
1570
+
1571
+ def self.roadmap_all_entries(data)
1572
+ data[:batches].flat_map { |b| b[:entries] }
1573
+ end
1574
+
1575
+ # --- plan (D2) ---------------------------------------------------------------
1576
+
1577
+ def self.roadmap_plan_fields(text, data, events)
1578
+ all_entries = roadmap_all_entries(data)
1579
+ order = data[:batches].map { |b| b[:heading] }.join(" → ")
1580
+ created = events.empty? ? NOT_RECORDED : human_time(events.first[0].utc.iso8601)
1581
+ [
1582
+ ["Goal", roadmap_goal(text), ""],
1583
+ [data[:grouping], "#{data[:batches].length} #{data[:grouping].downcase}, #{all_entries.length} intents", ""],
1584
+ ["Order", order, ""],
1585
+ ["Created", created, ""],
1586
+ ]
1587
+ end
1588
+
1589
+ def self.roadmap_plan_entries_table(data)
1590
+ label = roadmap_batch_label(data)
1591
+ rows = ["| #{label} | Graph ID | Intent | Status |", "| --- | --- | --- | --- |"]
1592
+ data[:batches].each do |batch|
1593
+ batch[:entries].each do |e|
1594
+ rows << "| #{escape(batch[:heading])} | #{escape(e[:id])} | #{escape(e[:text])} | #{escape(e[:status])} |"
1595
+ end
1596
+ end
1597
+ rows.join("\n")
1598
+ end
1599
+
1600
+ # --- state (D3) ---------------------------------------------------------------
1601
+
1602
+ def self.roadmap_state_fields(text, data, events, store_root, now)
1603
+ all_entries = roadmap_all_entries(data)
1604
+ total = all_entries.length
1605
+ delivered = all_entries.count { |e| e[:status] == "delivered" }
1606
+ bar = roadmap_progress_bar(delivered, total)
1607
+
1608
+ frontier = data[:frontier]
1609
+ frontier_value = frontier ? frontier[:heading] : NOT_RECORDED
1610
+ frontier_note =
1611
+ if frontier.nil?
1612
+ ""
1613
+ elsif frontier[:in_flight].any?
1614
+ "in flight"
1615
+ else
1616
+ "queued"
1617
+ end
1618
+
1619
+ delivering_value =
1620
+ if frontier && frontier[:in_flight].any?
1621
+ frontier[:in_flight].map do |e|
1622
+ dir = roadmap_intent_dir(store_root, e["id"])
1623
+ "#{e['id']} (#{roadmap_lead(dir, now: now)})"
1624
+ end.join(", ")
1625
+ else
1626
+ NOT_RECORDED
1627
+ end
1628
+
1629
+ next_entry = all_entries.find { |e| e[:status] == "queued" }
1630
+ next_value = next_entry ? "#{next_entry[:id]} #{next_entry[:text]}".strip : NOT_RECORDED
1631
+
1632
+ changed_value = events.empty? ? NOT_RECORDED : "#{events.last[1]} · #{human_time(events.last[0].utc.iso8601)}"
1633
+
1634
+ [
1635
+ ["Goal", roadmap_goal(text), ""],
1636
+ ["Progress", "#{bar} #{delivered} / #{total}", ""],
1637
+ ["Frontier", frontier_value, frontier_note],
1638
+ ["Delivering", delivering_value, ""],
1639
+ ["Next", next_value, ""],
1640
+ ["Changed", changed_value, ""],
1641
+ ]
1642
+ end
1643
+
1644
+ # RC4/spec.md defect 2: the Batches table carries the same Intent title column the plan
1645
+ # verb's own table already does (roadmap_plan_entries_table). The Intent cell spends
1646
+ # whatever the row's other cells leave it (W8a/W8b) through the ONE shared budget helper
1647
+ # (fit_row_cell) dashboard.rb's screen_fit_intent also spends by, computed PER ROW from that
1648
+ # row's own batch/id/status/progress/lead - never a cross-row max - so one long row's Intent
1649
+ # cell can never re-truncate another row's already-correct one (A5).
1650
+ def self.roadmap_state_entries_table(data, store_root, now)
1651
+ label = roadmap_batch_label(data)
1652
+ rows = ["| #{label} | Graph ID | Intent | Status | Progress | Lead |",
1653
+ "| --- | --- | --- | --- | --- | --- |"]
1654
+ data[:batches].each do |batch|
1655
+ batch[:entries].each do |e|
1656
+ dir = roadmap_intent_dir(store_root, e[:id])
1657
+ progress = roadmap_entry_progress(dir)
1658
+ lead = roadmap_lead(dir, now: now)
1659
+ others = [batch[:heading], e[:id], e[:status], progress, lead]
1660
+ intent_cell = fit_row_cell(e[:text], others)
1661
+ rows << "| #{escape(batch[:heading])} | #{escape(e[:id])} | #{escape(intent_cell)} | " \
1662
+ "#{escape(e[:status])} | #{escape(progress)} | #{escape(lead)} |"
1663
+ end
1664
+ end
1665
+ rows.join("\n")
1666
+ end
1667
+
1668
+ # --- delivered (D4) ------------------------------------------------------------
1669
+
1670
+ def self.roadmap_delivered_meta(data, events)
1671
+ all_entries = roadmap_all_entries(data)
1672
+ closed = events.reverse.find { |_t, event, _d| event == "closed" }
1673
+ closed_part = closed ? human_time(closed[0].utc.iso8601) : "in progress"
1674
+
1675
+ merged = events.select { |_t, event, _d| event == "merged" }
1676
+ duration = events.empty? || merged.empty? ? NOT_RECORDED : format_duration((merged.last[0] - events.first[0]).to_i)
1677
+
1678
+ "#{closed_part} · #{all_entries.length} intents · #{duration}"
1679
+ end
1680
+
1681
+ # The regex RoadmapSavepoint::KEYWORD_TABLE pairs with an event word - read from the table
1682
+ # rather than copied, so the Merged cell's vocabulary never drifts from rebuild's own.
1683
+ def self.roadmap_savepoint_keyword_regex(event)
1684
+ RoadmapSavepoint::KEYWORD_TABLE.find { |_re, ev| ev == event }.first
1685
+ end
1686
+
1687
+ # R10/R21/R22: the Merged cell reads a line only when the entry id is its SUBJECT - the first
1688
+ # whitespace-delimited token of the detail, never a whole word anywhere in it (R21: a real
1689
+ # ledger line names one entry's id as its subject and a SECOND entry's id in passing, and the
1690
+ # second entry has no merge line of its own to fill this row with). Among subject-matching
1691
+ # lines, one is read when the ledger's own event is `merged` OR its detail matches
1692
+ # KEYWORD_TABLE's merged pattern (R22: the appender sometimes files a real per-entry merge
1693
+ # under a different event word, `dispatched`, because the rest of the line was other news),
1694
+ # and refused when the event is `handoff` or the detail matches KEYWORD_TABLE's handoff
1695
+ # pattern - stricter than R10's original guarantee, never weaker. The sha is the first
1696
+ # hex-with-at-least-one-digit token of 7-40 characters in the matched line.
1697
+ def self.roadmap_merged_cell(id, events)
1698
+ merged_re = roadmap_savepoint_keyword_regex("merged")
1699
+ handoff_re = roadmap_savepoint_keyword_regex("handoff")
1700
+
1701
+ line = events.find do |_t, event, detail|
1702
+ next false unless detail.to_s.strip.split(/\s+/).first == id
1703
+ next false if event == "handoff" || detail.to_s =~ handoff_re
1704
+ event == "merged" || detail.to_s =~ merged_re
1705
+ end
1706
+ return NOT_RECORDED unless line
1707
+
1708
+ m = line[2].match(/\b(?=[0-9a-f]*\d)[0-9a-f]{7,40}\b/i)
1709
+ m ? m[0] : NOT_RECORDED
1710
+ end
1711
+
1712
+ def self.roadmap_delivered_table(data, events)
1713
+ label = roadmap_batch_label(data)
1714
+ rows = ["| #{label} | Graph ID | Intent | Merged |", "| --- | --- | --- | --- |"]
1715
+ data[:batches].each do |batch|
1716
+ batch[:entries].each do |e|
1717
+ rows << "| #{escape(batch[:heading])} | #{escape(e[:id])} | #{escape(e[:text])} | " \
1718
+ "#{escape(roadmap_merged_cell(e[:id], events))} |"
1719
+ end
1720
+ end
1721
+ rows.join("\n")
1722
+ end
1723
+
1724
+ def self.roadmap_log_table(text)
1725
+ log_rows = roadmap_log_rows(text)
1726
+ return NOT_RECORDED if log_rows.empty?
1727
+
1728
+ rows = ["| When | Event | Detail |", "| --- | --- | --- |"]
1729
+ log_rows.each { |r| rows << "| #{escape(r[:when])} | #{escape(r[:event])} | #{escape(r[:what])} |" }
1730
+ rows.join("\n")
1731
+ end
1732
+
1733
+ # --- render ---------------------------------------------------------------------
1734
+
1735
+ # D6: `ReportScreen.render_roadmap(path:, verb:, store_root: nil, now: Time.now, template:
1736
+ # nil)`. No ENV, no git; `now:` is used only for lock freshness (R7). `store_root` defaults to
1737
+ # the derived tier root; `template` defaults to the installed-or-in-repo
1738
+ # `templates/report-roadmap-<verb>.md`.
1739
+ def self.render_roadmap(path:, verb:, store_root: nil, now: Time.now, template: nil)
1740
+ verb = verb.to_s
1741
+ raise ArgumentError, "verb must be one of #{ROADMAP_VERBS.join(', ')}, got #{verb.inspect}" unless ROADMAP_VERBS.include?(verb)
1742
+
1743
+ store_root ||= roadmap_tier_root(path)
1744
+ text = File.read(path)
1745
+ data = roadmap_entries(path: path, store_root: store_root)
1746
+ events = roadmap_events(path)
1747
+ template ||= File.read(roadmap_default_template_path(verb))
1748
+
1749
+ out = template.dup
1750
+ out = out.gsub("{{slug}}", data[:slug])
1751
+
1752
+ case verb
1753
+ when "plan"
1754
+ out = out.gsub("{{fields.rows}}", state_rows(roadmap_plan_fields(text, data, events)).join("\n"))
1755
+ out = out.gsub("{{entries.table}}", roadmap_plan_entries_table(data))
1756
+ when "state"
1757
+ out = out.gsub("{{fields.rows}}", state_rows(roadmap_state_fields(text, data, events, store_root, now)).join("\n"))
1758
+ out = out.gsub("{{entries.table}}", roadmap_state_entries_table(data, store_root, now))
1759
+ when "delivered"
1760
+ out = out.gsub("{{meta}}", roadmap_delivered_meta(data, events))
1761
+ out = out.gsub("{{delivered.table}}", roadmap_delivered_table(data, events))
1762
+ out = out.gsub("{{log.table}}", roadmap_log_table(text))
1763
+ end
1764
+
1765
+ fit_screen(out.gsub(/\n{3,}/, "\n\n"))
662
1766
  end
663
1767
 
664
1768
  # --- S8: --ansi passthrough (D2) -----------------------------------------------