@zalom/plastic 2.0.0-alpha.1 → 2.0.0-alpha.11

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 (55) hide show
  1. package/bin/lib/context_budget.rb +453 -0
  2. package/bin/plastic-bench +78 -0
  3. package/hooks/hooks.json +12 -0
  4. package/hooks/message-display +81 -0
  5. package/hooks/savepoint +5 -5
  6. package/package.json +1 -1
  7. package/scripts/agent-report +8 -2
  8. package/scripts/append-ledger +16 -3
  9. package/scripts/dashboard.rb +39 -10
  10. package/scripts/day-summary +53 -0
  11. package/scripts/doctor.rb +163 -0
  12. package/scripts/end-intent +93 -0
  13. package/scripts/hook-capture +21 -8
  14. package/scripts/hook-close +3 -1
  15. package/scripts/hook-message-display +74 -0
  16. package/scripts/hook-record +12 -4
  17. package/scripts/hook-savepoint +45 -0
  18. package/scripts/hook-session-start +34 -1
  19. package/scripts/intent-screen +77 -0
  20. package/scripts/lib/arm.rb +26 -1
  21. package/scripts/lib/compact_instructions.rb +56 -0
  22. package/scripts/lib/day_summary.rb +211 -0
  23. package/scripts/lib/doctor_core.rb +52 -3
  24. package/scripts/lib/doctor_session_ledger.rb +52 -0
  25. package/scripts/lib/handoff.rb +184 -0
  26. package/scripts/lib/hook_registry.rb +14 -0
  27. package/scripts/lib/installer_core.rb +117 -11
  28. package/scripts/lib/intent_screen.rb +309 -0
  29. package/scripts/lib/intent_screen_ansi.rb +262 -0
  30. package/scripts/lib/message_display.rb +290 -0
  31. package/scripts/lib/report_screen.rb +671 -0
  32. package/scripts/lib/savepoint.rb +14 -0
  33. package/scripts/lib/screen_paint.rb +276 -0
  34. package/scripts/lib/session_close.rb +22 -2
  35. package/scripts/lib/session_git.rb +49 -18
  36. package/scripts/lib/session_ledger.rb +124 -0
  37. package/scripts/plastic-lock +8 -1
  38. package/scripts/read-config +3 -0
  39. package/scripts/report-screen +157 -0
  40. package/scripts/rollback.rb +6 -0
  41. package/scripts/savepoint-note +67 -0
  42. package/scripts/spawn-preamble +9 -2
  43. package/scripts/write-handoff +60 -0
  44. package/skills/auto/SKILL.md +15 -8
  45. package/skills/auto/references/human-report-contract.md +59 -53
  46. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  47. package/skills/intent-continuing/SKILL.md +31 -22
  48. package/skills/intent-continuing/references/boarding-matrix.md +5 -5
  49. package/skills/intent-continuing/references/context-management.md +1 -1
  50. package/skills/intent-ending/SKILL.md +8 -2
  51. package/skills/intent-executing/SKILL.md +6 -0
  52. package/templates/config.yml +5 -0
  53. package/templates/intent-screen.md +17 -0
  54. package/templates/outcome.md +14 -1
  55. package/templates/report-state.md +11 -0
@@ -0,0 +1,671 @@
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
+ # Fix 2026-09-01: the record is the truth of delivery (D14: never a guess).
307
+ # The shipped version comes from outcome.md's own ship line first ("Shipped
308
+ # as `v2.0.0-alpha.10`", "released as **v2.0.0-alpha.5**", "released
309
+ # v2.0.0-alpha.9", "Tagged v1.14.1", "Delivered in", "Release v"); the injected tag reader (git) is the
310
+ # fallback when the record is silent. A bare version with no ship verb
311
+ # ("from 1.14.1") is not a shipped version.
312
+ 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
313
+
314
+ def self.shipped_version(intent_dir)
315
+ text = outcome_text(intent_dir)
316
+ return nil unless text
317
+ m = text.match(SHIP_VERSION_RE)
318
+ m && m[1]
319
+ end
320
+
321
+ # The merge commit named on outcome.md's merge line, or nil. The CLI's tag
322
+ # reader asks git which tag contains it; the ship row prints it.
323
+ def self.merge_sha(intent_dir)
324
+ text = outcome_text(intent_dir)
325
+ return nil unless text
326
+ line = text.lines.find { |l| l =~ /\bmerge(d)?\b/i && l =~ /\b[0-9a-f]{7,40}\b/ }
327
+ line && line.match(/\b([0-9a-f]{7,40})\b/)[1]
328
+ end
329
+
330
+ def self.ship_row(_text, intent_dir, tag_reader)
331
+ sha = merge_sha(intent_dir)
332
+ version = shipped_version(intent_dir) || tag_reader.call(intent_dir)
333
+ return nil if sha.nil? && (version.nil? || version.to_s.empty?)
334
+ ver_text = version && !version.to_s.empty? ? "v#{version.to_s.sub(/\Av/, '')}" : NOT_RECORDED
335
+ sha_text = sha || NOT_RECORDED
336
+ { kind: "ship", what: "#{sha_text} → alpha · #{ver_text}", source: "outcome.md; git tags" }
337
+ end
338
+
339
+ def self.doctor_row(text)
340
+ m = text.to_s.match(/(\d+)\s*pass,?\s*(\d+)\s*warn,?\s*(\d+)\s*fail/i)
341
+ return nil unless m
342
+ { kind: "doctor", what: "#{m[1]} pass · #{m[2]} warn · #{m[3]} fail", source: "outcome.md" }
343
+ end
344
+
345
+ def self.deviates_row(section)
346
+ line = section.lines.find { |l| l.lstrip.sub(/\A-\s*/, "").start_with?("Deviation:") }
347
+ return nil unless line
348
+ text = line.lstrip.sub(/\A-\s*/, "").strip
349
+ { kind: "deviates", what: text, source: "outcome.md ## Verification — Deviation:" }
350
+ end
351
+
352
+ def self.deposits_row(text)
353
+ line = text.to_s.lines.find { |l| l =~ %r{`resources/[^`]+`} }
354
+ return nil unless line
355
+ path = line.match(%r{`(resources/[^`]+)`})[1]
356
+ { kind: "deposits", what: path, source: "outcome.md" }
357
+ end
358
+
359
+ def self.verdict_row(text)
360
+ m = text.to_s.match(/verdict[:\s]+([A-Za-z][A-Za-z ]*)/i)
361
+ return nil unless m
362
+ { kind: "verdict", what: m[1].strip, source: "outcome.md" }
363
+ end
364
+
365
+ def self.evidence_rows(intent_dir, tag_reader: ->(_dir) { nil })
366
+ text = outcome_text(intent_dir)
367
+ return [] unless text
368
+ verification = section_of(text, "## Verification")
369
+
370
+ rows = []
371
+ rows << suite_row(verification)
372
+ rows << red_row(verification)
373
+ rows << (research_intent?(intent_dir) ? nil : ship_row(text, intent_dir, tag_reader))
374
+ if research_intent?(intent_dir)
375
+ rows << deposits_row(text)
376
+ rows << verdict_row(text)
377
+ end
378
+ rows << doctor_row(text)
379
+ rows << deviates_row(verification)
380
+ rows.compact
381
+ end
382
+
383
+ # --- S4/S5: the state verb and the --all roster --------------------------------
384
+
385
+ CHANGED_NOTE = "the reason this screen printed"
386
+
387
+ def self.state_fields(intent_dir:, store_root:, changed:)
388
+ base = intent_basename(intent_dir)
389
+ id = base.split("--", 2).first
390
+ text = intent_text(intent_dir)
391
+ status, title = IntentScreen.index_fields(store_root, id)
392
+ name = title || IntentScreen.fallback_name(text.to_s)
393
+
394
+ f = {}
395
+ f.merge!(IntentScreen.store_fields(store_root))
396
+ f["status"] = status
397
+ f["status.note"] = status == "unlisted" ? "no INDEX.md line names this id" : "listed under ## #{status} in INDEX.md"
398
+ f.merge!(IntentScreen.savepoint_fields(intent_dir, text.to_s))
399
+ items = IntentScreen.checklist_items(intent_dir)
400
+ f.merge!(IntentScreen.progress_fields(items))
401
+ f.merge!(IntentScreen.next_fields(items, status, checklist_present: IntentScreen.items_present?(intent_dir)))
402
+ f.merge!(IntentScreen.insight_fields(text.to_s))
403
+
404
+ changed_value = changed && !changed.to_s.empty? ? changed.to_s : "on request"
405
+
406
+ rows = [
407
+ ["Store", f["store"], f["store.note"]],
408
+ ["Status", f["status"], f["status.note"]],
409
+ ["Stage", f["stage"], f["stage.note"]],
410
+ ["Savepoint", f["savepoint"], f["savepoint.note"]],
411
+ ["Progress", "#{f['progress.bar']} #{f['progress.done']} / #{f['progress.total']}", f["progress.note"]],
412
+ ["Next", f["next"], f["next.note"]],
413
+ ["Insight", f["insight"], f["insight.note"]],
414
+ ["Changed", changed_value, CHANGED_NOTE],
415
+ ]
416
+ { id: id, name: name, rows: rows, items: items }
417
+ end
418
+
419
+ # Rows 42-46: pad BOTH columns to the widest NOTED label/value, computed on
420
+ # the raw emitted (already-escaped) cell text; unnoted rows carry no padding.
421
+ def self.state_rows(rows)
422
+ escaped = rows.map { |label, value, note| ["**#{label}**", escape(value), escape(note)] }
423
+ noted = escaped.select { |_, _, note| !note.to_s.empty? }
424
+ label_w = noted.map { |l, _, _| l.length }.max || 0
425
+ value_w = noted.map { |_, v, _| v.length }.max || 0
426
+ escaped.map do |label, value, note|
427
+ if note.to_s.empty?
428
+ "| #{label} | #{value} | |"
429
+ else
430
+ "| #{label.ljust(label_w)} | #{value.ljust(value_w)} | #{note} |"
431
+ end
432
+ end
433
+ end
434
+
435
+ def self.render_state(intent_dir:, store_root:, changed:, template:)
436
+ data = state_fields(intent_dir: intent_dir, store_root: store_root, changed: changed)
437
+ out = template.dup
438
+ out = out.gsub("{{id}}", data[:id])
439
+ out = out.gsub("{{name}}", data[:name])
440
+ out = out.gsub("{{fields.rows}}", state_rows(data[:rows]).join("\n"))
441
+ out = out.gsub("{{steps.rows}}", IntentScreen.steps_rows(data[:items]))
442
+ out.gsub(/\n{3,}/, "\n\n")
443
+ end
444
+
445
+ # --- roster (D7/D8) -------------------------------------------------------------
446
+
447
+ def self.active_dirnames(index_path)
448
+ return [] unless File.exist?(index_path)
449
+ dirnames = []
450
+ section = nil
451
+ File.foreach(index_path) do |line|
452
+ if line.start_with?("## ")
453
+ section = line[3..].strip
454
+ next
455
+ end
456
+ next unless section == "Active"
457
+ m = line.match(%r{\(store/([^/]+)/})
458
+ dirnames << m[1] if m
459
+ end
460
+ dirnames
461
+ end
462
+
463
+ def self.newest_savepoint_ts(intent_dir)
464
+ lines = savepoint_lines(intent_dir)
465
+ lines.last&.first
466
+ end
467
+
468
+ def self.roster(store_root)
469
+ index_path = File.join(store_root, "INDEX.md")
470
+ entries = active_dirnames(index_path).filter_map do |dirname|
471
+ dir = File.join(store_root, "store", dirname)
472
+ next unless File.directory?(dir)
473
+ text = intent_text(dir).to_s
474
+ fields = IntentScreen.savepoint_fields(dir, text)
475
+ next if fields["stage"] == "Done"
476
+ { dir: dir, id: dirname.split("--", 2).first, ts: newest_savepoint_ts(dir) }
477
+ end
478
+ entries.sort_by { |e| [-(e[:ts] ? Time.parse(e[:ts]).to_i : 0), e[:id]] }
479
+ end
480
+
481
+ def self.lead(intent_dir)
482
+ data = Lock.read(intent_dir)
483
+ return "idle" unless data
484
+ agent = data["owner_agent"].to_s
485
+ session = data["owner_session"].to_s
486
+ return "idle" if agent.empty? && session.empty?
487
+ "#{agent.empty? ? 'unknown' : agent} · #{session[0, 8]}"
488
+ rescue StandardError
489
+ "idle"
490
+ end
491
+
492
+ def self.collapsed_open_steps_note(count)
493
+ count <= 3 ? "#{count} open" : "#{count} open · showing the first three"
494
+ end
495
+
496
+ def self.render_collapsed_block(intent_dir, store_root, changed:)
497
+ data = state_fields(intent_dir: intent_dir, store_root: store_root, changed: changed)
498
+ stage = data[:rows].find { |l, _, _| l == "Stage" }[1]
499
+ nxt = data[:rows].find { |l, _, _| l == "Next" }[1]
500
+ ch = data[:rows].find { |l, _, _| l == "Changed" }[1]
501
+
502
+ open_items = data[:items].each_with_index.reject { |item, _| item[:done] }
503
+ lines = []
504
+ lines << "▶ #{data[:id]} · #{data[:name]}"
505
+ lines << "Stage #{stage}"
506
+ lines << "Next #{nxt}"
507
+ lines << "Changed #{ch}"
508
+ lines << collapsed_open_steps_note(open_items.length)
509
+ open_items.first(3).each { |item, i| lines << "S#{i + 1} [ open ] #{escape(item[:text])}" }
510
+ lines.join("\n")
511
+ end
512
+
513
+ def self.render_roster(store_root, changed: nil, now: Time.now)
514
+ entries = roster(store_root)
515
+ return "No intents in delivery.\n" if entries.empty?
516
+
517
+ header = "▶ In delivery · #{entries.length} #{entries.length == 1 ? 'intent' : 'intents'} · " \
518
+ "#{now.utc.strftime('%Y-%m-%d %H:%M UTC')}"
519
+ table = ["| Intent | Stage | Progress | Changed | Lead |", "| --- | --- | --- | --- | --- |"]
520
+ entries.each do |e|
521
+ text = intent_text(e[:dir]).to_s
522
+ savepoint = IntentScreen.savepoint_fields(e[:dir], text)
523
+ items = IntentScreen.checklist_items(e[:dir])
524
+ progress = IntentScreen.progress_fields(items)
525
+ ch = state_fields(intent_dir: e[:dir], store_root: store_root, changed: changed)[:rows].find { |l, _, _| l == "Changed" }[1]
526
+ table << "| #{e[:id]} | #{savepoint['stage']} | #{progress['progress.bar']} #{progress['progress.done']} / #{progress['progress.total']} | #{escape(ch)} | #{lead(e[:dir])} |"
527
+ end
528
+ blocks = entries.map { |e| render_collapsed_block(e[:dir], store_root, changed: changed) }
529
+ head_and_table = ([header, ""] + table).join("\n")
530
+ # Each collapsed block already has its own internal "\n"; a blank line
531
+ # separates block from block (design--delivery-reports.html:137-152),
532
+ # so they read as distinct entries instead of running together.
533
+ "#{head_and_table}\n\n#{blocks.join("\n\n")}\n"
534
+ end
535
+
536
+ # --- S6: the delivered verb ------------------------------------------------------
537
+
538
+ def self.delivered_timestamp(intent_dir)
539
+ lines = savepoint_lines(intent_dir)
540
+ done = lines.reverse.find { |_ts, kind, _text| kind == "Done" }
541
+ done ? human_time(done[0]) : NOT_RECORDED
542
+ end
543
+
544
+ def self.render_delivered(intent_dir:, tag_reader: ->(_dir) { nil })
545
+ id = intent_id(intent_dir)
546
+ name = title_for(intent_dir, default_store_root(intent_dir))
547
+ ts = delivered_timestamp(intent_dir)
548
+ m = mode(intent_dir)
549
+ dur = duration(intent_dir)
550
+ version = shipped_version(intent_dir) || tag_reader.call(intent_dir)
551
+ ver_text = version && !version.to_s.empty? ? "v#{version.to_s.sub(/\Av/, '')}" : NOT_RECORDED
552
+
553
+ lines = []
554
+ lines << "## ✔ #{id} · #{name} · delivered"
555
+ lines << "#{ts} · #{m} · #{dur} · #{ver_text}"
556
+ lines << ""
557
+ lines << "**Asked**"
558
+ lines << " #{asked(intent_dir)}"
559
+ lines << " #{decision_note(intent_dir)}"
560
+ lines << ""
561
+ lines << "**Delivered**"
562
+ lines << "| Row | What | Proven by |"
563
+ lines << "| --- | --- | --- |"
564
+ delivered_rows(intent_dir).each do |r|
565
+ lines << "| #{r[:label]} | #{escape(r[:text])} | #{escape(proven_by(intent_dir, r[:label]))} |"
566
+ end
567
+ lines << ""
568
+ lines << "**Evidence**"
569
+ ev = evidence_rows(intent_dir, tag_reader: tag_reader)
570
+ if ev.empty?
571
+ # 317a S4 (matrix S4a): a header-only table (319's live rendering) says
572
+ # nothing; the honest floor is the same phrase every other absent source
573
+ # prints.
574
+ lines << NOT_RECORDED
575
+ else
576
+ lines << "| Kind | What | Source |"
577
+ lines << "| --- | --- | --- |"
578
+ ev.each do |r|
579
+ lines << "| #{r[:kind]} | #{escape(r[:what])} | #{escape(r[:source])} |"
580
+ end
581
+ end
582
+ lines << ""
583
+ needsyou = needs_you_rows(intent_dir)
584
+ lines << "**Needs you**"
585
+ if needsyou.empty?
586
+ lines << "None"
587
+ else
588
+ lines << "| N | What | Why |"
589
+ lines << "| --- | --- | --- |"
590
+ needsyou.each { |r| lines << "| #{r[:n]} | #{escape(r[:what])} | #{escape(r[:why])} |" }
591
+ end
592
+ "#{lines.join("\n")}\n"
593
+ end
594
+
595
+ # --- S7: the delay verb -----------------------------------------------------------
596
+
597
+ def self.delay_timeline(intent_dir)
598
+ savepoint_lines(intent_dir).map { |ts, kind, text| { ts: ts, kind: kind, text: text } }
599
+ end
600
+
601
+ def self.longest_gap(timeline)
602
+ return nil if timeline.length < 2
603
+ best = nil
604
+ timeline.each_cons(2) do |a, b|
605
+ secs = (Time.parse(b[:ts]) - Time.parse(a[:ts])).to_i
606
+ best = { secs: secs, a: a[:kind], b: b[:kind] } if best.nil? || secs > best[:secs]
607
+ end
608
+ "longest gap #{best[:secs] / 60} min, #{best[:a]} to #{best[:b]}"
609
+ end
610
+
611
+ def self.where_time_went(timeline)
612
+ gap = longest_gap(timeline)
613
+
614
+ unless timeline.any? { |r| %w[Review Commit].include?(r[:kind]) }
615
+ parts = ["the review and commit ledger was not kept for this intent"]
616
+ parts << gap if gap
617
+ return parts.join(" · ")
618
+ end
619
+
620
+ rounds = timeline.count { |r| r[:kind] == "Review" }
621
+ commits = timeline.count { |r| r[:kind] == "Commit" }
622
+ parts = []
623
+ parts << "reviews #{rounds} round#{rounds == 1 ? '' : 's'}" if rounds.positive?
624
+ parts << "#{commits} commit#{commits == 1 ? '' : 's'}" if commits.positive?
625
+ parts << gap if gap
626
+ parts.join(" · ")
627
+ end
628
+
629
+ def self.hhmm(ts)
630
+ m = ts.match(/T(\d\d:\d\d)/)
631
+ m ? m[1] : ts
632
+ end
633
+
634
+ def self.delay_outcome_line(intent_dir)
635
+ text = outcome_text(intent_dir)
636
+ return NOT_RECORDED unless text
637
+ section = section_of(text, "## Summary")
638
+ # The first PARAGRAPH, not just its first physical line - outcome.md's
639
+ # prose is hand-wrapped at ~100 columns, so a single logical sentence
640
+ # spans several source lines.
641
+ paragraph = section.lstrip.split(/\n\s*\n/, 2).first.to_s.lines.map(&:strip).join(" ").strip
642
+ return NOT_RECORDED if paragraph.empty?
643
+ doc = doctor_row(text)
644
+ doc ? "#{paragraph} · #{doc[:what]}" : paragraph
645
+ end
646
+
647
+ def self.render_delay(intent_dir:)
648
+ id = intent_id(intent_dir)
649
+ name = title_for(intent_dir, default_store_root(intent_dir))
650
+ dur = duration(intent_dir)
651
+ timeline = delay_timeline(intent_dir)
652
+
653
+ lines = []
654
+ lines << "✔ #{id} · #{name} · delivered in #{dur}"
655
+ lines << ""
656
+ timeline.each { |r| lines << "#{hhmm(r[:ts])} #{r[:kind]} #{escape(r[:text])}" }
657
+ lines << ""
658
+ lines << "**Where the time went** #{where_time_went(timeline)}"
659
+ lines << ""
660
+ lines << "**Outcome** #{delay_outcome_line(intent_dir)}"
661
+ "#{lines.join("\n")}\n"
662
+ end
663
+
664
+ # --- S8: --ansi passthrough (D2) -----------------------------------------------
665
+ #
666
+ # 316a owns the ANSI renderer; 317 only wires a generic DI seam so this
667
+ # module never blocks on 316a landing and never breaks when it does (row 77).
668
+ # A renderer file, when present, is expected to define IntentScreenAnsi.paint
669
+ # (one plain-text string in, one string out). Wiring the real contract 316a
670
+ # ships is left to a follow-up step once that file exists (see checklist S14).
671
+ 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