@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.
- package/hooks/message-display +2 -0
- package/package.json +1 -1
- package/scripts/agent-report +8 -2
- package/scripts/hook-message-display +2 -0
- package/scripts/intent-screen +4 -0
- package/scripts/lib/hook_registry.rb +5 -3
- package/scripts/lib/installer_core.rb +3 -0
- package/scripts/lib/intent_screen.rb +25 -2
- package/scripts/lib/intent_screen_ansi.rb +22 -11
- package/scripts/lib/message_display.rb +12 -1
- package/scripts/lib/report_screen.rb +586 -0
- package/scripts/lib/savepoint.rb +14 -0
- package/scripts/report-screen +119 -0
- package/scripts/savepoint-note +67 -0
- package/scripts/spawn-preamble +9 -2
- package/skills/auto/SKILL.md +8 -5
- package/skills/auto/references/human-report-contract.md +59 -53
- package/skills/intent-continuing/SKILL.md +15 -10
- package/skills/intent-ending/SKILL.md +5 -2
- package/skills/intent-executing/SKILL.md +6 -0
- package/templates/outcome.md +5 -0
- package/templates/report-state.md +11 -0
|
@@ -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
|
package/scripts/lib/savepoint.rb
CHANGED
|
@@ -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
|