@zalom/plastic 2.0.0-alpha.6 → 2.0.0-alpha.8

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.
@@ -0,0 +1,586 @@
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_relative "intent_screen"
14
+ require_relative "lock"
15
+
16
+ module ReportScreen
17
+ NOT_RECORDED = "not recorded"
18
+
19
+ # --- shared helpers ----------------------------------------------------------
20
+
21
+ def self.intent_basename(intent_dir)
22
+ File.basename(intent_dir)
23
+ end
24
+
25
+ def self.intent_id(intent_dir)
26
+ intent_basename(intent_dir).split("--", 2).first
27
+ end
28
+
29
+ def self.intent_file_path(intent_dir)
30
+ File.join(intent_dir, "#{intent_basename(intent_dir)}.md")
31
+ end
32
+
33
+ def self.intent_text(intent_dir)
34
+ path = intent_file_path(intent_dir)
35
+ File.exist?(path) ? File.read(path) : nil
36
+ end
37
+
38
+ def self.spec_text(intent_dir)
39
+ path = File.join(intent_dir, "spec.md")
40
+ File.exist?(path) ? File.read(path) : nil
41
+ end
42
+
43
+ def self.outcome_text(intent_dir)
44
+ path = File.join(intent_dir, "outcome.md")
45
+ File.exist?(path) ? File.read(path) : nil
46
+ end
47
+
48
+ # The body of a top-level "## Heading" section, stopping at the next "## "
49
+ # heading (same idiom as IntentScreen.insight_fields). Returns "" when the
50
+ # heading is absent.
51
+ def self.section_of(text, heading)
52
+ return "" unless text
53
+ text.split(/^#{Regexp.escape(heading)}\s*$/, 2)[1].to_s.split(/^## /, 2)[0].to_s
54
+ end
55
+
56
+ def self.escape(text)
57
+ text.to_s.gsub("|", "\\|")
58
+ end
59
+
60
+ def self.frontmatter(intent_dir)
61
+ text = intent_text(intent_dir)
62
+ return {} unless text && text.start_with?("---")
63
+ parts = text.split("---", 3)
64
+ return {} if parts.length < 3
65
+ require "yaml"
66
+ require "date"
67
+ YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
68
+ rescue StandardError
69
+ {}
70
+ end
71
+
72
+ def self.research_intent?(intent_dir)
73
+ Array(frontmatter(intent_dir)["tags"]).map(&:to_s).include?("research")
74
+ end
75
+
76
+ # Markdown pipe-table data rows (header + separator skipped), each an array
77
+ # of trimmed cell strings. Tolerates leading prose before the table.
78
+ def self.table_rows(text)
79
+ lines = text.to_s.lines.map(&:strip).select { |l| l.start_with?("|") }
80
+ sep_idx = lines.index { |l| l.match?(/\A\|[\s:|-]+\|?\z/) }
81
+ return [] unless sep_idx
82
+ lines[(sep_idx + 1)..].map { |l| l.split("|", -1).map(&:strip)[1..-2].to_a }
83
+ end
84
+
85
+ # Every [heading_line, body] pair in a Markdown file, split on ANY heading
86
+ # line (any level). Used by proven_by (D19) so a section's own matrix rows
87
+ # are never confused with a sibling section's.
88
+ def self.split_by_headings(text)
89
+ sections = []
90
+ heading = nil
91
+ body = +""
92
+ text.to_s.each_line do |line|
93
+ if line.start_with?("#")
94
+ sections << [heading, body] if heading
95
+ heading = line.strip
96
+ body = +""
97
+ else
98
+ body << line
99
+ end
100
+ end
101
+ sections << [heading, body] if heading
102
+ sections
103
+ end
104
+
105
+ def self.savepoint_lines(intent_dir)
106
+ path = File.join(intent_dir, "savepoint.md")
107
+ return [] unless File.exist?(path)
108
+ File.readlines(path).map(&:strip).reject(&:empty?).filter_map do |line|
109
+ m = line.match(IntentScreen::SAVEPOINT_RE)
110
+ m ? [m[1], m[2], m[3]] : nil
111
+ end
112
+ end
113
+
114
+ def self.human_time(ts)
115
+ IntentScreen.human_time(ts)
116
+ end
117
+
118
+ def self.title_for(intent_dir, store_root)
119
+ text = intent_text(intent_dir)
120
+ return "not recorded" unless text
121
+ if store_root
122
+ _status, title = IntentScreen.index_fields(store_root, intent_id(intent_dir))
123
+ return title if title
124
+ end
125
+ IntentScreen.fallback_name(text)
126
+ end
127
+
128
+ def self.default_store_root(intent_dir)
129
+ File.expand_path("../..", intent_dir)
130
+ end
131
+
132
+ # --- S3: record readers -------------------------------------------------------
133
+
134
+ # Row 21: the ## Intent section body only, never the frontmatter `intent:` line.
135
+ def self.asked(intent_dir)
136
+ text = intent_text(intent_dir)
137
+ return NOT_RECORDED unless text
138
+ body = section_of(text, "## Intent").strip
139
+ body.empty? ? NOT_RECORDED : body
140
+ end
141
+
142
+ # Row 22: bullets under spec.md's ## Decisions only.
143
+ def self.decision_count(intent_dir)
144
+ text = spec_text(intent_dir)
145
+ return NOT_RECORDED unless text
146
+ section_of(text, "## Decisions").lines.count { |l| l.lstrip.start_with?("- ") }
147
+ end
148
+
149
+ # Rows 23/24: outcome.md's ## Delivered, table or bullet form.
150
+ def self.delivered_rows(intent_dir)
151
+ text = outcome_text(intent_dir)
152
+ return [] unless text
153
+ section = section_of(text, "## Delivered")
154
+ return [] if section.strip.empty?
155
+
156
+ rows = table_rows(section)
157
+ return rows.map { |cells| { label: cells[0].to_s, text: cells[1].to_s } } if rows.any?
158
+
159
+ bullets = section.lines.select { |l| l.lstrip.start_with?("- ") }
160
+ bullets.each_with_index.map do |line, i|
161
+ { label: (i + 1).to_s, text: line.lstrip.sub(/\A-\s*/, "").strip }
162
+ end
163
+ end
164
+
165
+ # Rows 25-27: D19 - the label must appear as a standalone token in an action
166
+ # file heading (any level); the count is the matched section's table rows only.
167
+ def self.matching_action_heading(intent_dir, label)
168
+ Dir.glob(File.join(intent_dir, "actions", "*.md")).sort.each do |path|
169
+ split_by_headings(File.read(path)).each do |heading, body|
170
+ tokens = heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/)
171
+ return [heading, body] if tokens.include?(label)
172
+ end
173
+ end
174
+ [nil, nil]
175
+ end
176
+
177
+ def self.proven_by(intent_dir, label)
178
+ _heading, body = matching_action_heading(intent_dir, label)
179
+ return NOT_RECORDED unless body
180
+ n = table_rows(body).length
181
+ n.positive? ? "#{n} test#{n == 1 ? '' : 's'}" : NOT_RECORDED
182
+ end
183
+
184
+ # Row 34: outcome.md's ## Needs you, our own N1..NN numbering (never the
185
+ # table's own N column, which could be malformed).
186
+ def self.needs_you_rows(intent_dir)
187
+ text = outcome_text(intent_dir)
188
+ return [] unless text
189
+ return [] unless text.include?("## Needs you")
190
+ section = section_of(text, "## Needs you")
191
+ rows = table_rows(section)
192
+ rows.each_with_index.map do |cells, i|
193
+ { n: "N#{i + 1}", what: cells[1].to_s, why: cells[2].to_s }
194
+ end
195
+ end
196
+
197
+ # Row 35: first-to-last savepoint timestamp, "1 h 51 min" / "n min".
198
+ def self.duration(intent_dir)
199
+ lines = savepoint_lines(intent_dir)
200
+ return NOT_RECORDED if lines.length < 2
201
+ secs = (Time.parse(lines.last[0]) - Time.parse(lines.first[0])).to_i
202
+ format_duration(secs)
203
+ end
204
+
205
+ def self.format_duration(secs)
206
+ mins = [secs, 0].max / 60
207
+ return "#{mins} min" if mins < 60
208
+ "#{mins / 60} h #{mins % 60} min"
209
+ end
210
+
211
+ # Row 36 (D20): mode from the LIVE delivery lock's run_mode; absent -> not recorded.
212
+ def self.mode(intent_dir)
213
+ data = Lock.read(intent_dir)
214
+ value = data && data["run_mode"]
215
+ value && !value.to_s.empty? ? value.to_s : NOT_RECORDED
216
+ end
217
+
218
+ # --- evidence rows (rows 28-33, 37) --------------------------------------------
219
+
220
+ def self.suite_row(section)
221
+ m = section.match(/([\d,]+)\s*runs,\s*([\d,]+)\s*assertions,\s*([\d,]+)\s*failures/)
222
+ return nil unless m
223
+ { kind: "suite", what: "#{m[1]} runs · #{m[2]} assertions · #{m[3]} failures", source: "outcome.md ## Verification" }
224
+ end
225
+
226
+ def self.red_row(section)
227
+ line = section.lines.find { |l| l =~ /\bred\b/i && l =~ /`([0-9a-f]{7,40})`/ }
228
+ return nil unless line
229
+ sha = line.match(/`([0-9a-f]{7,40})`/)[1]
230
+ { kind: "red", what: "#{sha} proven test-only and red", source: "outcome.md ## Verification" }
231
+ end
232
+
233
+ def self.ship_row(text, intent_dir, tag_reader)
234
+ line = text.to_s.lines.find { |l| l =~ /\bmerge(d)?\b/i && l =~ /\b[0-9a-f]{7,40}\b/ }
235
+ sha = line && line.match(/\b([0-9a-f]{7,40})\b/)[1]
236
+ version = tag_reader.call(intent_dir)
237
+ return nil if sha.nil? && (version.nil? || version.to_s.empty?)
238
+ ver_text = version && !version.to_s.empty? ? "v#{version.to_s.sub(/\Av/, '')}" : NOT_RECORDED
239
+ sha_text = sha || NOT_RECORDED
240
+ { kind: "ship", what: "#{sha_text} → alpha · #{ver_text}", source: "outcome.md; git tags" }
241
+ end
242
+
243
+ def self.doctor_row(text)
244
+ m = text.to_s.match(/(\d+)\s*pass,?\s*(\d+)\s*warn,?\s*(\d+)\s*fail/i)
245
+ return nil unless m
246
+ { kind: "doctor", what: "#{m[1]} pass · #{m[2]} warn · #{m[3]} fail", source: "outcome.md" }
247
+ end
248
+
249
+ def self.deviates_row(section)
250
+ line = section.lines.find { |l| l.lstrip.sub(/\A-\s*/, "").start_with?("Deviation:") }
251
+ return nil unless line
252
+ text = line.lstrip.sub(/\A-\s*/, "").strip
253
+ { kind: "deviates", what: text, source: "outcome.md ## Verification — Deviation:" }
254
+ end
255
+
256
+ def self.deposits_row(text)
257
+ line = text.to_s.lines.find { |l| l =~ %r{`resources/[^`]+`} }
258
+ return nil unless line
259
+ path = line.match(%r{`(resources/[^`]+)`})[1]
260
+ { kind: "deposits", what: path, source: "outcome.md" }
261
+ end
262
+
263
+ def self.verdict_row(text)
264
+ m = text.to_s.match(/verdict[:\s]+([A-Za-z][A-Za-z ]*)/i)
265
+ return nil unless m
266
+ { kind: "verdict", what: m[1].strip, source: "outcome.md" }
267
+ end
268
+
269
+ def self.evidence_rows(intent_dir, tag_reader: ->(_dir) { nil })
270
+ text = outcome_text(intent_dir)
271
+ return [] unless text
272
+ verification = section_of(text, "## Verification")
273
+
274
+ rows = []
275
+ rows << suite_row(verification)
276
+ rows << red_row(verification)
277
+ rows << (research_intent?(intent_dir) ? nil : ship_row(text, intent_dir, tag_reader))
278
+ if research_intent?(intent_dir)
279
+ rows << deposits_row(text)
280
+ rows << verdict_row(text)
281
+ end
282
+ rows << doctor_row(text)
283
+ rows << deviates_row(verification)
284
+ rows.compact
285
+ end
286
+
287
+ # --- S4/S5: the state verb and the --all roster --------------------------------
288
+
289
+ CHANGED_NOTE = "the reason this screen printed"
290
+
291
+ def self.state_fields(intent_dir:, store_root:, changed:)
292
+ base = intent_basename(intent_dir)
293
+ id = base.split("--", 2).first
294
+ text = intent_text(intent_dir)
295
+ status, title = IntentScreen.index_fields(store_root, id)
296
+ name = title || IntentScreen.fallback_name(text.to_s)
297
+
298
+ f = {}
299
+ f.merge!(IntentScreen.store_fields(store_root))
300
+ f["status"] = status
301
+ f["status.note"] = status == "unlisted" ? "no INDEX.md line names this id" : "listed under ## #{status} in INDEX.md"
302
+ f.merge!(IntentScreen.savepoint_fields(intent_dir, text.to_s))
303
+ items = IntentScreen.checklist_items(intent_dir)
304
+ f.merge!(IntentScreen.progress_fields(items))
305
+ f.merge!(IntentScreen.next_fields(items, status, checklist_present: IntentScreen.items_present?(intent_dir)))
306
+ f.merge!(IntentScreen.insight_fields(text.to_s))
307
+
308
+ changed_value = changed && !changed.to_s.empty? ? changed.to_s : "on request"
309
+
310
+ rows = [
311
+ ["Store", f["store"], f["store.note"]],
312
+ ["Status", f["status"], f["status.note"]],
313
+ ["Stage", f["stage"], f["stage.note"]],
314
+ ["Savepoint", f["savepoint"], f["savepoint.note"]],
315
+ ["Progress", "#{f['progress.bar']} #{f['progress.done']} / #{f['progress.total']}", f["progress.note"]],
316
+ ["Next", f["next"], f["next.note"]],
317
+ ["Insight", f["insight"], f["insight.note"]],
318
+ ["Changed", changed_value, CHANGED_NOTE],
319
+ ]
320
+ { id: id, name: name, rows: rows, items: items }
321
+ end
322
+
323
+ # Rows 42-46: pad BOTH columns to the widest NOTED label/value, computed on
324
+ # the raw emitted (already-escaped) cell text; unnoted rows carry no padding.
325
+ def self.state_rows(rows)
326
+ escaped = rows.map { |label, value, note| ["**#{label}**", escape(value), escape(note)] }
327
+ noted = escaped.select { |_, _, note| !note.to_s.empty? }
328
+ label_w = noted.map { |l, _, _| l.length }.max || 0
329
+ value_w = noted.map { |_, v, _| v.length }.max || 0
330
+ escaped.map do |label, value, note|
331
+ if note.to_s.empty?
332
+ "| #{label} | #{value} | |"
333
+ else
334
+ "| #{label.ljust(label_w)} | #{value.ljust(value_w)} | #{note} |"
335
+ end
336
+ end
337
+ end
338
+
339
+ def self.render_state(intent_dir:, store_root:, changed:, template:)
340
+ data = state_fields(intent_dir: intent_dir, store_root: store_root, changed: changed)
341
+ out = template.dup
342
+ out = out.gsub("{{id}}", data[:id])
343
+ out = out.gsub("{{name}}", data[:name])
344
+ out = out.gsub("{{fields.rows}}", state_rows(data[:rows]).join("\n"))
345
+ out = out.gsub("{{steps.rows}}", IntentScreen.steps_rows(data[:items]))
346
+ out.gsub(/\n{3,}/, "\n\n")
347
+ end
348
+
349
+ # --- roster (D7/D8) -------------------------------------------------------------
350
+
351
+ def self.active_dirnames(index_path)
352
+ return [] unless File.exist?(index_path)
353
+ dirnames = []
354
+ section = nil
355
+ File.foreach(index_path) do |line|
356
+ if line.start_with?("## ")
357
+ section = line[3..].strip
358
+ next
359
+ end
360
+ next unless section == "Active"
361
+ m = line.match(%r{\(store/([^/]+)/})
362
+ dirnames << m[1] if m
363
+ end
364
+ dirnames
365
+ end
366
+
367
+ def self.newest_savepoint_ts(intent_dir)
368
+ lines = savepoint_lines(intent_dir)
369
+ lines.last&.first
370
+ end
371
+
372
+ def self.roster(store_root)
373
+ index_path = File.join(store_root, "INDEX.md")
374
+ entries = active_dirnames(index_path).filter_map do |dirname|
375
+ dir = File.join(store_root, "store", dirname)
376
+ next unless File.directory?(dir)
377
+ text = intent_text(dir).to_s
378
+ fields = IntentScreen.savepoint_fields(dir, text)
379
+ next if fields["stage"] == "Done"
380
+ { dir: dir, id: dirname.split("--", 2).first, ts: newest_savepoint_ts(dir) }
381
+ end
382
+ entries.sort_by { |e| [-(e[:ts] ? Time.parse(e[:ts]).to_i : 0), e[:id]] }
383
+ end
384
+
385
+ def self.lead(intent_dir)
386
+ data = Lock.read(intent_dir)
387
+ return "idle" unless data
388
+ agent = data["owner_agent"].to_s
389
+ session = data["owner_session"].to_s
390
+ return "idle" if agent.empty? && session.empty?
391
+ "#{agent.empty? ? 'unknown' : agent} · #{session[0, 8]}"
392
+ rescue StandardError
393
+ "idle"
394
+ end
395
+
396
+ def self.collapsed_open_steps_note(count)
397
+ count <= 3 ? "#{count} open" : "#{count} open · showing the first three"
398
+ end
399
+
400
+ def self.render_collapsed_block(intent_dir, store_root, changed:)
401
+ data = state_fields(intent_dir: intent_dir, store_root: store_root, changed: changed)
402
+ stage = data[:rows].find { |l, _, _| l == "Stage" }[1]
403
+ nxt = data[:rows].find { |l, _, _| l == "Next" }[1]
404
+ ch = data[:rows].find { |l, _, _| l == "Changed" }[1]
405
+
406
+ open_items = data[:items].each_with_index.reject { |item, _| item[:done] }
407
+ lines = []
408
+ lines << "▶ #{data[:id]} · #{data[:name]}"
409
+ lines << "Stage #{stage}"
410
+ lines << "Next #{nxt}"
411
+ lines << "Changed #{ch}"
412
+ lines << collapsed_open_steps_note(open_items.length)
413
+ open_items.first(3).each { |item, i| lines << "S#{i + 1} [ open ] #{escape(item[:text])}" }
414
+ lines.join("\n")
415
+ end
416
+
417
+ def self.render_roster(store_root, changed: nil, now: Time.now)
418
+ entries = roster(store_root)
419
+ return "No intents in delivery.\n" if entries.empty?
420
+
421
+ header = "▶ In delivery · #{entries.length} #{entries.length == 1 ? 'intent' : 'intents'} · " \
422
+ "#{now.utc.strftime('%Y-%m-%d %H:%M UTC')}"
423
+ table = ["| Intent | Stage | Progress | Changed | Lead |", "| --- | --- | --- | --- | --- |"]
424
+ entries.each do |e|
425
+ text = intent_text(e[:dir]).to_s
426
+ savepoint = IntentScreen.savepoint_fields(e[:dir], text)
427
+ items = IntentScreen.checklist_items(e[:dir])
428
+ progress = IntentScreen.progress_fields(items)
429
+ ch = state_fields(intent_dir: e[:dir], store_root: store_root, changed: changed)[:rows].find { |l, _, _| l == "Changed" }[1]
430
+ table << "| #{e[:id]} | #{savepoint['stage']} | #{progress['progress.bar']} #{progress['progress.done']} / #{progress['progress.total']} | #{escape(ch)} | #{lead(e[:dir])} |"
431
+ end
432
+ blocks = entries.map { |e| render_collapsed_block(e[:dir], store_root, changed: changed) }
433
+ head_and_table = ([header, ""] + table).join("\n")
434
+ # Each collapsed block already has its own internal "\n"; a blank line
435
+ # separates block from block (design--delivery-reports.html:137-152),
436
+ # so they read as distinct entries instead of running together.
437
+ "#{head_and_table}\n\n#{blocks.join("\n\n")}\n"
438
+ end
439
+
440
+ # --- S6: the delivered verb ------------------------------------------------------
441
+
442
+ def self.delivered_timestamp(intent_dir)
443
+ lines = savepoint_lines(intent_dir)
444
+ done = lines.reverse.find { |_ts, kind, _text| kind == "Done" }
445
+ done ? human_time(done[0]) : NOT_RECORDED
446
+ end
447
+
448
+ def self.render_delivered(intent_dir:, tag_reader: ->(_dir) { nil })
449
+ id = intent_id(intent_dir)
450
+ name = title_for(intent_dir, default_store_root(intent_dir))
451
+ ts = delivered_timestamp(intent_dir)
452
+ m = mode(intent_dir)
453
+ dur = duration(intent_dir)
454
+ version = tag_reader.call(intent_dir)
455
+ ver_text = version && !version.to_s.empty? ? "v#{version.to_s.sub(/\Av/, '')}" : NOT_RECORDED
456
+
457
+ lines = []
458
+ lines << "## ✔ #{id} · #{name} · delivered"
459
+ lines << "#{ts} · #{m} · #{dur} · #{ver_text}"
460
+ lines << ""
461
+ lines << "**Asked**"
462
+ lines << " #{asked(intent_dir)}"
463
+ lines << " #{decision_count(intent_dir)} decisions in spec.md"
464
+ lines << ""
465
+ lines << "**Delivered**"
466
+ lines << "| Row | What | Proven by |"
467
+ lines << "| --- | --- | --- |"
468
+ delivered_rows(intent_dir).each do |r|
469
+ lines << "| #{r[:label]} | #{escape(r[:text])} | #{escape(proven_by(intent_dir, r[:label]))} |"
470
+ end
471
+ lines << ""
472
+ lines << "**Evidence**"
473
+ lines << "| Kind | What | Source |"
474
+ lines << "| --- | --- | --- |"
475
+ evidence_rows(intent_dir, tag_reader: tag_reader).each do |r|
476
+ lines << "| #{r[:kind]} | #{escape(r[:what])} | #{escape(r[:source])} |"
477
+ end
478
+ lines << ""
479
+ needsyou = needs_you_rows(intent_dir)
480
+ lines << "**Needs you**"
481
+ if needsyou.empty?
482
+ lines << "None"
483
+ else
484
+ lines << "| N | What | Why |"
485
+ lines << "| --- | --- | --- |"
486
+ needsyou.each { |r| lines << "| #{r[:n]} | #{escape(r[:what])} | #{escape(r[:why])} |" }
487
+ end
488
+ "#{lines.join("\n")}\n"
489
+ end
490
+
491
+ # --- S7: the delay verb -----------------------------------------------------------
492
+
493
+ def self.delay_timeline(intent_dir)
494
+ savepoint_lines(intent_dir).map { |ts, kind, text| { ts: ts, kind: kind, text: text } }
495
+ end
496
+
497
+ def self.longest_gap(timeline)
498
+ return nil if timeline.length < 2
499
+ best = nil
500
+ timeline.each_cons(2) do |a, b|
501
+ secs = (Time.parse(b[:ts]) - Time.parse(a[:ts])).to_i
502
+ best = { secs: secs, a: a[:kind], b: b[:kind] } if best.nil? || secs > best[:secs]
503
+ end
504
+ "longest gap #{best[:secs] / 60} min, #{best[:a]} to #{best[:b]}"
505
+ end
506
+
507
+ def self.where_time_went(timeline)
508
+ gap = longest_gap(timeline)
509
+
510
+ unless timeline.any? { |r| %w[Review Commit].include?(r[:kind]) }
511
+ parts = ["the review and commit ledger was not kept for this intent"]
512
+ parts << gap if gap
513
+ return parts.join(" · ")
514
+ end
515
+
516
+ rounds = timeline.count { |r| r[:kind] == "Review" }
517
+ commits = timeline.count { |r| r[:kind] == "Commit" }
518
+ parts = []
519
+ parts << "reviews #{rounds} round#{rounds == 1 ? '' : 's'}" if rounds.positive?
520
+ parts << "#{commits} commit#{commits == 1 ? '' : 's'}" if commits.positive?
521
+ parts << gap if gap
522
+ parts.join(" · ")
523
+ end
524
+
525
+ def self.hhmm(ts)
526
+ m = ts.match(/T(\d\d:\d\d)/)
527
+ m ? m[1] : ts
528
+ end
529
+
530
+ def self.delay_outcome_line(intent_dir)
531
+ text = outcome_text(intent_dir)
532
+ return NOT_RECORDED unless text
533
+ section = section_of(text, "## Summary")
534
+ # The first PARAGRAPH, not just its first physical line - outcome.md's
535
+ # prose is hand-wrapped at ~100 columns, so a single logical sentence
536
+ # spans several source lines.
537
+ paragraph = section.lstrip.split(/\n\s*\n/, 2).first.to_s.lines.map(&:strip).join(" ").strip
538
+ return NOT_RECORDED if paragraph.empty?
539
+ doc = doctor_row(text)
540
+ doc ? "#{paragraph} · #{doc[:what]}" : paragraph
541
+ end
542
+
543
+ def self.render_delay(intent_dir:)
544
+ id = intent_id(intent_dir)
545
+ name = title_for(intent_dir, default_store_root(intent_dir))
546
+ dur = duration(intent_dir)
547
+ timeline = delay_timeline(intent_dir)
548
+
549
+ lines = []
550
+ lines << "✔ #{id} · #{name} · delivered in #{dur}"
551
+ lines << ""
552
+ timeline.each { |r| lines << "#{hhmm(r[:ts])} #{r[:kind]} #{escape(r[:text])}" }
553
+ lines << ""
554
+ lines << "**Where the time went** #{where_time_went(timeline)}"
555
+ lines << ""
556
+ lines << "**Outcome** #{delay_outcome_line(intent_dir)}"
557
+ "#{lines.join("\n")}\n"
558
+ end
559
+
560
+ # --- S8: --ansi passthrough (D2) -----------------------------------------------
561
+ #
562
+ # 316a owns the ANSI renderer; 317 only wires a generic DI seam so this
563
+ # module never blocks on 316a landing and never breaks when it does (row 77).
564
+ # A renderer file, when present, is expected to define IntentScreenAnsi.paint
565
+ # (one plain-text string in, one string out). Wiring the real contract 316a
566
+ # ships is left to a follow-up step once that file exists (see checklist S14).
567
+ def self.maybe_paint(text, renderer_path:, enabled:)
568
+ return text unless enabled
569
+ return text unless renderer_path && File.exist?(renderer_path)
570
+
571
+ begin
572
+ require renderer_path
573
+ rescue LoadError, StandardError
574
+ return text
575
+ end
576
+
577
+ mod = Object.const_get(:IntentScreenAnsi) if Object.const_defined?(:IntentScreenAnsi)
578
+ return text unless mod && mod.respond_to?(:paint)
579
+
580
+ begin
581
+ mod.paint(text)
582
+ rescue StandardError
583
+ text
584
+ end
585
+ end
586
+ end
@@ -213,6 +213,20 @@ module Savepoint
213
213
  append_savepoint_line(intent_dir, "Exec", "started", now)
214
214
  end
215
215
 
216
+ # Append a `Review` line: one per plan-review or post-execution-review verdict
217
+ # (intent 317, D5/D17). Same shape as every other line, through the shared
218
+ # append_savepoint_line primitive, so dedup and the timestamp format never
219
+ # drift from the one line-writer every other kind already uses.
220
+ def self.append_review_savepoint(intent_dir, text, now: Time.now)
221
+ append_savepoint_line(intent_dir, "Review", text, now)
222
+ end
223
+
224
+ # Append a `Commit` line: one per commit landing during Exec (intent 317,
225
+ # D5/D17). Same primitive as append_review_savepoint above.
226
+ def self.append_commit_savepoint(intent_dir, text, now: Time.now)
227
+ append_savepoint_line(intent_dir, "Commit", text, now)
228
+ end
229
+
216
230
  TERMINAL_DISPOSITIONS = %w[delivered abandoned].freeze
217
231
 
218
232
  # Append the terminal bookend `Done delivered|abandoned`, written by the