@zalom/plastic 2.0.0-alpha.7 → 2.0.0-alpha.9

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,648 @@
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
+ PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
143
+
144
+ # 317a S3 (A6): the note under Asked. Bulleted decisions in a real spec keep
145
+ # the historic "N decisions in spec.md"; a prose ## Decisions falls back to
146
+ # the highest D<n> it names; a placeholder spec falls through to the intent
147
+ # record's "### Decisions" (which section_of's "^## " anchor cannot reach);
148
+ # nothing anywhere says "decisions not recorded" - never a false 0, and the
149
+ # scaffold's "- ..." never counts as 1.
150
+ def self.decision_note(intent_dir)
151
+ spec = spec_text(intent_dir)
152
+ if spec && !spec.lstrip.start_with?(PLACEHOLDER_SENTINEL)
153
+ n = decisions_in(section_of(spec, "## Decisions"))
154
+ return "#{n} decisions in spec.md" if n.positive?
155
+ end
156
+ n = decisions_in(intent_text(intent_dir).to_s.split(/^### Decisions\s*$/, 2)[1].to_s.split(/^#+ /, 2)[0])
157
+ return "#{n} decisions in the intent record" if n.positive?
158
+ "decisions not recorded"
159
+ end
160
+
161
+ def self.decisions_in(body)
162
+ bullets = body.to_s.lines.count { |l| s = l.lstrip; s.start_with?("- ") && s.strip != "- ..." }
163
+ return bullets if bullets.positive?
164
+ body.to_s.scan(/\bD(\d{1,3})\b/).flatten.map(&:to_i).max.to_i
165
+ end
166
+
167
+ # Row 22: bullets under spec.md's ## Decisions only.
168
+ def self.decision_count(intent_dir)
169
+ text = spec_text(intent_dir)
170
+ return NOT_RECORDED unless text
171
+ section_of(text, "## Decisions").lines.count { |l| l.lstrip.start_with?("- ") }
172
+ end
173
+
174
+ # Rows 23/24: outcome.md's ## Delivered, table or bullet form.
175
+ def self.delivered_rows(intent_dir)
176
+ text = outcome_text(intent_dir)
177
+ return [] unless text
178
+ section = section_of(text, "## Delivered")
179
+ return [] if section.strip.empty?
180
+
181
+ rows = table_rows(section)
182
+ return rows.map { |cells| { label: cells[0].to_s, text: cells[1].to_s } } if rows.any?
183
+
184
+ bullet_rows(section).each_with_index.map do |text, i|
185
+ { label: (i + 1).to_s, text: text }
186
+ end
187
+ end
188
+
189
+ # 317a S1 (matrix S1a/S1b): a bullet row is its "- " line PLUS its wrapped
190
+ # continuation lines - outcome prose is hand-wrapped at ~100 columns, and
191
+ # taking one physical line truncated every real record mid-sentence. A blank
192
+ # line or a heading ends the row; prose after a blank is never swept in.
193
+ def self.bullet_rows(section)
194
+ rows = []
195
+ section.to_s.each_line do |line|
196
+ stripped = line.strip
197
+ if line.lstrip.start_with?("- ")
198
+ rows << line.lstrip.sub(/\A-\s*/, "").strip
199
+ elsif stripped.empty? || line.start_with?("#")
200
+ rows << nil unless rows.empty? || rows.last.nil?
201
+ elsif !rows.empty? && !rows.last.nil?
202
+ rows[rows.length - 1] = "#{rows.last} #{stripped}"
203
+ end
204
+ end
205
+ rows.compact
206
+ end
207
+
208
+ # Rows 25-27: D19 - the label must appear as a standalone token in an action
209
+ # file heading (any level); the count is the matched section's table rows only.
210
+ def self.matching_action_heading(intent_dir, label)
211
+ Dir.glob(File.join(intent_dir, "actions", "*.md")).sort.each do |path|
212
+ split_by_headings(File.read(path)).each do |heading, body|
213
+ tokens = heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/)
214
+ return [heading, body] if tokens.include?(label)
215
+ end
216
+ end
217
+ [nil, nil]
218
+ end
219
+
220
+ def self.proven_by(intent_dir, label)
221
+ _heading, body = matching_action_heading(intent_dir, label)
222
+ return NOT_RECORDED unless body
223
+ n = table_rows(body).length
224
+ n.positive? ? "#{n} test#{n == 1 ? '' : 's'}" : NOT_RECORDED
225
+ end
226
+
227
+ # Row 34: outcome.md's ## Needs you, our own N1..NN numbering (never the
228
+ # table's own N column, which could be malformed).
229
+ def self.needs_you_rows(intent_dir)
230
+ text = outcome_text(intent_dir)
231
+ return [] unless text
232
+ return [] unless text.include?("## Needs you")
233
+ section = section_of(text, "## Needs you")
234
+ rows = table_rows(section)
235
+ if rows.any?
236
+ return rows.each_with_index.map do |cells, i|
237
+ { n: "N#{i + 1}", what: cells[1].to_s, why: cells[2].to_s }
238
+ end
239
+ end
240
+
241
+ # 317a S2 (matrix S2a): prose that exists must never render as None - the
242
+ # 317 record hid three owner picks behind exactly that. One joined row,
243
+ # why "not recorded"; a literal None (or an empty section) stays [].
244
+ content = section.gsub(/<!--.*?-->/m, "").strip
245
+ return [] if content.empty? || content == "None"
246
+
247
+ what = content.lines.map(&:strip).reject(&:empty?)
248
+ .join(" ").sub(/\A-\s*/, "").squeeze(" ")
249
+ [{ n: "N1", what: what, why: NOT_RECORDED }]
250
+ end
251
+
252
+ # Row 35: first-to-last savepoint timestamp, "1 h 51 min" / "n min".
253
+ def self.duration(intent_dir)
254
+ lines = savepoint_lines(intent_dir)
255
+ return NOT_RECORDED if lines.length < 2
256
+ secs = (Time.parse(lines.last[0]) - Time.parse(lines.first[0])).to_i
257
+ format_duration(secs)
258
+ end
259
+
260
+ def self.format_duration(secs)
261
+ mins = [secs, 0].max / 60
262
+ return "#{mins} min" if mins < 60
263
+ "#{mins / 60} h #{mins % 60} min"
264
+ end
265
+
266
+ # Row 36 (D20): mode from the LIVE delivery lock's run_mode; absent -> not recorded.
267
+ def self.mode(intent_dir)
268
+ data = Lock.read(intent_dir)
269
+ value = data && data["run_mode"]
270
+ return value.to_s if value && !value.to_s.empty?
271
+
272
+ # 317a S7 (D5): after the close the lock is gone; end-intent stamps the
273
+ # run_mode into outcome.md frontmatter, so mode stops being unknowable
274
+ # retrospectively. Live lock first - it is the source of truth mid-flight.
275
+ value = outcome_frontmatter(intent_dir)["mode"]
276
+ value && !value.to_s.empty? ? value.to_s : NOT_RECORDED
277
+ end
278
+
279
+ def self.outcome_frontmatter(intent_dir)
280
+ text = outcome_text(intent_dir)
281
+ return {} unless text && text.start_with?("---")
282
+ parts = text.split("---", 3)
283
+ return {} if parts.length < 3
284
+ require "yaml"
285
+ require "date"
286
+ YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
287
+ rescue StandardError
288
+ {}
289
+ end
290
+
291
+ # --- evidence rows (rows 28-33, 37) --------------------------------------------
292
+
293
+ def self.suite_row(section)
294
+ m = section.match(/([\d,]+)\s*runs,\s*([\d,]+)\s*assertions,\s*([\d,]+)\s*failures/)
295
+ return nil unless m
296
+ { kind: "suite", what: "#{m[1]} runs · #{m[2]} assertions · #{m[3]} failures", source: "outcome.md ## Verification" }
297
+ end
298
+
299
+ def self.red_row(section)
300
+ line = section.lines.find { |l| l =~ /\bred\b/i && l =~ /`([0-9a-f]{7,40})`/ }
301
+ return nil unless line
302
+ sha = line.match(/`([0-9a-f]{7,40})`/)[1]
303
+ { kind: "red", what: "#{sha} proven test-only and red", source: "outcome.md ## Verification" }
304
+ end
305
+
306
+ def self.ship_row(text, intent_dir, tag_reader)
307
+ line = text.to_s.lines.find { |l| l =~ /\bmerge(d)?\b/i && l =~ /\b[0-9a-f]{7,40}\b/ }
308
+ sha = line && line.match(/\b([0-9a-f]{7,40})\b/)[1]
309
+ version = tag_reader.call(intent_dir)
310
+ return nil if sha.nil? && (version.nil? || version.to_s.empty?)
311
+ ver_text = version && !version.to_s.empty? ? "v#{version.to_s.sub(/\Av/, '')}" : NOT_RECORDED
312
+ sha_text = sha || NOT_RECORDED
313
+ { kind: "ship", what: "#{sha_text} → alpha · #{ver_text}", source: "outcome.md; git tags" }
314
+ end
315
+
316
+ def self.doctor_row(text)
317
+ m = text.to_s.match(/(\d+)\s*pass,?\s*(\d+)\s*warn,?\s*(\d+)\s*fail/i)
318
+ return nil unless m
319
+ { kind: "doctor", what: "#{m[1]} pass · #{m[2]} warn · #{m[3]} fail", source: "outcome.md" }
320
+ end
321
+
322
+ def self.deviates_row(section)
323
+ line = section.lines.find { |l| l.lstrip.sub(/\A-\s*/, "").start_with?("Deviation:") }
324
+ return nil unless line
325
+ text = line.lstrip.sub(/\A-\s*/, "").strip
326
+ { kind: "deviates", what: text, source: "outcome.md ## Verification — Deviation:" }
327
+ end
328
+
329
+ def self.deposits_row(text)
330
+ line = text.to_s.lines.find { |l| l =~ %r{`resources/[^`]+`} }
331
+ return nil unless line
332
+ path = line.match(%r{`(resources/[^`]+)`})[1]
333
+ { kind: "deposits", what: path, source: "outcome.md" }
334
+ end
335
+
336
+ def self.verdict_row(text)
337
+ m = text.to_s.match(/verdict[:\s]+([A-Za-z][A-Za-z ]*)/i)
338
+ return nil unless m
339
+ { kind: "verdict", what: m[1].strip, source: "outcome.md" }
340
+ end
341
+
342
+ def self.evidence_rows(intent_dir, tag_reader: ->(_dir) { nil })
343
+ text = outcome_text(intent_dir)
344
+ return [] unless text
345
+ verification = section_of(text, "## Verification")
346
+
347
+ rows = []
348
+ rows << suite_row(verification)
349
+ rows << red_row(verification)
350
+ rows << (research_intent?(intent_dir) ? nil : ship_row(text, intent_dir, tag_reader))
351
+ if research_intent?(intent_dir)
352
+ rows << deposits_row(text)
353
+ rows << verdict_row(text)
354
+ end
355
+ rows << doctor_row(text)
356
+ rows << deviates_row(verification)
357
+ rows.compact
358
+ end
359
+
360
+ # --- S4/S5: the state verb and the --all roster --------------------------------
361
+
362
+ CHANGED_NOTE = "the reason this screen printed"
363
+
364
+ def self.state_fields(intent_dir:, store_root:, changed:)
365
+ base = intent_basename(intent_dir)
366
+ id = base.split("--", 2).first
367
+ text = intent_text(intent_dir)
368
+ status, title = IntentScreen.index_fields(store_root, id)
369
+ name = title || IntentScreen.fallback_name(text.to_s)
370
+
371
+ f = {}
372
+ f.merge!(IntentScreen.store_fields(store_root))
373
+ f["status"] = status
374
+ f["status.note"] = status == "unlisted" ? "no INDEX.md line names this id" : "listed under ## #{status} in INDEX.md"
375
+ f.merge!(IntentScreen.savepoint_fields(intent_dir, text.to_s))
376
+ items = IntentScreen.checklist_items(intent_dir)
377
+ f.merge!(IntentScreen.progress_fields(items))
378
+ f.merge!(IntentScreen.next_fields(items, status, checklist_present: IntentScreen.items_present?(intent_dir)))
379
+ f.merge!(IntentScreen.insight_fields(text.to_s))
380
+
381
+ changed_value = changed && !changed.to_s.empty? ? changed.to_s : "on request"
382
+
383
+ rows = [
384
+ ["Store", f["store"], f["store.note"]],
385
+ ["Status", f["status"], f["status.note"]],
386
+ ["Stage", f["stage"], f["stage.note"]],
387
+ ["Savepoint", f["savepoint"], f["savepoint.note"]],
388
+ ["Progress", "#{f['progress.bar']} #{f['progress.done']} / #{f['progress.total']}", f["progress.note"]],
389
+ ["Next", f["next"], f["next.note"]],
390
+ ["Insight", f["insight"], f["insight.note"]],
391
+ ["Changed", changed_value, CHANGED_NOTE],
392
+ ]
393
+ { id: id, name: name, rows: rows, items: items }
394
+ end
395
+
396
+ # Rows 42-46: pad BOTH columns to the widest NOTED label/value, computed on
397
+ # the raw emitted (already-escaped) cell text; unnoted rows carry no padding.
398
+ def self.state_rows(rows)
399
+ escaped = rows.map { |label, value, note| ["**#{label}**", escape(value), escape(note)] }
400
+ noted = escaped.select { |_, _, note| !note.to_s.empty? }
401
+ label_w = noted.map { |l, _, _| l.length }.max || 0
402
+ value_w = noted.map { |_, v, _| v.length }.max || 0
403
+ escaped.map do |label, value, note|
404
+ if note.to_s.empty?
405
+ "| #{label} | #{value} | |"
406
+ else
407
+ "| #{label.ljust(label_w)} | #{value.ljust(value_w)} | #{note} |"
408
+ end
409
+ end
410
+ end
411
+
412
+ def self.render_state(intent_dir:, store_root:, changed:, template:)
413
+ data = state_fields(intent_dir: intent_dir, store_root: store_root, changed: changed)
414
+ out = template.dup
415
+ out = out.gsub("{{id}}", data[:id])
416
+ out = out.gsub("{{name}}", data[:name])
417
+ out = out.gsub("{{fields.rows}}", state_rows(data[:rows]).join("\n"))
418
+ out = out.gsub("{{steps.rows}}", IntentScreen.steps_rows(data[:items]))
419
+ out.gsub(/\n{3,}/, "\n\n")
420
+ end
421
+
422
+ # --- roster (D7/D8) -------------------------------------------------------------
423
+
424
+ def self.active_dirnames(index_path)
425
+ return [] unless File.exist?(index_path)
426
+ dirnames = []
427
+ section = nil
428
+ File.foreach(index_path) do |line|
429
+ if line.start_with?("## ")
430
+ section = line[3..].strip
431
+ next
432
+ end
433
+ next unless section == "Active"
434
+ m = line.match(%r{\(store/([^/]+)/})
435
+ dirnames << m[1] if m
436
+ end
437
+ dirnames
438
+ end
439
+
440
+ def self.newest_savepoint_ts(intent_dir)
441
+ lines = savepoint_lines(intent_dir)
442
+ lines.last&.first
443
+ end
444
+
445
+ def self.roster(store_root)
446
+ index_path = File.join(store_root, "INDEX.md")
447
+ entries = active_dirnames(index_path).filter_map do |dirname|
448
+ dir = File.join(store_root, "store", dirname)
449
+ next unless File.directory?(dir)
450
+ text = intent_text(dir).to_s
451
+ fields = IntentScreen.savepoint_fields(dir, text)
452
+ next if fields["stage"] == "Done"
453
+ { dir: dir, id: dirname.split("--", 2).first, ts: newest_savepoint_ts(dir) }
454
+ end
455
+ entries.sort_by { |e| [-(e[:ts] ? Time.parse(e[:ts]).to_i : 0), e[:id]] }
456
+ end
457
+
458
+ def self.lead(intent_dir)
459
+ data = Lock.read(intent_dir)
460
+ return "idle" unless data
461
+ agent = data["owner_agent"].to_s
462
+ session = data["owner_session"].to_s
463
+ return "idle" if agent.empty? && session.empty?
464
+ "#{agent.empty? ? 'unknown' : agent} · #{session[0, 8]}"
465
+ rescue StandardError
466
+ "idle"
467
+ end
468
+
469
+ def self.collapsed_open_steps_note(count)
470
+ count <= 3 ? "#{count} open" : "#{count} open · showing the first three"
471
+ end
472
+
473
+ def self.render_collapsed_block(intent_dir, store_root, changed:)
474
+ data = state_fields(intent_dir: intent_dir, store_root: store_root, changed: changed)
475
+ stage = data[:rows].find { |l, _, _| l == "Stage" }[1]
476
+ nxt = data[:rows].find { |l, _, _| l == "Next" }[1]
477
+ ch = data[:rows].find { |l, _, _| l == "Changed" }[1]
478
+
479
+ open_items = data[:items].each_with_index.reject { |item, _| item[:done] }
480
+ lines = []
481
+ lines << "▶ #{data[:id]} · #{data[:name]}"
482
+ lines << "Stage #{stage}"
483
+ lines << "Next #{nxt}"
484
+ lines << "Changed #{ch}"
485
+ lines << collapsed_open_steps_note(open_items.length)
486
+ open_items.first(3).each { |item, i| lines << "S#{i + 1} [ open ] #{escape(item[:text])}" }
487
+ lines.join("\n")
488
+ end
489
+
490
+ def self.render_roster(store_root, changed: nil, now: Time.now)
491
+ entries = roster(store_root)
492
+ return "No intents in delivery.\n" if entries.empty?
493
+
494
+ header = "▶ In delivery · #{entries.length} #{entries.length == 1 ? 'intent' : 'intents'} · " \
495
+ "#{now.utc.strftime('%Y-%m-%d %H:%M UTC')}"
496
+ table = ["| Intent | Stage | Progress | Changed | Lead |", "| --- | --- | --- | --- | --- |"]
497
+ entries.each do |e|
498
+ text = intent_text(e[:dir]).to_s
499
+ savepoint = IntentScreen.savepoint_fields(e[:dir], text)
500
+ items = IntentScreen.checklist_items(e[:dir])
501
+ progress = IntentScreen.progress_fields(items)
502
+ ch = state_fields(intent_dir: e[:dir], store_root: store_root, changed: changed)[:rows].find { |l, _, _| l == "Changed" }[1]
503
+ table << "| #{e[:id]} | #{savepoint['stage']} | #{progress['progress.bar']} #{progress['progress.done']} / #{progress['progress.total']} | #{escape(ch)} | #{lead(e[:dir])} |"
504
+ end
505
+ blocks = entries.map { |e| render_collapsed_block(e[:dir], store_root, changed: changed) }
506
+ head_and_table = ([header, ""] + table).join("\n")
507
+ # Each collapsed block already has its own internal "\n"; a blank line
508
+ # separates block from block (design--delivery-reports.html:137-152),
509
+ # so they read as distinct entries instead of running together.
510
+ "#{head_and_table}\n\n#{blocks.join("\n\n")}\n"
511
+ end
512
+
513
+ # --- S6: the delivered verb ------------------------------------------------------
514
+
515
+ def self.delivered_timestamp(intent_dir)
516
+ lines = savepoint_lines(intent_dir)
517
+ done = lines.reverse.find { |_ts, kind, _text| kind == "Done" }
518
+ done ? human_time(done[0]) : NOT_RECORDED
519
+ end
520
+
521
+ def self.render_delivered(intent_dir:, tag_reader: ->(_dir) { nil })
522
+ id = intent_id(intent_dir)
523
+ name = title_for(intent_dir, default_store_root(intent_dir))
524
+ ts = delivered_timestamp(intent_dir)
525
+ m = mode(intent_dir)
526
+ dur = duration(intent_dir)
527
+ version = tag_reader.call(intent_dir)
528
+ ver_text = version && !version.to_s.empty? ? "v#{version.to_s.sub(/\Av/, '')}" : NOT_RECORDED
529
+
530
+ lines = []
531
+ lines << "## ✔ #{id} · #{name} · delivered"
532
+ lines << "#{ts} · #{m} · #{dur} · #{ver_text}"
533
+ lines << ""
534
+ lines << "**Asked**"
535
+ lines << " #{asked(intent_dir)}"
536
+ lines << " #{decision_note(intent_dir)}"
537
+ lines << ""
538
+ lines << "**Delivered**"
539
+ lines << "| Row | What | Proven by |"
540
+ lines << "| --- | --- | --- |"
541
+ delivered_rows(intent_dir).each do |r|
542
+ lines << "| #{r[:label]} | #{escape(r[:text])} | #{escape(proven_by(intent_dir, r[:label]))} |"
543
+ end
544
+ lines << ""
545
+ lines << "**Evidence**"
546
+ ev = evidence_rows(intent_dir, tag_reader: tag_reader)
547
+ if ev.empty?
548
+ # 317a S4 (matrix S4a): a header-only table (319's live rendering) says
549
+ # nothing; the honest floor is the same phrase every other absent source
550
+ # prints.
551
+ lines << NOT_RECORDED
552
+ else
553
+ lines << "| Kind | What | Source |"
554
+ lines << "| --- | --- | --- |"
555
+ ev.each do |r|
556
+ lines << "| #{r[:kind]} | #{escape(r[:what])} | #{escape(r[:source])} |"
557
+ end
558
+ end
559
+ lines << ""
560
+ needsyou = needs_you_rows(intent_dir)
561
+ lines << "**Needs you**"
562
+ if needsyou.empty?
563
+ lines << "None"
564
+ else
565
+ lines << "| N | What | Why |"
566
+ lines << "| --- | --- | --- |"
567
+ needsyou.each { |r| lines << "| #{r[:n]} | #{escape(r[:what])} | #{escape(r[:why])} |" }
568
+ end
569
+ "#{lines.join("\n")}\n"
570
+ end
571
+
572
+ # --- S7: the delay verb -----------------------------------------------------------
573
+
574
+ def self.delay_timeline(intent_dir)
575
+ savepoint_lines(intent_dir).map { |ts, kind, text| { ts: ts, kind: kind, text: text } }
576
+ end
577
+
578
+ def self.longest_gap(timeline)
579
+ return nil if timeline.length < 2
580
+ best = nil
581
+ timeline.each_cons(2) do |a, b|
582
+ secs = (Time.parse(b[:ts]) - Time.parse(a[:ts])).to_i
583
+ best = { secs: secs, a: a[:kind], b: b[:kind] } if best.nil? || secs > best[:secs]
584
+ end
585
+ "longest gap #{best[:secs] / 60} min, #{best[:a]} to #{best[:b]}"
586
+ end
587
+
588
+ def self.where_time_went(timeline)
589
+ gap = longest_gap(timeline)
590
+
591
+ unless timeline.any? { |r| %w[Review Commit].include?(r[:kind]) }
592
+ parts = ["the review and commit ledger was not kept for this intent"]
593
+ parts << gap if gap
594
+ return parts.join(" · ")
595
+ end
596
+
597
+ rounds = timeline.count { |r| r[:kind] == "Review" }
598
+ commits = timeline.count { |r| r[:kind] == "Commit" }
599
+ parts = []
600
+ parts << "reviews #{rounds} round#{rounds == 1 ? '' : 's'}" if rounds.positive?
601
+ parts << "#{commits} commit#{commits == 1 ? '' : 's'}" if commits.positive?
602
+ parts << gap if gap
603
+ parts.join(" · ")
604
+ end
605
+
606
+ def self.hhmm(ts)
607
+ m = ts.match(/T(\d\d:\d\d)/)
608
+ m ? m[1] : ts
609
+ end
610
+
611
+ def self.delay_outcome_line(intent_dir)
612
+ text = outcome_text(intent_dir)
613
+ return NOT_RECORDED unless text
614
+ section = section_of(text, "## Summary")
615
+ # The first PARAGRAPH, not just its first physical line - outcome.md's
616
+ # prose is hand-wrapped at ~100 columns, so a single logical sentence
617
+ # spans several source lines.
618
+ paragraph = section.lstrip.split(/\n\s*\n/, 2).first.to_s.lines.map(&:strip).join(" ").strip
619
+ return NOT_RECORDED if paragraph.empty?
620
+ doc = doctor_row(text)
621
+ doc ? "#{paragraph} · #{doc[:what]}" : paragraph
622
+ end
623
+
624
+ def self.render_delay(intent_dir:)
625
+ id = intent_id(intent_dir)
626
+ name = title_for(intent_dir, default_store_root(intent_dir))
627
+ dur = duration(intent_dir)
628
+ timeline = delay_timeline(intent_dir)
629
+
630
+ lines = []
631
+ lines << "✔ #{id} · #{name} · delivered in #{dur}"
632
+ lines << ""
633
+ timeline.each { |r| lines << "#{hhmm(r[:ts])} #{r[:kind]} #{escape(r[:text])}" }
634
+ lines << ""
635
+ lines << "**Where the time went** #{where_time_went(timeline)}"
636
+ lines << ""
637
+ lines << "**Outcome** #{delay_outcome_line(intent_dir)}"
638
+ "#{lines.join("\n")}\n"
639
+ end
640
+
641
+ # --- S8: --ansi passthrough (D2) -----------------------------------------------
642
+ #
643
+ # 316a owns the ANSI renderer; 317 only wires a generic DI seam so this
644
+ # module never blocks on 316a landing and never breaks when it does (row 77).
645
+ # A renderer file, when present, is expected to define IntentScreenAnsi.paint
646
+ # (one plain-text string in, one string out). Wiring the real contract 316a
647
+ # ships is left to a follow-up step once that file exists (see checklist S14).
648
+ 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