@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/bin/test +24 -4
  4. package/hooks/call-budget +4 -0
  5. package/hooks/hooks.json +24 -0
  6. package/hooks/message-display +134 -0
  7. package/hooks/statusline +4 -27
  8. package/package.json +2 -2
  9. package/scripts/agent-report +8 -2
  10. package/scripts/dashboard.rb +297 -18
  11. package/scripts/doctor.rb +652 -5
  12. package/scripts/end-intent +219 -0
  13. package/scripts/hook-call-budget +222 -0
  14. package/scripts/hook-capture +25 -113
  15. package/scripts/hook-message-display +81 -0
  16. package/scripts/hook-record +12 -4
  17. package/scripts/hook-session-start +45 -7
  18. package/scripts/index-projection +74 -0
  19. package/scripts/intent-screen +77 -0
  20. package/scripts/lib/action_graph_shim.rb +277 -0
  21. package/scripts/lib/arm.rb +26 -1
  22. package/scripts/lib/atomic_write.rb +31 -0
  23. package/scripts/lib/compact_instructions.rb +5 -5
  24. package/scripts/lib/core_integrity.rb +71 -0
  25. package/scripts/lib/dashboard_screen.rb +40 -0
  26. package/scripts/lib/day_summary.rb +7 -2
  27. package/scripts/lib/doctor_core.rb +102 -5
  28. package/scripts/lib/doctor_session_ledger.rb +52 -0
  29. package/scripts/lib/graph_edges.rb +137 -0
  30. package/scripts/lib/graph_file.rb +246 -0
  31. package/scripts/lib/graph_tree.rb +98 -0
  32. package/scripts/lib/guarded_append.rb +155 -0
  33. package/scripts/lib/hook_registry.rb +28 -2
  34. package/scripts/lib/hook_replay.rb +211 -0
  35. package/scripts/lib/index_projection.rb +201 -0
  36. package/scripts/lib/installer_core.rb +141 -6
  37. package/scripts/lib/intent_screen.rb +309 -0
  38. package/scripts/lib/intent_screen_ansi.rb +262 -0
  39. package/scripts/lib/message_display.rb +586 -0
  40. package/scripts/lib/meter_watch.rb +179 -0
  41. package/scripts/lib/node_file.rb +214 -0
  42. package/scripts/lib/node_ids.rb +99 -0
  43. package/scripts/lib/node_ledger.rb +377 -0
  44. package/scripts/lib/node_packet.rb +908 -0
  45. package/scripts/lib/node_return.rb +199 -0
  46. package/scripts/lib/node_worktree.rb +337 -0
  47. package/scripts/lib/outcome_report.rb +440 -0
  48. package/scripts/lib/packet_wrapper.rb +132 -0
  49. package/scripts/lib/ready_set.rb +462 -0
  50. package/scripts/lib/release_guard.rb +16 -0
  51. package/scripts/lib/report_screen.rb +1967 -0
  52. package/scripts/lib/roadmap_graph.rb +210 -0
  53. package/scripts/lib/roadmap_migration.rb +95 -0
  54. package/scripts/lib/roadmap_queue.rb +155 -5
  55. package/scripts/lib/roadmap_render.rb +150 -0
  56. package/scripts/lib/roadmap_savepoint.rb +62 -12
  57. package/scripts/lib/runner_absorb.rb +620 -0
  58. package/scripts/lib/runner_answer.rb +206 -0
  59. package/scripts/lib/runner_core.rb +194 -0
  60. package/scripts/lib/runner_dispatch.rb +506 -0
  61. package/scripts/lib/runner_policy.rb +173 -0
  62. package/scripts/lib/runner_proposals.rb +275 -0
  63. package/scripts/lib/runner_rewind.rb +201 -0
  64. package/scripts/lib/runner_sweep.rb +231 -0
  65. package/scripts/lib/savepoint.rb +149 -12
  66. package/scripts/lib/screen_paint.rb +555 -0
  67. package/scripts/lib/screens/dashboard.rb +20 -0
  68. package/scripts/lib/screens/plan.rb +18 -0
  69. package/scripts/lib/screens/roadmap.rb +15 -0
  70. package/scripts/lib/session_git.rb +49 -18
  71. package/scripts/lib/session_ledger.rb +128 -0
  72. package/scripts/lib/session_usage.rb +190 -0
  73. package/scripts/lib/verify_intent.rb +33 -0
  74. package/scripts/lib/work_graph_validator.rb +201 -0
  75. package/scripts/meter-watch +57 -0
  76. package/scripts/node-packet +92 -0
  77. package/scripts/node-transition +291 -0
  78. package/scripts/outcome-report +74 -0
  79. package/scripts/plastic-lock +8 -1
  80. package/scripts/read-config +3 -3
  81. package/scripts/ready-set +126 -0
  82. package/scripts/release-check +118 -0
  83. package/scripts/report-screen +281 -0
  84. package/scripts/roadmap-graph +119 -0
  85. package/scripts/roadmap-savepoint +7 -0
  86. package/scripts/runner +397 -0
  87. package/scripts/savepoint-note +69 -0
  88. package/scripts/session-usage +56 -0
  89. package/scripts/spawn-preamble +9 -2
  90. package/scripts/validate-work-graph +39 -0
  91. package/scripts/verify-intent +2 -1
  92. package/skills/auto/SKILL.md +41 -34
  93. package/skills/auto/references/human-report-contract.md +136 -54
  94. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  95. package/skills/dashboard/SKILL.md +13 -2
  96. package/skills/dashboard/templates/dashboard-global.md +1 -1
  97. package/skills/dashboard/templates/dashboard-project.md +2 -2
  98. package/skills/doctor/SKILL.md +10 -4
  99. package/skills/intent-continuing/SKILL.md +51 -41
  100. package/skills/intent-continuing/references/board-fill.md +9 -0
  101. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  102. package/skills/intent-continuing/references/context-management.md +1 -1
  103. package/skills/intent-ending/SKILL.md +36 -16
  104. package/skills/intent-executing/SKILL.md +21 -5
  105. package/skills/intent-executing/implementer-prompt.md +6 -1
  106. package/skills/intent-speccing/SKILL.md +7 -4
  107. package/skills/releasing/SKILL.md +39 -0
  108. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  109. package/skills/releasing/references/release-lines.md +1 -1
  110. package/skills/roadmap/SKILL.md +26 -0
  111. package/skills/roadmap/references/file-format.md +10 -0
  112. package/templates/config.yml +3 -3
  113. package/templates/dashboard-screen.md +22 -0
  114. package/templates/display-fixture.md +21 -0
  115. package/templates/graph.md +16 -0
  116. package/templates/intent-screen.md +17 -0
  117. package/templates/node-decision.md +11 -0
  118. package/templates/node-research.md +11 -0
  119. package/templates/node-verify.md +13 -0
  120. package/templates/node-work.md +22 -0
  121. package/templates/outcome.md +19 -1
  122. package/templates/report-plan.md +15 -0
  123. package/templates/report-roadmap-delivered.md +10 -0
  124. package/templates/report-roadmap-plan.md +9 -0
  125. package/templates/report-roadmap-state.md +9 -0
  126. package/templates/report-state.md +11 -0
  127. package/templates/roadmap.md +13 -0
@@ -0,0 +1,1967 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # ReportScreen (intent 317) - the record readers plus the three renderers
5
+ # (state, delivered, delay) behind scripts/report-screen. Every rendered cell
6
+ # traces to a file on disk (D14): a missing source renders the exact string
7
+ # "not recorded", never a guess or a blank. Pure: explicit paths in, a string
8
+ # out. Dependency injection for anything reaching outside the fixture: the
9
+ # clock is passed as `now:`, git tag reading as `tag_reader:`, and the ANSI
10
+ # renderer path as `renderer_path:` (D2).
11
+ require "time"
12
+ require "json"
13
+ require "date"
14
+ require_relative "intent_screen"
15
+ require_relative "lock"
16
+ require_relative "session_ledger"
17
+ require_relative "roadmap_queue"
18
+ require_relative "roadmap_savepoint"
19
+ require_relative "roadmap_graph"
20
+ require_relative "graph_tree"
21
+ require_relative "screen_paint"
22
+ require_relative "outcome_report"
23
+ require_relative "node_file"
24
+
25
+ module ReportScreen
26
+ NOT_RECORDED = "not recorded"
27
+
28
+ # --- shared helpers ----------------------------------------------------------
29
+
30
+ def self.intent_basename(intent_dir)
31
+ File.basename(intent_dir)
32
+ end
33
+
34
+ def self.intent_id(intent_dir)
35
+ intent_basename(intent_dir).split("--", 2).first
36
+ end
37
+
38
+ def self.intent_file_path(intent_dir)
39
+ File.join(intent_dir, "#{intent_basename(intent_dir)}.md")
40
+ end
41
+
42
+ def self.intent_text(intent_dir)
43
+ path = intent_file_path(intent_dir)
44
+ File.exist?(path) ? File.read(path) : nil
45
+ end
46
+
47
+ def self.spec_text(intent_dir)
48
+ path = File.join(intent_dir, "spec.md")
49
+ File.exist?(path) ? File.read(path) : nil
50
+ end
51
+
52
+ def self.outcome_text(intent_dir)
53
+ path = File.join(intent_dir, "outcome.md")
54
+ File.exist?(path) ? File.read(path) : nil
55
+ end
56
+
57
+ # The body of a top-level "## Heading" section, stopping at the next "## "
58
+ # heading (same idiom as IntentScreen.insight_fields). Returns "" when the
59
+ # heading is absent.
60
+ def self.section_of(text, heading)
61
+ return "" unless text
62
+ text.split(/^#{Regexp.escape(heading)}\s*$/, 2)[1].to_s.split(/^## /, 2)[0].to_s
63
+ end
64
+
65
+ def self.escape(text)
66
+ text.to_s.gsub("|", "\\|")
67
+ end
68
+
69
+ # --- width bound (D7, intent 331f) --------------------------------------------
70
+ #
71
+ # ReportScreen.fit_screen(text, limit:) is the one shared pass every public render entry
72
+ # point in this file (and dashboard.rb's screen renderer) calls last, so no rendered row
73
+ # ever passes the limit. Input unchanged byte for byte when nothing is over the limit.
74
+
75
+ FIT_SCREEN_DEFAULT_LIMIT = 115
76
+ # The column floor and the progress-bar glyph regex are ScreenPaint's own (intent 331f,
77
+ # finding 1): ScreenPaint.paint_data_table shrinks a painted row's columns through the same
78
+ # rule this file's own fit_table_block uses, so both aliases point at the one definition
79
+ # rather than carrying a second copy that could drift.
80
+ FIT_SCREEN_COLUMN_FLOOR = ScreenPaint::FIT_COLUMN_FLOOR
81
+ PROGRESS_BAR_CHARS_RE = ScreenPaint::PROGRESS_BAR_CHARS_RE
82
+
83
+ # Truncate `text` to at most `max_chars`, cutting at the last whitespace at or before the
84
+ # limit (never mid-word) and appending a single ellipsis when truncation happens. The one
85
+ # shared implementation now lives on ScreenPaint (intent 331f, finding 1); dashboard.rb's own
86
+ # helper of the same name delegates here, and this delegates onward so neither caller's own
87
+ # name has to change.
88
+ def self.truncate_on_word_boundary(text, max_chars)
89
+ ScreenPaint.truncate_on_word_boundary(text, max_chars)
90
+ end
91
+
92
+ # Split on every pipe, escaped or not - the SAME rule ScreenPaint.cells_of uses (R3), so the
93
+ # fitter and the painter can never count a row's columns differently. Raw (unstripped) cells,
94
+ # so callers can still tell a padded column from an unpadded one.
95
+ def self.raw_cells_of(row)
96
+ row.split("|", -1)[1..-2].to_a
97
+ end
98
+
99
+ # Where a title ends (D8, orchestrator ruling 2026-09-05). A title ends at the first colon
100
+ # FOLLOWED BY A SPACE, which is how a person writes a label before its explanation. Any
101
+ # colon would also cut inside a URL or a clock time and leave a name no reader recognizes:
102
+ # zlatkocodes intent 4 opens "About page redesign and header navigation order. Rebuild
103
+ # https://zlatkocodes.com/about/ ... styling: ..." and used to render as "... Rebuild https".
104
+ # A title can carry both boundaries, and then the earlier one is the name: zlatkocodes 4 also
105
+ # has a real label colon, 130 characters in, long after its opening sentence ends. With
106
+ # neither boundary the title is the whole line.
107
+ # A line that opens with its colon has no label to take, so it falls back the same way. The
108
+ # one implementation: dashboard.rb reads titles through this rather than splitting again.
109
+ TITLE_LABEL_RE = /\A(.*?): /m.freeze
110
+ TITLE_SENTENCE_RE = /\A(.*?[.!?])(?:\s|\z)/m.freeze
111
+
112
+ def self.title_before_colon(text, max: 120)
113
+ line = text.to_s.strip
114
+ candidates = [TITLE_LABEL_RE, TITLE_SENTENCE_RE].filter_map { |re| line[re, 1]&.strip }
115
+ .reject(&:empty?)
116
+ truncate_on_word_boundary(candidates.min_by(&:length) || line, max)
117
+ end
118
+
119
+ # Intent 331f1 (RC1): every bound check below measures in DISPLAY COLUMNS
120
+ # (ScreenPaint.display_columns - ANSI stripped, a character at or above U+1100 counts two),
121
+ # not String#length - a bar row can pass a character-count check while still over the real
122
+ # 115-column bound, which is exactly why the suite stayed green while real screens rendered
123
+ # over it (spec.md's defect 3/4).
124
+ def self.fit_screen(text, limit: FIT_SCREEN_DEFAULT_LIMIT)
125
+ lines = text.to_s.lines
126
+ return text if lines.all? { |l| ScreenPaint.display_columns(l.chomp) <= limit }
127
+
128
+ out = +""
129
+ i = 0
130
+ while i < lines.length
131
+ if lines[i].lstrip.start_with?("|")
132
+ block = []
133
+ while i < lines.length && lines[i].lstrip.start_with?("|")
134
+ block << lines[i]
135
+ i += 1
136
+ end
137
+ out << fit_table_block(block, limit)
138
+ else
139
+ out << fit_plain_line(lines[i], limit)
140
+ i += 1
141
+ end
142
+ end
143
+ out
144
+ end
145
+
146
+ def self.fit_plain_line(line, limit)
147
+ body = line.chomp
148
+ return line if ScreenPaint.display_columns(body) <= limit
149
+ ending = line[body.length..].to_s
150
+ "#{truncate_on_word_boundary(body, limit)}#{ending}"
151
+ end
152
+
153
+ # Intent 331f1 (finding A3/A5): field tables and data tables get their own fitters
154
+ # (ScreenPaint.field_table? is the ONE classifier both this and the painter use), and the
155
+ # block-level guard above already lets an already-fitting block - every row already at or
156
+ # under `limit` in display columns, exactly what ReportScreen.fit_row_cell/
157
+ # roadmap_state_entries_table already produce for the roadmap Batches table - through
158
+ # untouched, so a table-wide shrink never re-truncates a row a caller already sized
159
+ # correctly (A5).
160
+ def self.fit_table_block(block, limit)
161
+ return block.join if block.all? { |l| ScreenPaint.display_columns(l.chomp) <= limit }
162
+
163
+ rows = block.map(&:chomp)
164
+ return fit_field_table_block(block, limit) if ScreenPaint.field_table?(rows)
165
+
166
+ is_sep = rows.map { |r| r.match?(ScreenPaint::SEPARATOR_RE) }
167
+ raw_rows = rows.map { |r| raw_cells_of(r) }
168
+ ncols = raw_rows.map(&:length).max.to_i
169
+ return block.join if ncols.zero?
170
+
171
+ stripped_cols = Array.new(ncols) { [] }
172
+ stripped_rows = []
173
+ header_idx = raw_rows.each_index.find { |ri| !is_sep[ri] }
174
+ raw_rows.each_with_index do |cells, ri|
175
+ next if is_sep[ri]
176
+ row = (0...ncols).map { |ci| cells[ci].to_s.strip }
177
+ row.each_with_index { |c, ci| stripped_cols[ci] << c }
178
+ stripped_rows << row
179
+ end
180
+ widths = stripped_cols.map { |col| col.map(&:length).max.to_i }
181
+
182
+ bar_column = Array.new(ncols) { |ci| stripped_cols[ci].any? { |c| c =~ PROGRESS_BAR_CHARS_RE } }
183
+ # Intent 331f1 (post-exec review, P3): the shared row-overage rule (ScreenPaint.
184
+ # row_display_overage), not a per-column bar credit - the same fix as paint_data_table's
185
+ # own P2, so the two renderers cannot drift apart on what "fits" means.
186
+ overage = ScreenPaint.row_display_overage(stripped_rows)
187
+ # Intent 331f1, S3 (brief 4): per-column minimums - never below the header cell, never
188
+ # below a natural width of 10 or less (the id case).
189
+ header_len = Array.new(ncols) { |ci| header_idx ? raw_rows[header_idx][ci].to_s.strip.length : 0 }
190
+ floors = (0...ncols).map { |ci| bar_column[ci] ? widths[ci] : ScreenPaint.column_floor(header_len[ci], widths[ci]) }
191
+
192
+ # A column is "padded" when at least one non-last, non-separator raw cell carries more
193
+ # than the one mandatory space before its closing pipe - the ljust convention several
194
+ # tables in this file already use (state_rows, roster). Only such a column is re-padded
195
+ # after a shrink; an unpadded table stays unpadded.
196
+ padded_column = Array.new(ncols) do |ci|
197
+ next false if ci == ncols - 1
198
+ raw_rows.each_with_index.any? { |cells, ri| !is_sep[ri] && cells[ci].to_s.end_with?(" ") }
199
+ end
200
+
201
+ budget = limit - (4 + 3 * (ncols - 1)) - overage
202
+ widths = ScreenPaint.shrink_column_widths(widths, budget, bar_columns: bar_column, floors: floors)
203
+
204
+ # 331f1a (D1/D2, plan-review ruling): a separator row passes through byte-identical
205
+ # whenever its OWN unfitted input already fits the bound - rebuilding it from the
206
+ # shrunk widths (with the `[w, 3].max` floor below) is what made it assemble wider
207
+ # than any data row in the first place, landing it as the only row the backstop ever
208
+ # cut (or, when the rebuilt form happened to still fit, wider than its own "---"
209
+ # input, which D1 forbids just as much). Only when even the unmodified input cannot
210
+ # fit does the old rebuild-and-backstop path apply - the bound wins there, which is
211
+ # exactly what test_fit_screen_backstops_an_unshrinkable_row and
212
+ # test_unshrinkable_data_table_is_still_bounded pin. A blank "| | | |" scaffold
213
+ # reaching this branch is classified as a separator by the same regex, so it gets
214
+ # the identical pass-through rule.
215
+ fitted_rows = raw_rows.each_with_index.map do |cells, ri|
216
+ if is_sep[ri]
217
+ original = rows[ri]
218
+ if ScreenPaint.display_columns(original) <= limit
219
+ original
220
+ else
221
+ "| #{widths.map { |w| "-" * [w, 3].max }.join(" | ")} |"
222
+ end
223
+ else
224
+ rendered = cells.each_with_index.map do |c, ci|
225
+ next c.to_s.strip if ci >= ncols
226
+ value = c.to_s.strip
227
+ value = truncate_on_word_boundary(value, widths[ci]) if value.length > widths[ci]
228
+ padded_column[ci] && ci != ncols - 1 ? value.ljust(widths[ci]) : value
229
+ end
230
+ "| #{rendered.join(' | ')} |"
231
+ end
232
+ end
233
+
234
+ # F28: the unconditional backstop. Every shrinkable column may already sit at its floor
235
+ # and the assembled row can still be over the limit; truncate the whole row on a word
236
+ # boundary rather than let it survive past 115 - a data table's separator row included
237
+ # (test_fit_screen_backstops_an_unshrinkable_row), unlike the field-table fitter's own
238
+ # separator, which always passes through untouched (W2). A separator already passed
239
+ # through byte-identical above never trips this (it already fits by construction).
240
+ fitted_rows.map! { |r| ScreenPaint.display_columns(r) > limit ? truncate_on_word_boundary(r, limit) : r }
241
+
242
+ "#{fitted_rows.join("\n")}\n"
243
+ end
244
+
245
+ # Intent 331f1 (S2, design): the field table's own fitter - a "| | | |" scaffold or
246
+ # "| --- | --- | --- |" separator row passes through byte for byte; the label column
247
+ # (first cell) takes its natural width and never shrinks or truncates; the VALUE column
248
+ # shrinks first, down to a floor of max(24, the widest bar cell in that column) so a
249
+ # progress bar is never cut; only then does the NOTE column shrink, and when what is left
250
+ # for it falls under ScreenPaint::FIT_COLUMN_FLOOR (8) columns the note is dropped whole
251
+ # (never squeezed to "in…") and the value reclaims the freed room, back up to its own
252
+ # natural width. A value that still cannot fit ends with an ellipsis; the value floor is
253
+ # never crossed even then, so the row may still exceed `limit` in that extreme case -
254
+ # there is no row-level backstop here (that backstop is the data-table branch's own, and
255
+ # it must never touch a field table's label cell).
256
+ #
257
+ # Intent 331f1 (post-exec review, P1): `label_w`/`value_w`/`note_w` and `budget` are character
258
+ # counts spent against the 115 DISPLAY-column bound - a bar row's glyphs (2 columns each) or
259
+ # an embedded ellipsis cost more display columns than characters, so a row can pass this
260
+ # arithmetic while still landing well over the real bound. `ScreenPaint.row_display_overage`
261
+ # reserves the worst row's own overage up front (P1-P3's shared fix); a fresh ellipsis this
262
+ # function's OWN truncation adds where none existed before can still leave a small residual,
263
+ # which the corrective loop below closes by re-measuring the actual assembled row and shrinking
264
+ # note (then value, never below its floor) by the exact excess.
265
+ #
266
+ # Intent 331f1 (P5): the label (and, when flagged, the value) column is re-padded exactly the
267
+ # way `fit_table_block`'s own `padded_column` rule would - ljust in CHARACTERS, never display
268
+ # columns, so a terminal drawing a bar glyph one column wide stays aligned - restoring the
269
+ # alignment a fitted field table lost.
270
+ def self.fit_field_table_block(block, limit)
271
+ return block.join if block.all? { |l| ScreenPaint.display_columns(l.chomp) <= limit }
272
+
273
+ rows = block.map(&:chomp)
274
+ is_sep = rows.map { |r| r.match?(ScreenPaint::SEPARATOR_RE) }
275
+ content_idx = rows.each_index.reject { |ri| is_sep[ri] }
276
+ return block.join if content_idx.empty?
277
+
278
+ raw_content = content_idx.map { |ri| raw_cells_of(rows[ri]) }
279
+ parsed = content_idx.map { |ri| ScreenPaint.cells_of(rows[ri]) }
280
+ ncols = parsed.map(&:length).max.to_i
281
+ return block.join if ncols.zero?
282
+
283
+ label_w = parsed.map { |c| c[0].to_s.length }.max.to_i
284
+ value_texts = parsed.map { |c| c[1].to_s }
285
+ natural_value_w = value_texts.map(&:length).max.to_i
286
+ bar_value_w = value_texts.select { |v| v =~ PROGRESS_BAR_CHARS_RE }.map(&:length).max.to_i
287
+ value_floor = [24, bar_value_w].max
288
+ value_w = natural_value_w
289
+
290
+ has_note = ncols > 2 && parsed.any? { |c| !c[2].to_s.empty? }
291
+ note_texts = has_note ? parsed.map { |c| c[2].to_s } : []
292
+ note_w = note_texts.map(&:length).max.to_i
293
+
294
+ gaps = ncols - 1
295
+ overage = ScreenPaint.row_display_overage(parsed.map { |c| (0...ncols).map { |ci| c[ci].to_s } })
296
+ budget = limit - (4 + 3 * gaps) - overage
297
+ overflow = (label_w + value_w + note_w) - budget
298
+
299
+ if overflow.positive?
300
+ shrink = [[overflow, value_w - value_floor].min, 0].max
301
+ value_w -= shrink
302
+ overflow -= shrink
303
+ end
304
+
305
+ if overflow.positive? && has_note
306
+ remaining_for_note = note_w - overflow
307
+ if remaining_for_note < FIT_SCREEN_COLUMN_FLOOR
308
+ freed = note_w
309
+ overflow -= freed
310
+ note_w = 0
311
+ has_note = false
312
+ value_w = [value_w - overflow, natural_value_w].min if overflow.negative?
313
+ else
314
+ note_w = remaining_for_note
315
+ end
316
+ end
317
+
318
+ # P5: a column (never the last) is "padded" when at least one non-separator RAW cell already
319
+ # ends with two spaces before its closing pipe - the same `padded_column` convention
320
+ # `fit_table_block` uses (state_rows, roster).
321
+ padded_label = raw_content.any? { |cells| cells[0].to_s.end_with?(" ") }
322
+ padded_value = ncols > 2 && raw_content.any? { |cells| cells[1].to_s.end_with?(" ") }
323
+
324
+ render = lambda do
325
+ rows.each_index.map do |ri|
326
+ next rows[ri] if is_sep[ri]
327
+ cells = ScreenPaint.cells_of(rows[ri])
328
+ label = cells[0].to_s
329
+ value = cells[1].to_s
330
+ note = has_note ? cells[2].to_s : ""
331
+
332
+ value = truncate_on_word_boundary(value, value_w) if value.length > value_w && value !~ PROGRESS_BAR_CHARS_RE
333
+ note = truncate_on_word_boundary(note, note_w) if has_note && note.length > note_w
334
+
335
+ label = label.ljust(label_w) if padded_label
336
+ value = value.ljust(value_w) if padded_value
337
+
338
+ if has_note
339
+ "| #{label} | #{value} | #{note} |"
340
+ elsif ncols > 2
341
+ "| #{label} | #{value} | |"
342
+ else
343
+ "| #{label} | #{value} |"
344
+ end
345
+ end
346
+ end
347
+
348
+ # The corrective pass (P1): measure what actually got assembled, and if it still runs over
349
+ # `limit`, shrink note (then value, down to its floor) by the exact excess and re-render.
350
+ # Bounded: each pass either shrinks a column or breaks, and there are at most two columns
351
+ # left to shrink once the label is fixed.
352
+ loop do
353
+ candidate = render.call
354
+ max_dw = content_idx.map { |ri| ScreenPaint.display_columns(candidate[ri]) }.max.to_i
355
+ break if max_dw <= limit
356
+
357
+ excess = max_dw - limit
358
+ progressed = false
359
+ if has_note && note_w.positive?
360
+ cut = [excess, note_w].min
361
+ note_w -= cut
362
+ excess -= cut
363
+ progressed = true if cut.positive?
364
+ if note_w < FIT_SCREEN_COLUMN_FLOOR
365
+ has_note = false
366
+ note_w = 0
367
+ end
368
+ end
369
+ if excess.positive? && value_w > value_floor
370
+ cut = [excess, value_w - value_floor].min
371
+ value_w -= cut
372
+ progressed = true if cut.positive?
373
+ end
374
+ break unless progressed
375
+ end
376
+
377
+ "#{render.call.join("\n")}\n"
378
+ end
379
+
380
+ # Intent 331f1 (design's final bullet): the shared budget dashboard.rb's screen_fit_intent
381
+ # and roadmap_state_entries_table's Intent cell both spend by - a title cell fitted to
382
+ # whatever the row's OTHER already-rendered cells leave it, measured in display columns
383
+ # (RC1: an `others` cell carrying a progress bar costs two columns per glyph, not one).
384
+ # `others` are the sibling cells as they will actually render; the scaffolding is the
385
+ # leading "| ", a " | " between every pair of cells, and the trailing " |".
386
+ def self.fit_row_cell(title, others, max: FIT_SCREEN_DEFAULT_LIMIT)
387
+ scaffolding = 2 + (3 * others.length) + 2
388
+ budget = max - scaffolding - others.sum { |c| ScreenPaint.display_columns(c.to_s) }
389
+ return "" if budget <= 0
390
+ truncate_on_word_boundary(title, budget)
391
+ end
392
+
393
+ def self.frontmatter(intent_dir)
394
+ text = intent_text(intent_dir)
395
+ return {} unless text && text.start_with?("---")
396
+ parts = text.split("---", 3)
397
+ return {} if parts.length < 3
398
+ require "yaml"
399
+ require "date"
400
+ YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
401
+ rescue StandardError
402
+ {}
403
+ end
404
+
405
+ def self.research_intent?(intent_dir)
406
+ Array(frontmatter(intent_dir)["tags"]).map(&:to_s).include?("research")
407
+ end
408
+
409
+ # Intent 330 (D12): the shared fence walker feeding split_by_headings AND
410
+ # table_rows. A line matching \A\s{0,3}(```+|~~~+) while closed opens a
411
+ # fence and remembers the marker character and its length; while open, a
412
+ # line whose marker is the SAME character and at least as long, with only
413
+ # whitespace after it, closes the fence. Inside a fence every line is body
414
+ # - a leading "#" or a leading "|" included. A four-space-indented block is
415
+ # deliberately never a fence (the cap is 0-3 leading whitespace chars),
416
+ # which is the CommonMark indented-code case, out of scope on purpose
417
+ # (D12's stated limit). Yields [line, fenced] for every line, in order.
418
+ FENCE_LINE_RE = /\A\s{0,3}(`{3,}|~{3,})/.freeze
419
+
420
+ def self.each_fence_line(text)
421
+ return enum_for(:each_fence_line, text) unless block_given?
422
+
423
+ marker = nil # [character, length] of the currently open fence, or nil
424
+ text.to_s.each_line do |line|
425
+ if marker
426
+ yield line, true
427
+ m = line.match(FENCE_LINE_RE)
428
+ next unless m && m[1][0] == marker[0] && m[1].length >= marker[1]
429
+ next unless line.sub(FENCE_LINE_RE, "").strip.empty?
430
+
431
+ marker = nil
432
+ else
433
+ m = line.match(FENCE_LINE_RE)
434
+ if m
435
+ marker = [m[1][0], m[1].length]
436
+ yield line, true
437
+ else
438
+ yield line, false
439
+ end
440
+ end
441
+ end
442
+ end
443
+
444
+ # Markdown pipe-table data rows (header + separator skipped), each an array
445
+ # of trimmed cell strings. Tolerates leading prose before the table. Fence-
446
+ # aware (D12/O1.7): a pipe row inside a fenced example is never counted.
447
+ def self.table_rows(text)
448
+ lines = []
449
+ each_fence_line(text) do |line, fenced|
450
+ next if fenced
451
+
452
+ stripped = line.strip
453
+ lines << stripped if stripped.start_with?("|")
454
+ end
455
+ sep_idx = lines.index { |l| l.match?(/\A\|[\s:|-]+\|?\z/) }
456
+ return [] unless sep_idx
457
+ lines[(sep_idx + 1)..].map { |l| l.split("|", -1).map(&:strip)[1..-2].to_a }
458
+ end
459
+
460
+ # Every [heading_line, body] pair in a Markdown file, split on ANY heading
461
+ # line (any level). Used by proven_by (D19) so a section's own matrix rows
462
+ # are never confused with a sibling section's. Fence-aware (D12): a "#"
463
+ # line inside a fenced example never starts a new section.
464
+ def self.split_by_headings(text)
465
+ sections = []
466
+ heading = nil
467
+ body = +""
468
+ each_fence_line(text) do |line, fenced|
469
+ if !fenced && line.start_with?("#")
470
+ sections << [heading, body] if heading
471
+ heading = line.strip
472
+ body = +""
473
+ else
474
+ body << line
475
+ end
476
+ end
477
+ sections << [heading, body] if heading
478
+ sections
479
+ end
480
+
481
+ def self.savepoint_lines(intent_dir)
482
+ path = File.join(intent_dir, "savepoint.md")
483
+ return [] unless File.exist?(path)
484
+ File.readlines(path).map(&:strip).reject(&:empty?).filter_map do |line|
485
+ m = line.match(IntentScreen::SAVEPOINT_RE)
486
+ m ? [m[1], m[2], m[3]] : nil
487
+ end
488
+ end
489
+
490
+ def self.human_time(ts)
491
+ IntentScreen.human_time(ts)
492
+ end
493
+
494
+ def self.title_for(intent_dir, store_root)
495
+ text = intent_text(intent_dir)
496
+ return "not recorded" unless text
497
+ if store_root
498
+ _status, title = IntentScreen.index_fields(store_root, intent_id(intent_dir))
499
+ return title if title
500
+ end
501
+ IntentScreen.fallback_name(text)
502
+ end
503
+
504
+ def self.default_store_root(intent_dir)
505
+ File.expand_path("../..", intent_dir)
506
+ end
507
+
508
+ # --- S3: record readers -------------------------------------------------------
509
+
510
+ # Row 21: the ## Intent section body only, never the frontmatter `intent:` line.
511
+ def self.asked(intent_dir)
512
+ text = intent_text(intent_dir)
513
+ return NOT_RECORDED unless text
514
+ body = section_of(text, "## Intent").strip
515
+ body.empty? ? NOT_RECORDED : body
516
+ end
517
+
518
+ PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
519
+
520
+ # 317a S3 (A6): the note under Asked. Bulleted decisions in a real spec keep
521
+ # the historic "N decisions in spec.md"; a prose ## Decisions falls back to
522
+ # the highest D<n> it names; a placeholder spec falls through to the intent
523
+ # record's "### Decisions" (which section_of's "^## " anchor cannot reach);
524
+ # nothing anywhere says "decisions not recorded" - never a false 0, and the
525
+ # scaffold's "- ..." never counts as 1.
526
+ def self.decision_note(intent_dir)
527
+ spec = spec_text(intent_dir)
528
+ if spec && !spec.lstrip.start_with?(PLACEHOLDER_SENTINEL)
529
+ n = decisions_in(section_of(spec, "## Decisions"))
530
+ return "#{n} decisions in spec.md" if n.positive?
531
+ end
532
+ n = decisions_in(intent_text(intent_dir).to_s.split(/^### Decisions\s*$/, 2)[1].to_s.split(/^#+ /, 2)[0])
533
+ return "#{n} decisions in the intent record" if n.positive?
534
+ "decisions not recorded"
535
+ end
536
+
537
+ def self.decisions_in(body)
538
+ bullets = body.to_s.lines.count { |l| s = l.lstrip; s.start_with?("- ") && s.strip != "- ..." }
539
+ return bullets if bullets.positive?
540
+ body.to_s.scan(/\bD(\d{1,3})\b/).flatten.map(&:to_i).max.to_i
541
+ end
542
+
543
+ # Row 22: bullets under spec.md's ## Decisions only.
544
+ def self.decision_count(intent_dir)
545
+ text = spec_text(intent_dir)
546
+ return NOT_RECORDED unless text
547
+ section_of(text, "## Decisions").lines.count { |l| l.lstrip.start_with?("- ") }
548
+ end
549
+
550
+ # Rows 23/24: outcome.md's ## Delivered, table or bullet form.
551
+ def self.delivered_rows(intent_dir)
552
+ text = outcome_text(intent_dir)
553
+ return [] unless text
554
+ section = section_of(text, "## Delivered")
555
+ return [] if section.strip.empty?
556
+
557
+ rows = table_rows(section)
558
+ return rows.map { |cells| { label: cells[0].to_s, text: cells[1].to_s } } if rows.any?
559
+
560
+ bullet_rows(section).each_with_index.map do |text, i|
561
+ { label: (i + 1).to_s, text: text }
562
+ end
563
+ end
564
+
565
+ # 317a S1 (matrix S1a/S1b): a bullet row is its "- " line PLUS its wrapped
566
+ # continuation lines - outcome prose is hand-wrapped at ~100 columns, and
567
+ # taking one physical line truncated every real record mid-sentence. A blank
568
+ # line or a heading ends the row; prose after a blank is never swept in.
569
+ def self.bullet_rows(section)
570
+ rows = []
571
+ section.to_s.each_line do |line|
572
+ stripped = line.strip
573
+ if line.lstrip.start_with?("- ")
574
+ rows << line.lstrip.sub(/\A-\s*/, "").strip
575
+ elsif stripped.empty? || line.start_with?("#")
576
+ rows << nil unless rows.empty? || rows.last.nil?
577
+ elsif !rows.empty? && !rows.last.nil?
578
+ rows[rows.length - 1] = "#{rows.last} #{stripped}"
579
+ end
580
+ end
581
+ rows.compact
582
+ end
583
+
584
+ # Intent 331b (plan.md, "The one non-additive edit"): the standalone-token
585
+ # rule, extracted so `action_file_for` (the plan screen's Action column)
586
+ # calls the exact same rule as `matching_action_heading` and the two can
587
+ # never drift on what counts as a match.
588
+ def self.heading_tokens(heading)
589
+ heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/)
590
+ end
591
+
592
+ # Rows 25-27: D19/D1r - the label must appear as a standalone token in an
593
+ # action file heading (any level), AND that heading must own at least one
594
+ # matrix data row - a heading that only names the label, with no table
595
+ # beneath it (or a table with a separator but no data row), is skipped and
596
+ # the walk keeps going. Lexicographic path order (D8), then file order.
597
+ #
598
+ # Merge note (322 into alpha, 2026-09-05): 322's table-owning rule and 331b's
599
+ # extracted `heading_tokens` are both kept. The token split now comes from the
600
+ # shared helper so `action_file_for` cannot drift from this walk, while the
601
+ # `table_rows(body).any?` guard stays the thing that decides the match.
602
+ # Intent 334 (G1, D10r/D15r): the ordered list of files a "how was this
603
+ # proven" reader walks - actions/*.md first (the common path today), then
604
+ # nodes/*.md, lexicographic WITHIN each directory rather than across both, so
605
+ # an intent carrying both (a G9 backfill in progress) resolves the same label
606
+ # to whichever actions/ file already proves it, never to glob order.
607
+ def self.action_and_node_paths(intent_dir)
608
+ Dir.glob(File.join(intent_dir, "actions", "*.md")).sort +
609
+ Dir.glob(File.join(intent_dir, "nodes", "*.md")).sort
610
+ end
611
+
612
+ def self.matching_action_heading(intent_dir, label)
613
+ action_and_node_paths(intent_dir).each do |path|
614
+ split_by_headings(File.read(path)).each do |heading, body|
615
+ next unless heading_tokens(heading).include?(label)
616
+ return [heading, body] if table_rows(body).any?
617
+ end
618
+ end
619
+ [nil, nil]
620
+ end
621
+
622
+ # D3r: the row-cell fallback, for the shape where the label never appears in
623
+ # a heading at all, only as the first cell of a matrix data row. Restricted
624
+ # to tables under a heading that names itself a matrix (/matrix/i) - never a
625
+ # step list or any other table - so it cannot answer for a record that has
626
+ # no matrix anywhere (the close-gate defeat the plan review measured).
627
+ # Emphasis (bold/italic/code) is stripped from the compared cell; the count
628
+ # sums matching rows within one directory, then stops at the first
629
+ # directory that yields a non-zero count (post-execution review,
630
+ # non-blocking 6) - actions/ before nodes/, mirroring the heading walk's
631
+ # first-hit rule, so an intent whose nodes/ files restate ACTION_1's own
632
+ # matrix under the same label is never double-counted.
633
+ def self.matching_matrix_rows(intent_dir, label)
634
+ [Dir.glob(File.join(intent_dir, "actions", "*.md")).sort,
635
+ Dir.glob(File.join(intent_dir, "nodes", "*.md")).sort].each do |paths|
636
+ count = 0
637
+ paths.each do |path|
638
+ split_by_headings(File.read(path)).each do |heading, body|
639
+ next unless heading.to_s.match?(/matrix/i)
640
+ table_rows(body).each do |cells|
641
+ cell = cells[0].to_s.gsub(/[*_`]/, "").strip
642
+ count += 1 if cell == label
643
+ end
644
+ end
645
+ end
646
+ return count if count.positive?
647
+ end
648
+ 0
649
+ end
650
+
651
+ # 339 S9 (D17): a verify node owns `## Criteria`, never a matrix, so its
652
+ # Proven-by is how many criteria its node file names - not the
653
+ # absent-source phrase `matching_action_heading`/`matching_matrix_rows`
654
+ # falls through to for a label with no matrix anywhere. The kind comes from
655
+ # the node file's own envelope, `NodeFile.parse`, never guessed from the
656
+ # label's prefix and never by sniffing a body for a criteria-shaped list
657
+ # (row 9.4): a work node whose body happens to carry a bulleted "##
658
+ # Criteria" section must never borrow this path. Returns nil (not 0) when
659
+ # `label` is not a verify node at all, so `proven_by` can tell "not a
660
+ # verify node" apart from "a verify node with zero criteria".
661
+ def self.verify_node_criteria_count(intent_dir, label)
662
+ path = Dir.glob(File.join(intent_dir, "nodes", "#{label}.md")).first ||
663
+ Dir.glob(File.join(intent_dir, "nodes", "#{label}--*.md")).sort.first
664
+ return nil unless path
665
+
666
+ parsed = NodeFile.parse(path)
667
+ return nil unless parsed[:ok] && parsed[:kind] == "verify"
668
+
669
+ NodeFile.split_by_headings(parsed[:body].to_s).each do |heading, body|
670
+ next unless heading.to_s.sub(/\A#+\s*/, "").strip == "Criteria"
671
+
672
+ return body.each_line.count { |line| line.strip.start_with?("-") }
673
+ end
674
+ nil
675
+ end
676
+
677
+ # D7: a label with no letter never resolves, on either path - it is a
678
+ # bullet-derived Delivered number (delivered_rows), never a label anyone
679
+ # wrote, and would otherwise fabricate proof from a numbered heading like
680
+ # "## 1. What this intent is" or from a numbered matrix row-cell column.
681
+ def self.proven_by(intent_dir, label)
682
+ return NOT_RECORDED unless label.to_s.match?(/[A-Za-z]/)
683
+
684
+ criteria_count = verify_node_criteria_count(intent_dir, label)
685
+ unless criteria_count.nil?
686
+ return criteria_count.positive? ? "#{criteria_count} criteri#{criteria_count == 1 ? 'on' : 'a'}" : NOT_RECORDED
687
+ end
688
+
689
+ _heading, body = matching_action_heading(intent_dir, label)
690
+ if body
691
+ n = table_rows(body).length
692
+ return n.positive? ? "#{n} test#{n == 1 ? '' : 's'}" : NOT_RECORDED
693
+ end
694
+
695
+ n = matching_matrix_rows(intent_dir, label)
696
+ n.positive? ? "#{n} test#{n == 1 ? '' : 's'}" : NOT_RECORDED
697
+ end
698
+
699
+ # Row 34: outcome.md's ## Needs you, our own N1..NN numbering (never the
700
+ # table's own N column, which could be malformed).
701
+ def self.needs_you_rows(intent_dir)
702
+ text = outcome_text(intent_dir)
703
+ return [] unless text
704
+ return [] unless text.include?("## Needs you")
705
+ section = section_of(text, "## Needs you")
706
+ rows = table_rows(section)
707
+ if rows.any?
708
+ return rows.each_with_index.map do |cells, i|
709
+ { n: "N#{i + 1}", what: cells[1].to_s, why: cells[2].to_s }
710
+ end
711
+ end
712
+
713
+ # 317a S2 (matrix S2a): prose that exists must never render as None - the
714
+ # 317 record hid three owner picks behind exactly that. One joined row,
715
+ # why "not recorded"; a literal None (or an empty section) stays [].
716
+ content = section.gsub(/<!--.*?-->/m, "").strip
717
+ return [] if content.empty? || content == "None"
718
+
719
+ what = content.lines.map(&:strip).reject(&:empty?)
720
+ .join(" ").sub(/\A-\s*/, "").squeeze(" ")
721
+ [{ n: "N1", what: what, why: NOT_RECORDED }]
722
+ end
723
+
724
+ # Row 35: first-to-last savepoint timestamp, "1 h 51 min" / "n min".
725
+ def self.duration(intent_dir)
726
+ lines = savepoint_lines(intent_dir)
727
+ return NOT_RECORDED if lines.length < 2
728
+ secs = (Time.parse(lines.last[0]) - Time.parse(lines.first[0])).to_i
729
+ format_duration(secs)
730
+ end
731
+
732
+ def self.format_duration(secs)
733
+ mins = [secs, 0].max / 60
734
+ return "#{mins} min" if mins < 60
735
+ "#{mins / 60} h #{mins % 60} min"
736
+ end
737
+
738
+ # Row 36 (D20): mode from the LIVE delivery lock's run_mode; absent -> not recorded.
739
+ def self.mode(intent_dir)
740
+ data = Lock.read(intent_dir)
741
+ value = data && data["run_mode"]
742
+ return value.to_s if value && !value.to_s.empty?
743
+
744
+ # 317a S7 (D5): after the close the lock is gone; end-intent stamps the
745
+ # run_mode into outcome.md frontmatter, so mode stops being unknowable
746
+ # retrospectively. Live lock first - it is the source of truth mid-flight.
747
+ value = outcome_frontmatter(intent_dir)["mode"]
748
+ value && !value.to_s.empty? ? value.to_s : NOT_RECORDED
749
+ end
750
+
751
+ def self.outcome_frontmatter(intent_dir)
752
+ text = outcome_text(intent_dir)
753
+ return {} unless text && text.start_with?("---")
754
+ parts = text.split("---", 3)
755
+ return {} if parts.length < 3
756
+ require "yaml"
757
+ require "date"
758
+ YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
759
+ rescue StandardError
760
+ {}
761
+ end
762
+
763
+ # --- evidence rows (rows 28-33, 37) --------------------------------------------
764
+
765
+ def self.suite_row(section)
766
+ m = section.match(/([\d,]+)\s*runs,\s*([\d,]+)\s*assertions,\s*([\d,]+)\s*failures/)
767
+ return nil unless m
768
+ { kind: "suite", what: "#{m[1]} runs · #{m[2]} assertions · #{m[3]} failures", source: "outcome.md ## Verification" }
769
+ end
770
+
771
+ def self.red_row(section)
772
+ line = section.lines.find { |l| l =~ /\bred\b/i && l =~ /`([0-9a-f]{7,40})`/ }
773
+ return nil unless line
774
+ sha = line.match(/`([0-9a-f]{7,40})`/)[1]
775
+ { kind: "red", what: "#{sha} proven test-only and red", source: "outcome.md ## Verification" }
776
+ end
777
+
778
+ # Fix 2026-09-01: the record is the truth of delivery (D14: never a guess).
779
+ # The shipped version comes from outcome.md's own ship line first ("Shipped
780
+ # as `v2.0.0-alpha.10`", "released as **v2.0.0-alpha.5**", "released
781
+ # v2.0.0-alpha.9", "Tagged v1.14.1", "Delivered in", "Release v"); the injected tag reader (git) is the
782
+ # fallback when the record is silent. A bare version with no ship verb
783
+ # ("from 1.14.1") is not a shipped version.
784
+ SHIP_VERSION_RE = /\b(?:shipped|released?|delivered|tagged)\b(?:\s+(?:as|in))?[\s`*]*v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?)/i.freeze
785
+
786
+ def self.shipped_version(intent_dir)
787
+ text = outcome_text(intent_dir)
788
+ return nil unless text
789
+ m = text.match(SHIP_VERSION_RE)
790
+ m && m[1]
791
+ end
792
+
793
+ # The merge commit named on outcome.md's merge line, or nil. The CLI's tag
794
+ # reader asks git which tag contains it; the ship row prints it.
795
+ def self.merge_sha(intent_dir)
796
+ text = outcome_text(intent_dir)
797
+ return nil unless text
798
+ line = text.lines.find { |l| l =~ /\bmerge(d)?\b/i && l =~ /\b[0-9a-f]{7,40}\b/ }
799
+ line && line.match(/\b([0-9a-f]{7,40})\b/)[1]
800
+ end
801
+
802
+ # Intent 330 (D9): reads `flow: base:` from a project's project.yml when
803
+ # `intent_dir` sits in the installed project layout
804
+ # (<home>/projects/<slug>/store/<id--slug>); nil otherwise (a global-store
805
+ # intent, a project with no `flow:` key, or malformed YAML). Pure: no git,
806
+ # no shell-out, just the one file this intent's own layout already reads.
807
+ PROJECT_LAYOUT_RE = %r{\A(.*)/projects/([^/]+)/store/[^/]+\z}.freeze
808
+
809
+ def self.flow_base(intent_dir)
810
+ m = intent_dir.to_s.match(PROJECT_LAYOUT_RE)
811
+ return nil unless m
812
+
813
+ home, slug = m[1], m[2]
814
+ path = File.join(home, "projects", slug, "project.yml")
815
+ return nil unless File.exist?(path)
816
+
817
+ require "yaml"
818
+ data = YAML.safe_load(File.read(path))
819
+ return nil unless data.is_a?(Hash)
820
+
821
+ flow = data["flow"]
822
+ return nil unless flow.is_a?(Hash)
823
+
824
+ base = flow["base"]
825
+ base.is_a?(String) && !base.empty? ? base : nil
826
+ rescue StandardError
827
+ nil
828
+ end
829
+
830
+ # Intent 330 (D9/D10/D23): the ship row's WHAT cell is the merge sha, then
831
+ # " → <branch>" only when `branch_reader` answers one (never the "alpha"
832
+ # literal), then " · v<version>" or the existing not-recorded fallback. The
833
+ # Source cell names WHERE the branch came from (D23): project.yml when
834
+ # flow_base itself supplied that exact branch, else git refs, so the row
835
+ # never keeps the stale "git tags" literal for a branch git never answered.
836
+ def self.ship_row(_text, intent_dir, tag_reader, branch_reader: ->(_dir) { nil })
837
+ sha = merge_sha(intent_dir)
838
+ version = shipped_version(intent_dir) || tag_reader.call(intent_dir)
839
+ return nil if sha.nil? && (version.nil? || version.to_s.empty?)
840
+ branch = branch_reader.call(intent_dir)
841
+ sha_text = sha || NOT_RECORDED
842
+ what = +sha_text
843
+ what << " → #{branch}" if branch && !branch.to_s.empty?
844
+ # D10: the version segment is omitted, not filled with NOT_RECORDED. A
845
+ # repository with no release line has no version, the header already
846
+ # carries the shipped identity, and naming the absence twice on one screen
847
+ # is the defect this intent was opened to remove, not a floor worth keeping.
848
+ what << " · v#{version.to_s.sub(/\Av/, '')}" if version && !version.to_s.empty?
849
+ # D14: the cell names every file the row actually came from. The branch and
850
+ # the version have different origins, so when both contributed, both are
851
+ # named rather than only the branch's.
852
+ sources = ["outcome.md"]
853
+ if branch && !branch.to_s.empty?
854
+ sources << (flow_base(intent_dir) == branch ? "project.yml" : "git refs")
855
+ end
856
+ sources << "git tags" if version && !version.to_s.empty? && shipped_version(intent_dir).nil?
857
+ sources << "git tags" if sources.length == 1
858
+ source = sources.join("; ")
859
+ { kind: "ship", what: what, source: source }
860
+ end
861
+
862
+ def self.doctor_row(text)
863
+ m = text.to_s.match(/(\d+)\s*pass,?\s*(\d+)\s*warn,?\s*(\d+)\s*fail/i)
864
+ return nil unless m
865
+ { kind: "doctor", what: "#{m[1]} pass · #{m[2]} warn · #{m[3]} fail", source: "outcome.md" }
866
+ end
867
+
868
+ def self.deviates_row(section)
869
+ line = section.lines.find { |l| l.lstrip.sub(/\A-\s*/, "").start_with?("Deviation:") }
870
+ return nil unless line
871
+ text = line.lstrip.sub(/\A-\s*/, "").strip
872
+ { kind: "deviates", what: text, source: "outcome.md ## Verification — Deviation:" }
873
+ end
874
+
875
+ def self.deposits_row(text)
876
+ line = text.to_s.lines.find { |l| l =~ %r{`resources/[^`]+`} }
877
+ return nil unless line
878
+ path = line.match(%r{`(resources/[^`]+)`})[1]
879
+ { kind: "deposits", what: path, source: "outcome.md" }
880
+ end
881
+
882
+ def self.verdict_row(text)
883
+ m = text.to_s.match(/verdict[:\s]+([A-Za-z][A-Za-z ]*)/i)
884
+ return nil unless m
885
+ { kind: "verdict", what: m[1].strip, source: "outcome.md" }
886
+ end
887
+
888
+ def self.evidence_rows(intent_dir, tag_reader: ->(_dir) { nil }, branch_reader: ->(_dir) { nil })
889
+ text = outcome_text(intent_dir)
890
+ return [] unless text
891
+ verification = section_of(text, "## Verification")
892
+
893
+ rows = []
894
+ rows << suite_row(verification)
895
+ rows << red_row(verification)
896
+ rows << (research_intent?(intent_dir) ? nil : ship_row(text, intent_dir, tag_reader, branch_reader: branch_reader))
897
+ if research_intent?(intent_dir)
898
+ rows << deposits_row(text)
899
+ rows << verdict_row(text)
900
+ end
901
+ rows << doctor_row(text)
902
+ rows << deviates_row(verification)
903
+ rows.compact
904
+ end
905
+
906
+ # --- S4/S5: the state verb and the --all roster --------------------------------
907
+
908
+ CHANGED_NOTE = "the reason this screen printed"
909
+
910
+ def self.state_fields(intent_dir:, store_root:, changed:)
911
+ base = intent_basename(intent_dir)
912
+ id = base.split("--", 2).first
913
+ text = intent_text(intent_dir)
914
+ status, title = IntentScreen.index_fields(store_root, id)
915
+ name = title || IntentScreen.fallback_name(text.to_s)
916
+
917
+ f = {}
918
+ f.merge!(IntentScreen.store_fields(store_root))
919
+ f["status"] = status
920
+ f["status.note"] = status == "unlisted" ? "no INDEX.md line names this id" : "listed under ## #{status} in INDEX.md"
921
+ f.merge!(IntentScreen.savepoint_fields(intent_dir, text.to_s))
922
+ items = IntentScreen.checklist_items(intent_dir)
923
+ f.merge!(IntentScreen.progress_fields(items))
924
+ f.merge!(IntentScreen.next_fields(items, status, checklist_present: IntentScreen.items_present?(intent_dir)))
925
+ f.merge!(IntentScreen.insight_fields(text.to_s))
926
+
927
+ changed_value = changed && !changed.to_s.empty? ? changed.to_s : "on request"
928
+
929
+ rows = [
930
+ ["Store", f["store"], f["store.note"]],
931
+ ["Status", f["status"], f["status.note"]],
932
+ ["Stage", f["stage"], f["stage.note"]],
933
+ ["Savepoint", f["savepoint"], f["savepoint.note"]],
934
+ ["Progress", "#{f['progress.bar']} #{f['progress.done']} / #{f['progress.total']}", f["progress.note"]],
935
+ ["Next", f["next"], f["next.note"]],
936
+ ["Insight", f["insight"], f["insight.note"]],
937
+ ["Changed", changed_value, CHANGED_NOTE],
938
+ ]
939
+ { id: id, name: name, rows: rows, items: items }
940
+ end
941
+
942
+ # Rows 42-46: pad BOTH columns to the widest NOTED label/value, computed on
943
+ # the raw emitted (already-escaped) cell text; unnoted rows carry no padding.
944
+ def self.state_rows(rows)
945
+ escaped = rows.map { |label, value, note| ["**#{label}**", escape(value), escape(note)] }
946
+ noted = escaped.select { |_, _, note| !note.to_s.empty? }
947
+ label_w = noted.map { |l, _, _| l.length }.max || 0
948
+ value_w = noted.map { |_, v, _| v.length }.max || 0
949
+ escaped.map do |label, value, note|
950
+ if note.to_s.empty?
951
+ "| #{label} | #{value} | |"
952
+ else
953
+ "| #{label.ljust(label_w)} | #{value.ljust(value_w)} | #{note} |"
954
+ end
955
+ end
956
+ end
957
+
958
+ def self.render_state(intent_dir:, store_root:, changed:, template:)
959
+ data = state_fields(intent_dir: intent_dir, store_root: store_root, changed: changed)
960
+ out = template.dup
961
+ out = out.gsub("{{id}}", data[:id])
962
+ out = out.gsub("{{name}}", data[:name])
963
+ out = out.gsub("{{fields.rows}}", state_rows(data[:rows]).join("\n"))
964
+ out = out.gsub("{{steps.rows}}", IntentScreen.steps_rows(data[:items]))
965
+ fit_screen(out.gsub(/\n{3,}/, "\n\n"))
966
+ end
967
+
968
+ # --- roster (D7/D8) -------------------------------------------------------------
969
+
970
+ # The dirnames named under one "## <section_name>" heading of an INDEX.md.
971
+ # active_dirnames used to hardcode "Active"; intent 330's session verb (D22)
972
+ # reuses this to find Completed/Abandoned dirnames for the no-bookend
973
+ # footer, so the section is now a parameter.
974
+ def self.dirnames_in_section(index_path, section_name)
975
+ return [] unless File.exist?(index_path)
976
+ dirnames = []
977
+ section = nil
978
+ File.foreach(index_path) do |line|
979
+ if line.start_with?("## ")
980
+ section = line[3..].strip
981
+ next
982
+ end
983
+ next unless section == section_name
984
+ m = line.match(%r{\(store/([^/]+)/})
985
+ dirnames << m[1] if m
986
+ end
987
+ dirnames
988
+ end
989
+
990
+ def self.active_dirnames(index_path)
991
+ dirnames_in_section(index_path, "Active")
992
+ end
993
+
994
+ # Intent 330 (D22): both terminal sections count as "completed" for the
995
+ # no-bookend footer - a closed intent the reader cannot expect a Done
996
+ # savepoint line from, since the convention predates end-intent writing it.
997
+ def self.completed_dirnames(index_path)
998
+ dirnames_in_section(index_path, "Completed") + dirnames_in_section(index_path, "Abandoned")
999
+ end
1000
+
1001
+ def self.newest_savepoint_ts(intent_dir)
1002
+ lines = savepoint_lines(intent_dir)
1003
+ lines.last&.first
1004
+ end
1005
+
1006
+ # report-screen archive <store_root> (intent 339, G6, n6, spec D9): a
1007
+ # read-only VIEW of a store's terminal intents - the reading half of intent
1008
+ # 132, declining the other three halves (moving directories, path
1009
+ # resolution, doctor checks). Lists `## Completed` and `## Abandoned` only
1010
+ # (row 6.1, the same set `completed_dirnames` above already gathers - row
1011
+ # v1f.10 deleted the byte-for-byte duplicate that used to live here); each
1012
+ # row's disposition comes from outcome.md's own frontmatter (row 6.2),
1013
+ # never guessed from which INDEX section the dirname was found in, so a
1014
+ # terminal intent with no outcome.md renders the absent-source phrase
1015
+ # instead of a fabricated disposition (row 6.3). Reads only - moves
1016
+ # nothing (row 6.4).
1017
+ def self.render_archive(store_root)
1018
+ index_path = File.join(store_root, "INDEX.md")
1019
+ lines = ["# Archive: #{File.basename(store_root)}", "", "| Intent | Disposition |", "| --- | --- |"]
1020
+ completed_dirnames(index_path).each do |dirname|
1021
+ dir = File.join(store_root, "store", dirname)
1022
+ disposition = outcome_frontmatter(dir)["disposition"]
1023
+ disposition = NOT_RECORDED if disposition.nil? || disposition.to_s.empty?
1024
+ lines << "| #{escape(dirname)} | #{escape(disposition.to_s)} |"
1025
+ end
1026
+ fit_screen("#{lines.join("\n")}\n")
1027
+ end
1028
+
1029
+ def self.roster(store_root)
1030
+ index_path = File.join(store_root, "INDEX.md")
1031
+ entries = active_dirnames(index_path).filter_map do |dirname|
1032
+ dir = File.join(store_root, "store", dirname)
1033
+ next unless File.directory?(dir)
1034
+ text = intent_text(dir).to_s
1035
+ fields = IntentScreen.savepoint_fields(dir, text)
1036
+ next if fields["stage"] == "Done"
1037
+ { dir: dir, id: dirname.split("--", 2).first, ts: newest_savepoint_ts(dir) }
1038
+ end
1039
+ entries.sort_by { |e| [-(e[:ts] ? Time.parse(e[:ts]).to_i : 0), e[:id]] }
1040
+ end
1041
+
1042
+ # D6/R5, intent 331f: one freshness rule for every Lead cell, on the SAME primitive
1043
+ # (Lock.who) every call site now shares - a fresh lock prints "agent · key" (this file's
1044
+ # own long-standing format), an older lock prints "stale · N min", never idle; no lock, or
1045
+ # one that will not read, prints "idle". Lock.who is called ONCE: it already returns the
1046
+ # heartbeat timestamp alongside the state, so nothing stats the lock file a second time.
1047
+ def self.lead_cell(intent_dir, now: Time.now)
1048
+ data = Lock.who(intent_dir, now: now)
1049
+ case data["state"]
1050
+ when "fresh"
1051
+ owner = data["owner"] || {}
1052
+ agent = owner["agent"].to_s
1053
+ agent = "unknown" if agent.empty? || agent == "unknown"
1054
+ session = data["owner_session"].to_s
1055
+ "#{agent} · #{session[0, 8]}"
1056
+ when "stale"
1057
+ mins = [((now - Time.parse(data["heartbeat_at"])) / 60).to_i, 0].max
1058
+ "stale · #{mins} min"
1059
+ else
1060
+ "idle"
1061
+ end
1062
+ rescue StandardError
1063
+ "idle"
1064
+ end
1065
+
1066
+ # The roster's own call site (unchanged name/signature at the call sites below); `now:`
1067
+ # defaults so a caller that never passed a clock keeps working exactly as before.
1068
+ def self.lead(intent_dir, now: Time.now)
1069
+ lead_cell(intent_dir, now: now)
1070
+ end
1071
+
1072
+ def self.collapsed_open_steps_note(count)
1073
+ count <= 3 ? "#{count} open" : "#{count} open · showing the first three"
1074
+ end
1075
+
1076
+ def self.render_collapsed_block(intent_dir, store_root, changed:)
1077
+ data = state_fields(intent_dir: intent_dir, store_root: store_root, changed: changed)
1078
+ stage = data[:rows].find { |l, _, _| l == "Stage" }[1]
1079
+ nxt = data[:rows].find { |l, _, _| l == "Next" }[1]
1080
+ ch = data[:rows].find { |l, _, _| l == "Changed" }[1]
1081
+
1082
+ open_items = data[:items].each_with_index.reject { |item, _| item[:done] }
1083
+ lines = []
1084
+ lines << "▶ #{data[:id]} · #{data[:name]}"
1085
+ lines << "Stage #{stage}"
1086
+ lines << "Next #{nxt}"
1087
+ lines << "Changed #{ch}"
1088
+ lines << collapsed_open_steps_note(open_items.length)
1089
+ open_items.first(3).each { |item, i| lines << "S#{i + 1} [ open ] #{escape(item[:text])}" }
1090
+ lines.join("\n")
1091
+ end
1092
+
1093
+ def self.render_roster(store_root, changed: nil, now: Time.now)
1094
+ entries = roster(store_root)
1095
+ return "No intents in delivery.\n" if entries.empty?
1096
+
1097
+ header = "▶ In delivery · #{entries.length} #{entries.length == 1 ? 'intent' : 'intents'} · " \
1098
+ "#{now.utc.strftime('%Y-%m-%d %H:%M UTC')}"
1099
+ table = ["| Graph ID | Stage | Progress | Changed | Lead |", "| --- | --- | --- | --- | --- |"]
1100
+ entries.each do |e|
1101
+ text = intent_text(e[:dir]).to_s
1102
+ savepoint = IntentScreen.savepoint_fields(e[:dir], text)
1103
+ items = IntentScreen.checklist_items(e[:dir])
1104
+ progress = IntentScreen.progress_fields(items)
1105
+ ch = state_fields(intent_dir: e[:dir], store_root: store_root, changed: changed)[:rows].find { |l, _, _| l == "Changed" }[1]
1106
+ table << "| #{e[:id]} | #{savepoint['stage']} | #{progress['progress.bar']} #{progress['progress.done']} / #{progress['progress.total']} | #{escape(ch)} | #{lead(e[:dir], now: now)} |"
1107
+ end
1108
+ blocks = entries.map { |e| render_collapsed_block(e[:dir], store_root, changed: changed) }
1109
+ head_and_table = ([header, ""] + table).join("\n")
1110
+ # Each collapsed block already has its own internal "\n"; a blank line
1111
+ # separates block from block (design--delivery-reports.html:137-152),
1112
+ # so they read as distinct entries instead of running together.
1113
+ fit_screen("#{head_and_table}\n\n#{blocks.join("\n\n")}\n")
1114
+ end
1115
+
1116
+ # --- S6: the delivered verb ------------------------------------------------------
1117
+
1118
+ def self.delivered_timestamp(intent_dir)
1119
+ lines = savepoint_lines(intent_dir)
1120
+ done = lines.reverse.find { |_ts, kind, _text| kind == "Done" }
1121
+ done ? human_time(done[0]) : NOT_RECORDED
1122
+ end
1123
+
1124
+ # Intent 330 (D11): the header's last segment is the shipped identity, and
1125
+ # says which kind it is - v<version> when a version is known, else
1126
+ # "merge <sha>" (never a bare, ambiguous hash), else the exact NOT_RECORDED
1127
+ # string when neither exists.
1128
+ def self.header_ship_segment(intent_dir, tag_reader)
1129
+ version = shipped_version(intent_dir) || tag_reader.call(intent_dir)
1130
+ return "v#{version.to_s.sub(/\Av/, '')}" if version && !version.to_s.empty?
1131
+
1132
+ sha = merge_sha(intent_dir)
1133
+ return "merge #{sha}" if sha && !sha.to_s.empty?
1134
+
1135
+ NOT_RECORDED
1136
+ end
1137
+
1138
+ def self.render_delivered(intent_dir:, tag_reader: ->(_dir) { nil }, branch_reader: ->(_dir) { nil })
1139
+ id = intent_id(intent_dir)
1140
+ name = title_for(intent_dir, default_store_root(intent_dir))
1141
+ ts = delivered_timestamp(intent_dir)
1142
+ m = mode(intent_dir)
1143
+ dur = duration(intent_dir)
1144
+ ship_segment = header_ship_segment(intent_dir, tag_reader)
1145
+
1146
+ lines = []
1147
+ lines << "## ✔ #{id} · #{name} · delivered"
1148
+ lines << "#{ts} · #{m} · #{dur} · #{ship_segment}"
1149
+ lines << ""
1150
+ lines << "**Asked**"
1151
+ lines << " #{asked(intent_dir)}"
1152
+ lines << " #{decision_note(intent_dir)}"
1153
+ lines << ""
1154
+ lines << "**Delivered**"
1155
+ lines << "| Row | Detail | Proven by |"
1156
+ lines << "| --- | --- | --- |"
1157
+ delivered_rows(intent_dir).each do |r|
1158
+ lines << "| #{r[:label]} | #{escape(r[:text])} | #{escape(proven_by(intent_dir, r[:label]))} |"
1159
+ end
1160
+ lines << ""
1161
+ lines << "**Evidence**"
1162
+ ev = evidence_rows(intent_dir, tag_reader: tag_reader, branch_reader: branch_reader)
1163
+ if ev.empty?
1164
+ # 317a S4 (matrix S4a): a header-only table (319's live rendering) says
1165
+ # nothing; the honest floor is the same phrase every other absent source
1166
+ # prints.
1167
+ lines << NOT_RECORDED
1168
+ else
1169
+ lines << "| Kind | Detail | Source |"
1170
+ lines << "| --- | --- | --- |"
1171
+ ev.each do |r|
1172
+ lines << "| #{r[:kind]} | #{escape(r[:what])} | #{escape(r[:source])} |"
1173
+ end
1174
+ end
1175
+ lines << ""
1176
+ needsyou = needs_you_rows(intent_dir)
1177
+ lines << "**Needs you**"
1178
+ if needsyou.empty?
1179
+ lines << "None"
1180
+ else
1181
+ lines << "| N | Need | Reason |"
1182
+ lines << "| --- | --- | --- |"
1183
+ needsyou.each { |r| lines << "| #{r[:n]} | #{escape(r[:what])} | #{escape(r[:why])} |" }
1184
+ end
1185
+ out = fit_screen("#{lines.join("\n")}\n")
1186
+
1187
+ # Intent 339 (G6, n5, spec D8): additive, and only for an intent that has
1188
+ # a graph.md - an intent with none renders exactly the bytes it renders
1189
+ # today (row 5.2's frozen golden). Node state and titles come from the
1190
+ # LEDGER via OutcomeReport.model, never from outcome.md (row 5.6): a
1191
+ # stale hand-edited outcome must never be read as truth here.
1192
+ return out unless File.exist?(File.join(intent_dir, "graph.md"))
1193
+
1194
+ out + fit_screen(render_nodes_block(intent_dir))
1195
+ end
1196
+
1197
+ # Row 5.1/5.4/5.5: a "### Nodes" table (id, kind, ledger state, "(stale)"
1198
+ # when C13 applies) plus a "### Findings" bullet block when the record
1199
+ # carries any (D7). Neither heading is the `**Bold**` shape the delivered
1200
+ # screen's own block scan (`/^\*\*(.+?)\*\*/`) reads, so this block can
1201
+ # never widen that pinned header list (row 5.3).
1202
+ def self.render_nodes_block(intent_dir)
1203
+ model = OutcomeReport.model(intent_dir)
1204
+ stale = OutcomeReport.stale_nodes(entries: model[:entries] || [], edges: model[:edges] || {})
1205
+
1206
+ lines = ["", "### Nodes", "| Node | Kind | State |", "| --- | --- | --- |"]
1207
+ OutcomeReport.sort_ids(model[:nodes].keys).each do |id|
1208
+ n = model[:nodes][id]
1209
+ state = n[:state].to_s
1210
+ state = "#{state} (stale)" if stale.include?(id)
1211
+ lines << "| #{id} | #{escape(n[:kind].to_s)} | #{escape(state)} |"
1212
+ end
1213
+
1214
+ findings = OutcomeReport.findings(intent_dir)
1215
+ unless findings.empty?
1216
+ lines << ""
1217
+ lines << "### Findings"
1218
+ findings.each { |f| lines << "- #{escape(f)}" }
1219
+ end
1220
+
1221
+ "#{lines.join("\n")}\n"
1222
+ end
1223
+
1224
+ # --- S7: the delay verb -----------------------------------------------------------
1225
+
1226
+ def self.delay_timeline(intent_dir)
1227
+ savepoint_lines(intent_dir).map { |ts, kind, text| { ts: ts, kind: kind, text: text } }
1228
+ end
1229
+
1230
+ def self.longest_gap(timeline)
1231
+ return nil if timeline.length < 2
1232
+ best = nil
1233
+ timeline.each_cons(2) do |a, b|
1234
+ secs = (Time.parse(b[:ts]) - Time.parse(a[:ts])).to_i
1235
+ best = { secs: secs, a: a[:kind], b: b[:kind] } if best.nil? || secs > best[:secs]
1236
+ end
1237
+ "longest gap #{best[:secs] / 60} min, #{best[:a]} to #{best[:b]}"
1238
+ end
1239
+
1240
+ def self.where_time_went(timeline)
1241
+ gap = longest_gap(timeline)
1242
+
1243
+ unless timeline.any? { |r| %w[Review Commit].include?(r[:kind]) }
1244
+ parts = ["the review and commit ledger was not kept for this intent"]
1245
+ parts << gap if gap
1246
+ return parts.join(" · ")
1247
+ end
1248
+
1249
+ rounds = timeline.count { |r| r[:kind] == "Review" }
1250
+ commits = timeline.count { |r| r[:kind] == "Commit" }
1251
+ parts = []
1252
+ parts << "reviews #{rounds} round#{rounds == 1 ? '' : 's'}" if rounds.positive?
1253
+ parts << "#{commits} commit#{commits == 1 ? '' : 's'}" if commits.positive?
1254
+ parts << gap if gap
1255
+ parts.join(" · ")
1256
+ end
1257
+
1258
+ def self.hhmm(ts)
1259
+ m = ts.match(/T(\d\d:\d\d)/)
1260
+ m ? m[1] : ts
1261
+ end
1262
+
1263
+ def self.delay_outcome_line(intent_dir)
1264
+ text = outcome_text(intent_dir)
1265
+ return NOT_RECORDED unless text
1266
+ section = section_of(text, "## Summary")
1267
+ # The first PARAGRAPH, not just its first physical line - outcome.md's
1268
+ # prose is hand-wrapped at ~100 columns, so a single logical sentence
1269
+ # spans several source lines.
1270
+ paragraph = section.lstrip.split(/\n\s*\n/, 2).first.to_s.lines.map(&:strip).join(" ").strip
1271
+ return NOT_RECORDED if paragraph.empty?
1272
+ doc = doctor_row(text)
1273
+ doc ? "#{paragraph} · #{doc[:what]}" : paragraph
1274
+ end
1275
+
1276
+ def self.render_delay(intent_dir:)
1277
+ id = intent_id(intent_dir)
1278
+ name = title_for(intent_dir, default_store_root(intent_dir))
1279
+ dur = duration(intent_dir)
1280
+ timeline = delay_timeline(intent_dir)
1281
+
1282
+ lines = []
1283
+ lines << "✔ #{id} · #{name} · delivered in #{dur}"
1284
+ lines << ""
1285
+ timeline.each { |r| lines << "#{hhmm(r[:ts])} #{r[:kind]} #{escape(r[:text])}" }
1286
+ lines << ""
1287
+ lines << "**Where the time went** #{where_time_went(timeline)}"
1288
+ lines << ""
1289
+ lines << "**Outcome** #{delay_outcome_line(intent_dir)}"
1290
+ fit_screen("#{lines.join("\n")}\n")
1291
+ end
1292
+
1293
+ # --- the plan verb (intent 331b): the PRE-delivery report -----------------------
1294
+ #
1295
+ # `report-screen plan <intent_dir>` prints the plan the record already
1296
+ # carries, before Exec starts: Asked, the decisions count, the planned
1297
+ # steps with their action file, and risks. Every cell traces to a file
1298
+ # (D3/D14); a missing source prints "not recorded", the same floor every
1299
+ # other screen in the family uses, except Mode (D2): a missing lock prints
1300
+ # "not armed", never "not recorded" - there is nothing to fall back to
1301
+ # before Exec starts.
1302
+
1303
+ VERDICT_TOKENS = %w[PROCEED APPROVE PASS REVISE REWORK FAIL BLOCK].freeze
1304
+
1305
+ # spec.md F4: a checklist line's OWN declared label ("S6 Docs and...")
1306
+ # survives here; STEP_PREFIX_RE (IntentScreen's own stripping regex) is
1307
+ # reused for the strip, so the label this recognizes is exactly the prefix
1308
+ # IntentScreen.checklist_items strips - the two readers can never disagree
1309
+ # on where a label ends and the step text begins.
1310
+ # The separator class mirrors STEP_PREFIX_RE's own (hyphen, colon, middle
1311
+ # dot, em dash, en dash); the latter two are written as \u escapes rather
1312
+ # than the literal glyph so this line never trips the project's added-line
1313
+ # dash guard, which scans literal characters only - the compiled regex
1314
+ # matches identically either way.
1315
+ STEP_LABEL_RE = /\A(?:Step\s*|S)\s*(\d+)\s*(?:[-:·\u2014\u2013]\s*|\s+)(?=\S)/i.freeze
1316
+
1317
+ def self.asked_first_sentence(intent_dir)
1318
+ body = asked(intent_dir)
1319
+ return NOT_RECORDED if body == NOT_RECORDED
1320
+ collapsed = body.gsub(/\s+/, " ").strip
1321
+ head, rest = IntentScreen.clause_and_rest(collapsed)
1322
+ rest ? "#{head}…" : head
1323
+ end
1324
+
1325
+ # D5, intent 331f: the plan screen's own Asked row - the intent title before its first
1326
+ # colon (F21), never the whole `## Intent` body asked_first_sentence above reads. Most real
1327
+ # intent lines read "Short title: the elaborated ask...", so this is the short title; a body
1328
+ # with no colon at all (a short intent with no title/elaboration split) renders unchanged,
1329
+ # word-boundary truncated the same way every other title cell in the family is.
1330
+ def self.plan_asked_title(intent_dir)
1331
+ body = asked(intent_dir)
1332
+ return NOT_RECORDED if body == NOT_RECORDED
1333
+ title_before_colon(body.gsub(/\s+/, " ").strip)
1334
+ end
1335
+
1336
+ # spec.md F4: keeps checklist.md's own file order and each line's DECLARED
1337
+ # label, falling back to the positional S<n> only when a line declares
1338
+ # none - IntentScreen.checklist_items strips the label and renumbers
1339
+ # positionally, which is right for the state screen and wrong for the
1340
+ # Action lookup below.
1341
+ def self.plan_steps(intent_dir)
1342
+ return [] unless IntentScreen.items_present?(intent_dir)
1343
+
1344
+ raw = File.readlines(File.join(intent_dir, "checklist.md")).filter_map do |line|
1345
+ m = line.match(IntentScreen::ITEM_RE)
1346
+ next unless m
1347
+ text = m[2].strip
1348
+ next if text == "..."
1349
+ text
1350
+ end
1351
+
1352
+ raw.each_with_index.map do |text, i|
1353
+ m = text.match(STEP_LABEL_RE)
1354
+ label = m ? "S#{m[1]}" : "S#{i + 1}"
1355
+ { label: label, text: text.sub(IntentScreen::STEP_PREFIX_RE, "") }
1356
+ end
1357
+ end
1358
+
1359
+ # spec.md F3/F6a: the Action column names the file whose heading carries
1360
+ # the step's label AND whose section has a matrix table of its own - a
1361
+ # heading that resolves but proves nothing is the same hollow-close defect
1362
+ # `proven_by` already guards against, so it renders "not recorded" too.
1363
+ def self.action_file_for(intent_dir, label)
1364
+ action_and_node_paths(intent_dir).each do |path|
1365
+ split_by_headings(File.read(path)).each do |heading, body|
1366
+ next unless heading_tokens(heading).include?(label)
1367
+ return File.basename(path, ".md") if table_rows(body).any?
1368
+ end
1369
+ end
1370
+ NOT_RECORDED
1371
+ end
1372
+
1373
+ # D2: mode from the LIVE delivery lock only - unlike `mode` (row 36), a
1374
+ # missing lock never falls back to outcome.md's frontmatter (there is
1375
+ # nothing to fall back to before Exec starts) and never says the
1376
+ # delivered screen's "not recorded"; it says "not armed".
1377
+ def self.plan_mode(intent_dir)
1378
+ data = Lock.read(intent_dir)
1379
+ value = data && data["run_mode"]
1380
+ value && !value.to_s.empty? ? value.to_s : "not armed"
1381
+ end
1382
+
1383
+ # The last `Review` savepoint line whose text names a PLAN review - a
1384
+ # post-execution review line never matches, since its text never contains
1385
+ # "plan review".
1386
+ def self.plan_review_line(intent_dir)
1387
+ savepoint_lines(intent_dir).reverse.find { |_ts, kind, text| kind == "Review" && text =~ /plan review/i }
1388
+ end
1389
+
1390
+ def self.plan_reviewer(intent_dir)
1391
+ line = plan_review_line(intent_dir)
1392
+ return "not reviewed" unless line
1393
+ _ts, _kind, text = line
1394
+ VERDICT_TOKENS.find { |t| text =~ /\b#{t}\b/ } || NOT_RECORDED
1395
+ end
1396
+
1397
+ def self.plan_reviewer_note(intent_dir)
1398
+ line = plan_review_line(intent_dir)
1399
+ return "-" unless line
1400
+ ts, _kind, text = line
1401
+ "#{human_time(ts)} · #{text}"
1402
+ end
1403
+
1404
+ def self.plan_fields(intent_dir)
1405
+ [
1406
+ ["Asked", plan_asked_title(intent_dir), "## Intent"],
1407
+ ["Decisions", decision_note(intent_dir), "-"],
1408
+ ["Steps", "#{plan_steps(intent_dir).length} planned", "checklist.md"],
1409
+ ["Mode", plan_mode(intent_dir), "the delivery lock"],
1410
+ ["Reviewer", plan_reviewer(intent_dir), plan_reviewer_note(intent_dir)],
1411
+ ]
1412
+ end
1413
+
1414
+ # plan.md's own ## Risks bullets, wrapped continuations joined (317a's
1415
+ # bullet_rows); [] when plan.md is absent or carries no such section - the
1416
+ # renderer prints the literal "None" rather than an empty table, the
1417
+ # lesson 317a S4 already learned on the Evidence table.
1418
+ def self.risk_rows(intent_dir)
1419
+ path = File.join(intent_dir, "plan.md")
1420
+ return [] unless File.exist?(path)
1421
+ bullet_rows(section_of(File.read(path), "## Risks"))
1422
+ end
1423
+
1424
+ def self.render_plan(intent_dir:, store_root:, template:)
1425
+ id = intent_id(intent_dir)
1426
+ name = title_for(intent_dir, store_root)
1427
+ steps = plan_steps(intent_dir)
1428
+
1429
+ steps_rows =
1430
+ if steps.empty?
1431
+ "| | | no steps yet |"
1432
+ else
1433
+ steps.map do |s|
1434
+ "| #{escape(s[:label])} | #{escape(action_file_for(intent_dir, s[:label]))} | #{escape(s[:text])} |"
1435
+ end.join("\n")
1436
+ end
1437
+
1438
+ risks = risk_rows(intent_dir)
1439
+ risks_block =
1440
+ if risks.empty?
1441
+ "None"
1442
+ else
1443
+ rows = risks.each_with_index.map { |r, i| "| #{i + 1} | #{escape(r)} |" }
1444
+ (["| N | Risk |", "| --- | --- |"] + rows).join("\n")
1445
+ end
1446
+
1447
+ out = template.dup
1448
+ out = out.gsub("{{id}}", id)
1449
+ out = out.gsub("{{name}}", name)
1450
+ out = out.gsub("{{fields.rows}}", state_rows(plan_fields(intent_dir)).join("\n"))
1451
+ out = out.gsub("{{steps.rows}}", steps_rows)
1452
+ out = out.gsub("{{risks.block}}", risks_block)
1453
+ fit_screen(out.gsub(/\n{3,}/, "\n\n"))
1454
+ end
1455
+
1456
+ # --- S9: the session verb (intent 330) -------------------------------------------
1457
+ #
1458
+ # `report-screen session <tier_root>` - the delivered screens for every intent
1459
+ # this session completed, oldest first, then the state --all roster (D1).
1460
+ # Membership is the savepoint Done bookend inside [window_start, now] (D2),
1461
+ # never the delivery lock (a dispatched lead's derived auto- key is not the
1462
+ # owner's session id). The pure functions below take the clock and the
1463
+ # ledger root as arguments (D8): no Time.now, no git, no ENV read here.
1464
+
1465
+ # <home> for a tier root, by the same layout discriminator IntentScreen
1466
+ # uses elsewhere: a project tier root's parent directory is "projects".
1467
+ def self.home_for_tier_root(tier_root)
1468
+ File.basename(File.dirname(tier_root)) == "projects" ? File.expand_path("../..", tier_root) : tier_root
1469
+ end
1470
+
1471
+ # D18: <home>/store/.sessions, derived from the tier root through the SAME
1472
+ # discriminator - deriving it unconditionally from tier_root would answer
1473
+ # "/Users" for the global tier (~/.plastic itself has no "store" segment
1474
+ # to strip).
1475
+ def self.default_ledger_root(tier_root)
1476
+ File.join(home_for_tier_root(tier_root), "store", ".sessions")
1477
+ end
1478
+
1479
+ # D5: "global" is <home> itself; any other slug is <home>/projects/<slug>.
1480
+ def self.store_for_slug(home, slug)
1481
+ slug == "global" ? home : File.join(home, "projects", slug)
1482
+ end
1483
+
1484
+ # D4: the newest valid day directory that is not in the future, when
1485
+ # `today`'s own day directory does not exist. No ledger at all (D3.13)
1486
+ # answers `today` unchanged rather than raising - there is simply nothing
1487
+ # to scan, not an error.
1488
+ def self.fallback_day(ledger_root, today)
1489
+ return today if Dir.exist?(File.join(ledger_root, today))
1490
+ return today unless Dir.exist?(ledger_root)
1491
+
1492
+ candidates = Dir.children(ledger_root).select { |d| SessionLedger.valid_day_id?(d) && d <= today }
1493
+ candidates.max || today
1494
+ end
1495
+
1496
+ # D17: the visible note printed above the screens when no session id was
1497
+ # given at all, so the whole-day, tier-only fallback never looks like a
1498
+ # real, narrower answer.
1499
+ # D17: shaped as a screen opener ("▶ ... · ...") on purpose. The note is the
1500
+ # first line of the reply, and both ScreenPaint's OPENER_RE and the
1501
+ # MessageDisplay hook's first-character gate require that shape; a plain
1502
+ # sentence here would leave the whole session report unpainted.
1503
+ def self.window_note(day, reason)
1504
+ "▶ Window · the whole of #{Date.strptime(day, '%Y%m%d').iso8601} · #{reason}"
1505
+ end
1506
+
1507
+ # True when the day ledger actually carries a line for this session, across
1508
+ # the same two day directories the window search reads. The CLI asks so it
1509
+ # can tell "no session id given" apart from "this session id matches no
1510
+ # ledger line": D17 exists to stop the second one answering silently, and a
1511
+ # resumed background job carries exactly that kind of unmatched id.
1512
+ def self.session_tagged?(ledger_root:, session:, now:)
1513
+ return false if session.nil? || session.to_s.strip.empty?
1514
+
1515
+ short = SessionLedger.short_session_id(session)
1516
+ today = SessionLedger.day_id(now)
1517
+ yesterday = SessionLedger.day_id(now - 86_400)
1518
+ [yesterday, today].any? do |d|
1519
+ session_ledger_lines(ledger_root, d).any? { |l| l[:session] == short }
1520
+ end
1521
+ end
1522
+
1523
+ # D4: local midnight of `day`, converted to UTC, using `sample_now`'s OWN
1524
+ # utc_offset - never a literal UTC midnight, and never the machine's
1525
+ # ambient zone outside what the injected clock itself carries.
1526
+ def self.local_midnight_utc(day, sample_now)
1527
+ date = Date.strptime(day, "%Y%m%d")
1528
+ Time.new(date.year, date.month, date.day, 0, 0, 0, sample_now.utc_offset)
1529
+ end
1530
+
1531
+ # One day's session-tagged savepoint lines: "{ts} {Event} [{session}]
1532
+ # [{slug}] {summary}" (SessionLedger.savepoint_line's own shape). Missing
1533
+ # file, or a line that does not match, is silently skipped.
1534
+ SESSION_LEDGER_LINE_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s{2,}\S+\s{2,}\[([^\]]*)\]\s\[([^\]]*)\]/.freeze
1535
+
1536
+ def self.session_ledger_lines(ledger_root, day)
1537
+ path = File.join(ledger_root, day, "savepoint.md")
1538
+ return [] unless File.exist?(path)
1539
+
1540
+ File.readlines(path).filter_map do |line|
1541
+ m = line.match(SESSION_LEDGER_LINE_RE)
1542
+ m ? { ts: m[1], session: m[2], slug: m[3] } : nil
1543
+ end
1544
+ end
1545
+
1546
+ def self.store_intent_dirs(store)
1547
+ Dir.glob(File.join(store, "store", "*")).select { |d| IntentScreen.intent_dir?(d) }
1548
+ end
1549
+
1550
+ def self.last_done_ts(intent_dir)
1551
+ lines = savepoint_lines(intent_dir)
1552
+ done = lines.reverse.find { |_ts, kind, _text| kind == "Done" }
1553
+ done ? Time.parse(done[0]) : nil
1554
+ end
1555
+
1556
+ # D2/D3/D4/D5/D22: the intent directories completed inside the session's
1557
+ # window, oldest Done bookend first, plus the count of completed intents
1558
+ # (D22: Completed or Abandoned in INDEX.md) that carry no Done bookend at
1559
+ # all and so cannot be placed in any window.
1560
+ def self.session_delivered_dirs(ledger_root:, tier_root:, session:, since:, now:)
1561
+ today = SessionLedger.day_id(now)
1562
+ yesterday = SessionLedger.day_id(now - 86_400)
1563
+ short = session && !session.to_s.strip.empty? ? SessionLedger.short_session_id(session) : nil
1564
+
1565
+ tagged = short ? [yesterday, today].flat_map { |d| session_ledger_lines(ledger_root, d) }
1566
+ .select { |l| l[:session] == short } : []
1567
+ slugs = tagged.map { |l| l[:slug] }.uniq
1568
+
1569
+ window_start =
1570
+ if since
1571
+ Time.parse(since.to_s)
1572
+ elsif tagged.any?
1573
+ tagged.map { |l| Time.parse(l[:ts]) }.min
1574
+ else
1575
+ local_midnight_utc(fallback_day(ledger_root, today), now)
1576
+ end
1577
+
1578
+ home = home_for_tier_root(tier_root)
1579
+ stores = ([tier_root] + slugs.map { |s| store_for_slug(home, s) }).uniq
1580
+ stores = stores.select { |s| File.exist?(File.join(s, "INDEX.md")) }
1581
+
1582
+ entries = []
1583
+ skipped = 0
1584
+ stores.each do |store|
1585
+ completed = completed_dirnames(File.join(store, "INDEX.md"))
1586
+ store_intent_dirs(store).each do |dir|
1587
+ done_ts = last_done_ts(dir)
1588
+ if done_ts
1589
+ entries << [dir, done_ts] if done_ts >= window_start && done_ts <= now
1590
+ elsif completed.include?(File.basename(dir))
1591
+ skipped += 1
1592
+ end
1593
+ end
1594
+ end
1595
+
1596
+ [entries.sort_by { |_dir, ts| ts }.map(&:first), skipped]
1597
+ end
1598
+
1599
+ # D1/D7/D21/D22: one delivered screen per directory (oldest first, one
1600
+ # blank line apart), the roster last, and the skipped-count footer between
1601
+ # them when non-zero. `painter` is applied to each block SEPARATELY (D21):
1602
+ # a screen ScreenPaint cannot parse falls back to its own plain text
1603
+ # without touching its neighbours; the default is the identity function,
1604
+ # so a caller that never paints gets the plain screens verbatim. A
1605
+ # directory whose delivered screen cannot be rendered (O3.28) never sinks
1606
+ # the rest of the report.
1607
+ def self.render_session(dirs:, skipped:, store_root:, tag_reader: ->(_dir) { nil },
1608
+ branch_reader: ->(_dir) { nil }, note: nil, changed: nil,
1609
+ now: Time.now, painter: ->(text) { text })
1610
+ blocks = []
1611
+ blocks << note if note && !note.to_s.empty?
1612
+
1613
+ if dirs.empty?
1614
+ blocks << "No intents delivered in this session."
1615
+ else
1616
+ dirs.each do |dir|
1617
+ blocks << begin
1618
+ render_delivered(intent_dir: dir, tag_reader: tag_reader, branch_reader: branch_reader).chomp
1619
+ rescue StandardError => e
1620
+ "## #{intent_id(dir)} · could not render (#{e.message})"
1621
+ end
1622
+ end
1623
+ end
1624
+
1625
+ if skipped.positive?
1626
+ blocks << "#{skipped} completed intent#{skipped == 1 ? '' : 's'} skipped: no Done bookend in savepoint.md."
1627
+ end
1628
+
1629
+ blocks << render_roster(store_root, changed: changed, now: now).chomp
1630
+
1631
+ "#{blocks.map { |b| painter.call(b) }.join("\n\n")}\n"
1632
+ end
1633
+
1634
+ # --- S10: the roadmap verb (intent 331c) -----------------------------------------
1635
+ #
1636
+ # `report-screen roadmap <roadmap.md> plan|state|delivered` - a roadmap's own three reports,
1637
+ # the counterpart to an intent's state/delivered. Every entry comes from RoadmapQueue's public
1638
+ # `roadmap` reader (D6/R1): no second parser here ever re-derives its grammar, its INDEX
1639
+ # reconciliation, or its frontier selection.
1640
+
1641
+ ROADMAP_VERBS = %w[plan state delivered].freeze
1642
+
1643
+ # D6/R17: the tier root for a roadmap path is the parent of `roadmaps/`, one extra parent when
1644
+ # the file sits under `roadmaps/archived/` - the SAME rule RoadmapSavepoint.index_path_for
1645
+ # uses (that method is private, so this is the rule's second, agreeing owner; a test pins them
1646
+ # together).
1647
+ def self.roadmap_tier_root(path)
1648
+ dir = File.dirname(path)
1649
+ dir = File.dirname(dir) if File.basename(dir) == "archived"
1650
+ File.dirname(dir)
1651
+ end
1652
+
1653
+ def self.roadmap_default_template_path(verb)
1654
+ File.expand_path("../../templates/report-roadmap-#{verb}.md", __dir__)
1655
+ end
1656
+
1657
+ # D6/R1: the parsed, INDEX-reconciled entries for one roadmap file, obtained from
1658
+ # RoadmapQueue's own public reader - never a second parser.
1659
+ def self.roadmap_entries(path:, store_root:)
1660
+ index_path = File.join(store_root, "INDEX.md")
1661
+ RoadmapQueue.new(roadmaps_dir: File.dirname(path), index_path: index_path).roadmap(path)
1662
+ end
1663
+
1664
+ # R4/R15: the first sentence of `## Goal`, joined across wrapped source lines. Splits on a
1665
+ # period only (never IntentScreen.clause_and_rest's `[.;]` - a semicolon inside a real goal is
1666
+ # common and must survive, R15); a period with no following whitespace or end-of-string (a
1667
+ # version number like "2.0.0", never followed by a space mid-number) is never mistaken for a
1668
+ # sentence boundary (R4).
1669
+ def self.roadmap_goal(text)
1670
+ section = section_of(text, "## Goal").strip
1671
+ return NOT_RECORDED if section.empty?
1672
+
1673
+ joined = section.lines.map(&:strip).join(" ").squeeze(" ")
1674
+ m = joined.match(/\A(.*?\.)(?=\s|\z)/)
1675
+ (m ? m[1] : joined).strip
1676
+ end
1677
+
1678
+ # R16: the ledger's own entries when the paired `.savepoint.md` carries any; otherwise the
1679
+ # `## Log` lines classified through RoadmapSavepoint.classify_event (the same KEYWORD_TABLE,
1680
+ # no second vocabulary), each timestamped from its own Log line's date and time. A roadmap with
1681
+ # neither source (no ledger file, no classifiable Log line) answers `[]`, never an invented
1682
+ # event - callers reading it print `not recorded`.
1683
+ def self.roadmap_events(path)
1684
+ ledger = RoadmapSavepoint.ledger_entries(path)
1685
+ return ledger.sort_by { |t, _, _| t } if ledger.any?
1686
+
1687
+ text = File.exist?(path) ? File.read(path) : nil
1688
+ return [] unless text
1689
+
1690
+ section_of(text, "## Log").each_line.filter_map do |line|
1691
+ m = line.strip.match(RoadmapSavepoint::LOG_LINE)
1692
+ next nil unless m
1693
+ event = RoadmapSavepoint.classify_event(m[3])
1694
+ next nil unless event
1695
+ [Time.parse("#{m[1]}T#{m[2]}:00Z"), event, m[3].strip]
1696
+ end
1697
+ end
1698
+
1699
+ # Every `## Log` line, classified for the delivered screen's Log table: an unclassifiable line
1700
+ # still renders, with `not recorded` in its Event cell (never dropped, unlike roadmap_events'
1701
+ # fallback, which only wants events it can act on).
1702
+ def self.roadmap_log_rows(text)
1703
+ section_of(text, "## Log").each_line.filter_map do |line|
1704
+ m = line.strip.match(RoadmapSavepoint::LOG_LINE)
1705
+ next nil unless m
1706
+ { when: human_time("#{m[1]}T#{m[2]}:00Z"), event: RoadmapSavepoint.classify_event(m[3]) || NOT_RECORDED, what: m[3].strip }
1707
+ end
1708
+ end
1709
+
1710
+ # R7: idle unless the entry's own delivery lock is fresh as of `now:` - a stale lock (the
1711
+ # heartbeat older than the TTL) never masquerades as a live lead.
1712
+ def self.roadmap_lead(intent_dir, now:)
1713
+ return "idle" unless intent_dir
1714
+ lead_cell(intent_dir, now: now)
1715
+ end
1716
+
1717
+ def self.roadmap_intent_dir(store_root, id)
1718
+ Dir.glob(File.join(store_root, "store", "#{id}--*")).sort.find { |d| IntentScreen.intent_dir?(d) }
1719
+ end
1720
+
1721
+ def self.roadmap_entry_progress(dir)
1722
+ return NOT_RECORDED unless dir
1723
+ items = IntentScreen.checklist_items(dir)
1724
+ fields = IntentScreen.progress_fields(items)
1725
+ "#{fields['progress.bar']} #{fields['progress.done']} / #{fields['progress.total']}"
1726
+ end
1727
+
1728
+ def self.roadmap_progress_bar(done, total)
1729
+ on = total.zero? ? 0 : (done * IntentScreen::BAR_WIDTH) / total
1730
+ (IntentScreen::ON * on) + (IntentScreen::OFF * (IntentScreen::BAR_WIDTH - on))
1731
+ end
1732
+
1733
+ # "Batch" or "Wave" (singular): the entries table's own first column header (R3 - a legacy
1734
+ # Waves roadmap reads "Wave", never "Batch").
1735
+ def self.roadmap_batch_label(data)
1736
+ data[:grouping] == "Waves" ? "Wave" : "Batch"
1737
+ end
1738
+
1739
+ def self.roadmap_all_entries(data)
1740
+ data[:batches].flat_map { |b| b[:entries] }
1741
+ end
1742
+
1743
+ # --- plan (D2) ---------------------------------------------------------------
1744
+
1745
+ def self.roadmap_plan_fields(text, data, events)
1746
+ all_entries = roadmap_all_entries(data)
1747
+ order = data[:batches].map { |b| b[:heading] }.join(" → ")
1748
+ created = events.empty? ? NOT_RECORDED : human_time(events.first[0].utc.iso8601)
1749
+ [
1750
+ ["Goal", roadmap_goal(text), ""],
1751
+ [data[:grouping], "#{data[:batches].length} #{data[:grouping].downcase}, #{all_entries.length} intents", ""],
1752
+ ["Order", order, ""],
1753
+ ["Created", created, ""],
1754
+ ]
1755
+ end
1756
+
1757
+ # Intent 337, n7: the graph tree block for the plan screen, additive only
1758
+ # - a roadmap with no real "## Graph" section (RoadmapGraph.analyze's
1759
+ # `has_graph: false`) or a cyclic one renders no block at all, so a
1760
+ # graphless roadmap's screen stays byte-identical to before this method
1761
+ # existed (row 7.2). Fits the same screen limit `fit_screen` enforces
1762
+ # everywhere else (row 7.3), fenced so a box-drawing line is never
1763
+ # mistaken for a markdown table row.
1764
+ def self.roadmap_tree_block(path, store_root)
1765
+ index_path = File.join(store_root, "INDEX.md")
1766
+ analysis = RoadmapGraph.analyze(path, index_path: index_path)
1767
+ return "" unless analysis[:has_graph] && analysis[:cycle].nil?
1768
+
1769
+ labels = analysis[:entries].each_with_object({}) { |(id, e), h| h[id] = e[:title] }
1770
+ marks = {
1771
+ critical_path: analysis[:critical_paths] ? (analysis[:critical_paths][:critical_path] || []) : [],
1772
+ ready: analysis[:ready] || [],
1773
+ }
1774
+ tree = GraphTree.render(edges: analysis[:edges], labels: labels, marks: marks, width: FIT_SCREEN_DEFAULT_LIMIT)
1775
+ return "" unless tree[:ok]
1776
+
1777
+ "\n\n**Tree**\n\n```\n#{tree[:text]}```\n"
1778
+ end
1779
+
1780
+ def self.roadmap_plan_entries_table(data)
1781
+ label = roadmap_batch_label(data)
1782
+ rows = ["| #{label} | Graph ID | Intent | Status |", "| --- | --- | --- | --- |"]
1783
+ data[:batches].each do |batch|
1784
+ batch[:entries].each do |e|
1785
+ rows << "| #{escape(batch[:heading])} | #{escape(e[:id])} | #{escape(e[:text])} | #{escape(e[:status])} |"
1786
+ end
1787
+ end
1788
+ rows.join("\n")
1789
+ end
1790
+
1791
+ # --- state (D3) ---------------------------------------------------------------
1792
+
1793
+ def self.roadmap_state_fields(text, data, events, store_root, now)
1794
+ all_entries = roadmap_all_entries(data)
1795
+ total = all_entries.length
1796
+ delivered = all_entries.count { |e| e[:status] == "delivered" }
1797
+ bar = roadmap_progress_bar(delivered, total)
1798
+
1799
+ frontier = data[:frontier]
1800
+ frontier_value = frontier ? frontier[:heading] : NOT_RECORDED
1801
+ frontier_note =
1802
+ if frontier.nil?
1803
+ ""
1804
+ elsif frontier[:in_flight].any?
1805
+ "in flight"
1806
+ else
1807
+ "queued"
1808
+ end
1809
+
1810
+ delivering_value =
1811
+ if frontier && frontier[:in_flight].any?
1812
+ frontier[:in_flight].map do |e|
1813
+ dir = roadmap_intent_dir(store_root, e["id"])
1814
+ "#{e['id']} (#{roadmap_lead(dir, now: now)})"
1815
+ end.join(", ")
1816
+ else
1817
+ NOT_RECORDED
1818
+ end
1819
+
1820
+ next_entry = all_entries.find { |e| e[:status] == "queued" }
1821
+ next_value = next_entry ? "#{next_entry[:id]} #{next_entry[:text]}".strip : NOT_RECORDED
1822
+
1823
+ changed_value = events.empty? ? NOT_RECORDED : "#{events.last[1]} · #{human_time(events.last[0].utc.iso8601)}"
1824
+
1825
+ [
1826
+ ["Goal", roadmap_goal(text), ""],
1827
+ ["Progress", "#{bar} #{delivered} / #{total}", ""],
1828
+ ["Frontier", frontier_value, frontier_note],
1829
+ ["Delivering", delivering_value, ""],
1830
+ ["Next", next_value, ""],
1831
+ ["Changed", changed_value, ""],
1832
+ ]
1833
+ end
1834
+
1835
+ # RC4/spec.md defect 2: the Batches table carries the same Intent title column the plan
1836
+ # verb's own table already does (roadmap_plan_entries_table). The Intent cell spends
1837
+ # whatever the row's other cells leave it (W8a/W8b) through the ONE shared budget helper
1838
+ # (fit_row_cell) dashboard.rb's screen_fit_intent also spends by, computed PER ROW from that
1839
+ # row's own batch/id/status/progress/lead - never a cross-row max - so one long row's Intent
1840
+ # cell can never re-truncate another row's already-correct one (A5).
1841
+ def self.roadmap_state_entries_table(data, store_root, now)
1842
+ label = roadmap_batch_label(data)
1843
+ rows = ["| #{label} | Graph ID | Intent | Status | Progress | Lead |",
1844
+ "| --- | --- | --- | --- | --- | --- |"]
1845
+ data[:batches].each do |batch|
1846
+ batch[:entries].each do |e|
1847
+ dir = roadmap_intent_dir(store_root, e[:id])
1848
+ progress = roadmap_entry_progress(dir)
1849
+ lead = roadmap_lead(dir, now: now)
1850
+ others = [batch[:heading], e[:id], e[:status], progress, lead]
1851
+ intent_cell = fit_row_cell(e[:text], others)
1852
+ rows << "| #{escape(batch[:heading])} | #{escape(e[:id])} | #{escape(intent_cell)} | " \
1853
+ "#{escape(e[:status])} | #{escape(progress)} | #{escape(lead)} |"
1854
+ end
1855
+ end
1856
+ rows.join("\n")
1857
+ end
1858
+
1859
+ # --- delivered (D4) ------------------------------------------------------------
1860
+
1861
+ def self.roadmap_delivered_meta(data, events)
1862
+ all_entries = roadmap_all_entries(data)
1863
+ closed = events.reverse.find { |_t, event, _d| event == "closed" }
1864
+ closed_part = closed ? human_time(closed[0].utc.iso8601) : "in progress"
1865
+
1866
+ merged = events.select { |_t, event, _d| event == "merged" }
1867
+ duration = events.empty? || merged.empty? ? NOT_RECORDED : format_duration((merged.last[0] - events.first[0]).to_i)
1868
+
1869
+ "#{closed_part} · #{all_entries.length} intents · #{duration}"
1870
+ end
1871
+
1872
+ # The regex RoadmapSavepoint::KEYWORD_TABLE pairs with an event word - read from the table
1873
+ # rather than copied, so the Merged cell's vocabulary never drifts from rebuild's own.
1874
+ def self.roadmap_savepoint_keyword_regex(event)
1875
+ RoadmapSavepoint::KEYWORD_TABLE.find { |_re, ev| ev == event }.first
1876
+ end
1877
+
1878
+ # R10/R21/R22: the Merged cell reads a line only when the entry id is its SUBJECT - the first
1879
+ # whitespace-delimited token of the detail, never a whole word anywhere in it (R21: a real
1880
+ # ledger line names one entry's id as its subject and a SECOND entry's id in passing, and the
1881
+ # second entry has no merge line of its own to fill this row with). Among subject-matching
1882
+ # lines, one is read when the ledger's own event is `merged` OR its detail matches
1883
+ # KEYWORD_TABLE's merged pattern (R22: the appender sometimes files a real per-entry merge
1884
+ # under a different event word, `dispatched`, because the rest of the line was other news),
1885
+ # and refused when the event is `handoff` or the detail matches KEYWORD_TABLE's handoff
1886
+ # pattern - stricter than R10's original guarantee, never weaker. The sha is the first
1887
+ # hex-with-at-least-one-digit token of 7-40 characters in the matched line.
1888
+ def self.roadmap_merged_cell(id, events)
1889
+ merged_re = roadmap_savepoint_keyword_regex("merged")
1890
+ handoff_re = roadmap_savepoint_keyword_regex("handoff")
1891
+
1892
+ line = events.find do |_t, event, detail|
1893
+ next false unless detail.to_s.strip.split(/\s+/).first == id
1894
+ next false if event == "handoff" || detail.to_s =~ handoff_re
1895
+ event == "merged" || detail.to_s =~ merged_re
1896
+ end
1897
+ return NOT_RECORDED unless line
1898
+
1899
+ m = line[2].match(/\b(?=[0-9a-f]*\d)[0-9a-f]{7,40}\b/i)
1900
+ m ? m[0] : NOT_RECORDED
1901
+ end
1902
+
1903
+ def self.roadmap_delivered_table(data, events)
1904
+ label = roadmap_batch_label(data)
1905
+ rows = ["| #{label} | Graph ID | Intent | Merged |", "| --- | --- | --- | --- |"]
1906
+ data[:batches].each do |batch|
1907
+ batch[:entries].each do |e|
1908
+ rows << "| #{escape(batch[:heading])} | #{escape(e[:id])} | #{escape(e[:text])} | " \
1909
+ "#{escape(roadmap_merged_cell(e[:id], events))} |"
1910
+ end
1911
+ end
1912
+ rows.join("\n")
1913
+ end
1914
+
1915
+ def self.roadmap_log_table(text)
1916
+ log_rows = roadmap_log_rows(text)
1917
+ return NOT_RECORDED if log_rows.empty?
1918
+
1919
+ rows = ["| When | Event | Detail |", "| --- | --- | --- |"]
1920
+ log_rows.each { |r| rows << "| #{escape(r[:when])} | #{escape(r[:event])} | #{escape(r[:what])} |" }
1921
+ rows.join("\n")
1922
+ end
1923
+
1924
+ # --- render ---------------------------------------------------------------------
1925
+
1926
+ # D6: `ReportScreen.render_roadmap(path:, verb:, store_root: nil, now: Time.now, template:
1927
+ # nil)`. No ENV, no git; `now:` is used only for lock freshness (R7). `store_root` defaults to
1928
+ # the derived tier root; `template` defaults to the installed-or-in-repo
1929
+ # `templates/report-roadmap-<verb>.md`.
1930
+ def self.render_roadmap(path:, verb:, store_root: nil, now: Time.now, template: nil)
1931
+ verb = verb.to_s
1932
+ raise ArgumentError, "verb must be one of #{ROADMAP_VERBS.join(', ')}, got #{verb.inspect}" unless ROADMAP_VERBS.include?(verb)
1933
+
1934
+ store_root ||= roadmap_tier_root(path)
1935
+ text = File.read(path)
1936
+ data = roadmap_entries(path: path, store_root: store_root)
1937
+ events = roadmap_events(path)
1938
+ template ||= File.read(roadmap_default_template_path(verb))
1939
+
1940
+ out = template.dup
1941
+ out = out.gsub("{{slug}}", data[:slug])
1942
+
1943
+ case verb
1944
+ when "plan"
1945
+ out = out.gsub("{{fields.rows}}", state_rows(roadmap_plan_fields(text, data, events)).join("\n"))
1946
+ out = out.gsub("{{entries.table}}", roadmap_plan_entries_table(data))
1947
+ out = out.gsub("{{tree}}", roadmap_tree_block(path, store_root))
1948
+ when "state"
1949
+ out = out.gsub("{{fields.rows}}", state_rows(roadmap_state_fields(text, data, events, store_root, now)).join("\n"))
1950
+ out = out.gsub("{{entries.table}}", roadmap_state_entries_table(data, store_root, now))
1951
+ when "delivered"
1952
+ out = out.gsub("{{meta}}", roadmap_delivered_meta(data, events))
1953
+ out = out.gsub("{{delivered.table}}", roadmap_delivered_table(data, events))
1954
+ out = out.gsub("{{log.table}}", roadmap_log_table(text))
1955
+ end
1956
+
1957
+ fit_screen(out.gsub(/\n{3,}/, "\n\n"))
1958
+ end
1959
+
1960
+ # --- S8: --ansi passthrough (D2) -----------------------------------------------
1961
+ #
1962
+ # 316a owns the ANSI renderer; 317 only wires a generic DI seam so this
1963
+ # module never blocks on 316a landing and never breaks when it does (row 77).
1964
+ # A renderer file, when present, is expected to define IntentScreenAnsi.paint
1965
+ # (one plain-text string in, one string out). Wiring the real contract 316a
1966
+ # ships is left to a follow-up step once that file exists (see checklist S14).
1967
+ end