@zalom/plastic 2.0.0-alpha.7 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "2.0.0-alpha.7",
3
+ "version": "2.0.0-alpha.8",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,8 @@
22
22
  # Exit codes: 0 (report emitted), 2 (usage).
23
23
 
24
24
  require_relative "lib/savepoint"
25
+ require_relative "lib/intent_screen"
26
+
25
27
  def parse_args(argv)
26
28
  role = nil
27
29
  positional = []
@@ -67,11 +69,15 @@ STAGE_LABELS = {
67
69
  "exec" => "Exec", "done" => "Done"
68
70
  }.freeze
69
71
 
72
+ # Intent 317, D6: name the last LIFECYCLE line, never a trailing Lock/Review/
73
+ # Commit line (same guard as spawn-preamble; plan review finding B1).
70
74
  def current_stage(intent_dir)
71
75
  ledger = File.join(intent_dir, Savepoint::SAVEPOINT_FILE)
72
76
  if File.exist?(ledger)
73
- last = File.read(ledger).each_line.map(&:strip).reject(&:empty?).last
74
- return last if last
77
+ lines = File.read(ledger).each_line.map(&:strip).reject(&:empty?)
78
+ lifecycle = lines.select { |l| IntentScreen.lifecycle_line?(l) }
79
+ return lifecycle.last if lifecycle.any?
80
+ return lines.last if lines.any?
75
81
  end
76
82
  STAGE_LABELS.fetch(Savepoint.derive_stage(intent_dir), Savepoint.derive_stage(intent_dir))
77
83
  end
@@ -400,6 +400,9 @@ class InstallerCore
400
400
  "scripts/lib/harness_text.rb" => "scripts/lib/harness_text.rb",
401
401
  "scripts/codex-hook" => "scripts/codex-hook",
402
402
  "scripts/spawn-preamble" => "scripts/spawn-preamble",
403
+ "scripts/lib/report_screen.rb" => "scripts/lib/report_screen.rb",
404
+ "scripts/report-screen" => "scripts/report-screen",
405
+ "scripts/savepoint-note" => "scripts/savepoint-note",
403
406
  "scripts/lib/store_provisioning.rb" => "scripts/lib/store_provisioning.rb",
404
407
  "scripts/provision-project-store" => "scripts/provision-project-store",
405
408
  "scripts/lib/project_validator.rb" => "scripts/lib/project_validator.rb",
@@ -28,6 +28,19 @@ module IntentScreen
28
28
  STEP_PREFIX_RE = /\A(?:Step|S)\s*\d+\s*[-:·—–]\s*/i
29
29
  INSIGHT_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s+·\s+\S+\s+·\s+.+?\s+—\s+(.+)\z/
30
30
  SAVEPOINT_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s{2,}(\S+)\s{2,}(.+?)\s*\z/
31
+ # Intent 317, D6: field-2 tokens that are genuine lifecycle stages. A ledger
32
+ # can also carry non-lifecycle lines (`Lock takeover: ...`, and 317's own
33
+ # `Review`/`Commit`); those must never be mistaken for the current stage.
34
+ # Placed here, clear of STEP_PREFIX_RE above (316a edits that one).
35
+ LIFECYCLE_STAGES = %w[What Why How Exec Done].freeze
36
+
37
+ # True iff a raw savepoint ledger line's field 2 is a genuine lifecycle
38
+ # stage (post-execution-review finding 5: centralizes what spawn-preamble
39
+ # and agent-report used to each copy-paste beside the constant above).
40
+ def self.lifecycle_line?(line)
41
+ parts = line.to_s.split(/\s{2,}/)
42
+ parts.length >= 2 && LIFECYCLE_STAGES.include?(parts[1])
43
+ end
31
44
 
32
45
  # Word-boundary truncation caps (intent 316a D3/O1a/O1c). Never a clause
33
46
  # trim: a clause trim on step text destroys a pinned `OPEN:` row
@@ -126,14 +139,22 @@ module IntentScreen
126
139
  def self.savepoint_fields(intent_dir, intent_text)
127
140
  path = File.join(intent_dir, "savepoint.md")
128
141
  lines = File.exist?(path) ? File.readlines(path).map(&:strip).reject(&:empty?) : []
129
- last = lines.reverse.map { |l| l.match(SAVEPOINT_RE) }.compact.first
142
+ matched = lines.reverse.map { |l| l.match(SAVEPOINT_RE) }.compact
143
+ last = matched.first
130
144
  unless last
131
145
  return { "stage" => "Why", "stage.note" => "no savepoint line yet",
132
146
  "savepoint" => "none", "savepoint.note" => "" }
133
147
  end
134
148
 
149
+ # D6: the STAGE PICK is guarded to the last LIFECYCLE line (What/Why/How/
150
+ # Exec/Done), so a trailing Lock/Review/Commit line cannot be mistaken for
151
+ # the stage. The Savepoint field below still shows the TRUE last line,
152
+ # whatever its kind - that is what a savepoint is.
153
+ lifecycle_last = matched.find { |m| LIFECYCLE_STAGES.include?(m[2]) }
154
+ stage_source = lifecycle_last || last
155
+
135
156
  ts, stage, milestone = last[1], last[2], last[3]
136
- landing = landing_stage(stage, milestone)
157
+ landing = landing_stage(stage_source[2], stage_source[3])
137
158
  delivered = lines.map { |l| l.match(SAVEPOINT_RE) }.compact.map { |m| m[2] }.uniq
138
159
  delivered &= %w[What Why How Exec]
139
160
  note = if landing == "Done"
@@ -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
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # report-screen - the three delivery-report screens (intent 317): mid-delivery
6
+ # state, post-delivery delivered, and delay. Each fills from the record via
7
+ # scripts/lib/report_screen.rb; no number here is written by eye.
8
+ #
9
+ # Usage:
10
+ # report-screen state <intent_dir> [--changed "<text>"] [--ansi]
11
+ # report-screen state --all <store_root> [--changed "<text>"] [--ansi]
12
+ # report-screen delivered <intent_dir> [--ansi]
13
+ # report-screen delay <intent_dir> [--ansi]
14
+ #
15
+ # --ansi delegates to 316a's renderer conventions when that file is present
16
+ # (D2) and prints plain otherwise, so this never blocks on 316a and never
17
+ # breaks when it lands; NO_COLOR forces plain regardless of --ansi.
18
+ #
19
+ # Exit codes:
20
+ # 0 - the screen is on stdout
21
+ # 2 - usage error, or the path/store is not what the verb expects (one line
22
+ # on stderr, stdout empty)
23
+
24
+ require_relative "lib/report_screen"
25
+ require_relative "lib/intent_screen"
26
+
27
+ def usage_abort(message)
28
+ warn "report-screen: #{message}"
29
+ exit 2
30
+ end
31
+
32
+ # The one place allowed to shell out (D2/D20): resolves the nearest git tag
33
+ # reachable from the repo this script lives in, so `render_delivered`'s
34
+ # version field and the `ship` evidence row are never invented. The pure
35
+ # module never reads git itself; this is injected as `tag_reader:`.
36
+ def git_tag_reader(repo_root)
37
+ lambda do |_intent_dir|
38
+ return nil unless File.exist?(File.join(repo_root, ".git"))
39
+ tag = `git -C #{repo_root} describe --tags --abbrev=0 2>/dev/null`.strip
40
+ tag.empty? ? nil : tag
41
+ end
42
+ end
43
+
44
+ args = ARGV.dup
45
+ verb = args.shift
46
+ changed = nil
47
+ ansi = false
48
+ template_path = nil
49
+ renderer_path = nil
50
+ positional = []
51
+
52
+ while (arg = args.shift)
53
+ case arg
54
+ when "--changed"
55
+ usage_abort("--changed needs a value") if args.empty?
56
+ changed = args.shift
57
+ when "--ansi"
58
+ ansi = true
59
+ when "--all"
60
+ positional << "--all"
61
+ when "--template"
62
+ template_path = args.shift or usage_abort("--template needs a path")
63
+ when "--renderer-path"
64
+ renderer_path = args.shift or usage_abort("--renderer-path needs a path")
65
+ else
66
+ usage_abort("unknown flag #{arg.inspect}") if arg.start_with?("--") && arg != "--all"
67
+ positional << arg
68
+ end
69
+ end
70
+
71
+ usage_abort("usage: report-screen state|delivered|delay <intent_dir> [--changed \"<text>\"] [--ansi]") unless verb
72
+
73
+ all_mode = positional.delete("--all") ? true : false
74
+ target = positional.first
75
+
76
+ renderer_path ||= File.expand_path("../lib/intent_screen_ansi.rb", __dir__)
77
+ ansi_enabled = ansi && ENV["NO_COLOR"].to_s.empty?
78
+
79
+ def paint(text, ansi_enabled, renderer_path)
80
+ ReportScreen.maybe_paint(text, renderer_path: renderer_path, enabled: ansi_enabled)
81
+ end
82
+
83
+ case verb
84
+ when "state"
85
+ if all_mode
86
+ usage_abort("usage: report-screen state --all <store_root>") unless target
87
+ store_root = File.expand_path(target)
88
+ usage_abort("#{store_root} is not a store (no INDEX.md)") unless File.exist?(File.join(store_root, "INDEX.md"))
89
+ out = ReportScreen.render_roster(store_root, changed: changed)
90
+ $stdout.write paint(out, ansi_enabled, renderer_path)
91
+ else
92
+ usage_abort("usage: report-screen state <intent_dir> [--changed \"<text>\"]") unless target
93
+ intent_dir = File.expand_path(target)
94
+ usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
95
+ store_root = File.expand_path("../..", intent_dir)
96
+ template_path ||= File.expand_path("../templates/report-state.md", __dir__)
97
+ usage_abort("template not found at #{template_path}") unless File.exist?(template_path)
98
+ out = ReportScreen.render_state(intent_dir: intent_dir, store_root: store_root, changed: changed,
99
+ template: File.read(template_path))
100
+ $stdout.write paint(out, ansi_enabled, renderer_path)
101
+ end
102
+ when "delivered"
103
+ usage_abort("usage: report-screen delivered <intent_dir>") unless target
104
+ intent_dir = File.expand_path(target)
105
+ usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
106
+ repo_root = File.expand_path("..", __dir__)
107
+ out = ReportScreen.render_delivered(intent_dir: intent_dir, tag_reader: git_tag_reader(repo_root))
108
+ $stdout.write paint(out, ansi_enabled, renderer_path)
109
+ when "delay"
110
+ usage_abort("usage: report-screen delay <intent_dir>") unless target
111
+ intent_dir = File.expand_path(target)
112
+ usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
113
+ out = ReportScreen.render_delay(intent_dir: intent_dir)
114
+ $stdout.write paint(out, ansi_enabled, renderer_path)
115
+ else
116
+ usage_abort("unknown verb #{verb.inspect} (use state|delivered|delay)")
117
+ end
118
+
119
+ exit 0
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # savepoint-note - the writer CLI for the two savepoint kinds intent 317 adds:
6
+ # Review (one line per plan-review or post-execution-review verdict) and
7
+ # Commit (one line per commit landing during Exec). D17: there is no automatic
8
+ # seam for either (session-commit writes the day ledger, not the intent
9
+ # ledger; the executor's own commits are plain git), so this is an explicit
10
+ # thin wrapper on Savepoint.append_review_savepoint / append_commit_savepoint.
11
+ #
12
+ # Usage:
13
+ # savepoint-note <intent_dir> --kind Review|Commit --text "<text>"
14
+ #
15
+ # --text is normalized (D21): runs of two or more spaces collapse to one, so a
16
+ # free-text kind that happens to contain a double space cannot key the dedup
17
+ # primitive's split(/\s{2,}/) on the prefix alone and silently drop a later
18
+ # line. A newline in --text is refused outright (exit 2, ledger unchanged)
19
+ # rather than silently splitting one entry into two malformed ledger lines.
20
+ #
21
+ # Exit codes:
22
+ # 0 - the line was appended (or was already recorded; both are success)
23
+ # 2 - usage error, bad --kind, missing --text, a newline in --text, or the
24
+ # path is not an intent directory (one line on stderr, ledger unchanged)
25
+
26
+ require_relative "lib/savepoint"
27
+
28
+ def usage_abort(message)
29
+ warn "savepoint-note: #{message}"
30
+ exit 2
31
+ end
32
+
33
+ args = ARGV.dup
34
+ kind = nil
35
+ text = nil
36
+ positional = []
37
+ while (arg = args.shift)
38
+ case arg
39
+ when "--kind"
40
+ kind = args.shift
41
+ when "--text"
42
+ text = args.shift
43
+ else
44
+ usage_abort("unknown flag #{arg.inspect}") if arg.start_with?("--")
45
+ positional << arg
46
+ end
47
+ end
48
+
49
+ usage_abort("usage: savepoint-note <intent_dir> --kind Review|Commit --text \"<text>\"") unless positional.length == 1
50
+
51
+ intent_dir = File.expand_path(positional.first)
52
+ usage_abort("#{intent_dir} is not an intent directory") unless File.exist?(Savepoint.intent_file(intent_dir))
53
+
54
+ KINDS = %w[Review Commit].freeze
55
+ usage_abort("--kind must be one of #{KINDS.join(', ')}, got #{kind.inspect}") unless KINDS.include?(kind)
56
+ usage_abort("--text is required") if text.nil? || text.empty?
57
+ usage_abort("--text must not contain a newline") if text.include?("\n")
58
+
59
+ normalized = text.gsub(/ {2,}/, " ").strip
60
+ usage_abort("--text is empty after normalization") if normalized.empty?
61
+
62
+ case kind
63
+ when "Review" then Savepoint.append_review_savepoint(intent_dir, normalized)
64
+ when "Commit" then Savepoint.append_commit_savepoint(intent_dir, normalized)
65
+ end
66
+
67
+ exit 0
@@ -24,6 +24,7 @@
24
24
 
25
25
  require_relative "lib/savepoint"
26
26
  require_relative "lib/worktree"
27
+ require_relative "lib/intent_screen"
27
28
 
28
29
  # Verbatim honoring instruction. Kept as one constant so the contract doc and the
29
30
  # test assert against the exact same string.
@@ -115,11 +116,17 @@ STAGE_LABELS = {
115
116
  "exec" => "Exec", "done" => "Done"
116
117
  }.freeze
117
118
 
119
+ # Intent 317, D6: a dispatched agent's "Current stage:" line must name the last
120
+ # LIFECYCLE line (What/Why/How/Exec/Done), never a trailing Lock/Review/Commit
121
+ # line - the bug spawn-preamble shared with IntentScreen.savepoint_fields
122
+ # before this guard (plan review finding B1).
118
123
  def current_stage(intent_dir)
119
124
  ledger = File.join(intent_dir, Savepoint::SAVEPOINT_FILE)
120
125
  if File.exist?(ledger)
121
- last = File.read(ledger).each_line.map(&:strip).reject(&:empty?).last
122
- return last if last
126
+ lines = File.read(ledger).each_line.map(&:strip).reject(&:empty?)
127
+ lifecycle = lines.select { |l| IntentScreen.lifecycle_line?(l) }
128
+ return lifecycle.last if lifecycle.any?
129
+ return lines.last if lines.any?
123
130
  end
124
131
  STAGE_LABELS.fetch(Savepoint.derive_stage(intent_dir), Savepoint.derive_stage(intent_dir))
125
132
  end
@@ -200,9 +200,10 @@ Then How.
200
200
  into the spec, the matrix, and the tests; record what was dropped and why in the action
201
201
  file's review fold. A REVISE verdict is folded and not re-reviewed unless a finding changes
202
202
  a decision.
203
- 5. Notify the user (the one mid-flight briefing, per `references/human-report-contract.md`):
204
- State, the plan shape and what it builds; Risk, the riskiest row of the matrix; Call,
205
- proceeding to build. In auto mode this briefing informs; it does not wait.
203
+ 5. Print `ruby ~/.plastic/scripts/report-screen state <intent_dir> --changed "How written, plan review next"`
204
+ (D15; see `references/human-report-contract.md` for the full trigger list). This replaces
205
+ the old prose State/Risk/Call briefing at this boundary. In auto mode the screen informs;
206
+ it does not wait.
206
207
 
207
208
  Then Exec.
208
209
 
@@ -272,6 +273,8 @@ Read `../plastic-conventions/references/completion-and-done.md` for what "intent
272
273
  first, or pass `--discard-worktree-changes` deliberately); 3 means the lock survived the
273
274
  disarm (`/plastic-doctor check the lock status`); 6 means the structure check refused. Never
274
275
  leave an orphaned worktree; run `git worktree prune` on a stale reference.
276
+ 6. Print `ruby ~/.plastic/scripts/report-screen delivered <intent_dir>` once (D15): this is the
277
+ owner report at End, replacing the old prose Done briefing.
275
278
 
276
279
  ## Error Handling
277
280
 
@@ -284,8 +287,8 @@ leaves the project broken.
284
287
 
285
288
  - Read `references/agent-architecture.md` for the team model, the risk list, the headless note,
286
289
  and the solo fallback when dispatching or when a harness has no agent dispatch.
287
- - Read `references/human-report-contract.md` for the State/Risk/Call briefing before sending the
288
- How briefing.
290
+ - Read `references/human-report-contract.md` for the three report screens and the five
291
+ triggers before printing the How or Completion screen above.
289
292
  - Read `references/agent-report-contract.md` for the completion report format when reading a
290
293
  dispatched agent's return or synthesizing one.
291
294
  - Read `references/end-tail.md` for what `Arm.disarm` does at the End tail and why the reindex
@@ -1,72 +1,78 @@
1
- # Human Report Contract (per-stage EM-to-CTO briefing)
2
-
3
- This doc defines how the orchestrator briefs the human at each of the five stage boundaries
4
- (What, Why, How, Exec, Done) in auto mode; for small work only the How boundary fires (see
5
- `## Depth for small work`). It is the outward, human-facing counterpart to the
6
- internal report contract in `references/agent-report-contract.md`. Voice: an engineering
7
- manager briefing a CTO. Lead with impact, name the risk, leave the decision.
8
-
9
- ## The skeleton
10
-
11
- One fixed 3-line shape, reused at every stage:
12
-
13
- 1. **State**: what happened and what it means, impact first, one line.
14
- 2. **Risk**: the one thing that could bite, or "nothing flagged."
15
- 3. **Call**: the decision left to you, or the go-ahead I am taking.
16
-
17
- This is a shape, not a rigid template. Keep the order (State, then Risk, then Call) and keep it
18
- short. The words can flex to fit the stage.
19
-
20
- ## Per-stage content
21
-
22
- - **What**: State = the work I picked up and why it matters now. Risk = scope uncertainty.
23
- Call = confirm this is worth doing, or I proceed.
24
- - **Why**: State = the approach I chose, one line. Risk = the main trade-off. Call = the one
25
- decision I need (approve, or pick an option).
26
- - **How**: State = the plan shape (task count and what it builds). Risk = the riskiest task or
27
- dependency. Call = approve the plan to build.
28
- - **Exec**: State = what got built and the test result. Risk = residual failures or deviations.
29
- Call = go to review, or done.
30
- - **Done**: State = the delivered impact. Risk = residual risk. Call = the decision left to you
31
- (merge, release, accept).
1
+ # Human Report Contract (the three report screens, intent 317)
2
+
3
+ D15: the prose EM-to-CTO briefing this doc used to define is retired. The orchestrator now
4
+ prints one of three report screens, filled from the record by `scripts/report-screen`, never
5
+ written by eye:
6
+
7
+ - **`report-screen state <intent_dir> [--changed "<text>"]`** - the mid-delivery report. One
8
+ intent's field table (Store, Status, Stage, Savepoint, Progress, Next, Insight) plus a
9
+ `Changed` row naming what caused the print, and its Steps table.
10
+ - **`report-screen state --all <store_root>`** - the roster across every in-delivery intent,
11
+ most recently changed first, then one collapsed block (Stage, Next, Changed, first three
12
+ open steps) per intent.
13
+ - **`report-screen delivered <intent_dir>`** - the post-delivery report, printed once at close:
14
+ Asked, Delivered (with a Proven-by column), Evidence, Needs you.
15
+ - **`report-screen delay <intent_dir>`** - printed only on request ("why did X take so long"):
16
+ the delivery as a timeline plus the derived `Where the time went` line.
17
+
18
+ ## The five triggers for `state`
19
+
20
+ Print `state` (one intent, or `--all` for the roster) on any of these; a checklist tick alone,
21
+ an executor's intermediate commit, or an agent going idle is NOT one of them:
22
+
23
+ | Trigger | Scope |
24
+ |---|---|
25
+ | A savepoint line lands (a stage boundary: Why, How, Exec started, outcome written, Done) | that intent |
26
+ | A review verdict returns (plan review or post-execution review), naming what it changed | that intent |
27
+ | A blocker or needs-input is logged | that intent |
28
+ | A merge or a release lands | that intent |
29
+ | The owner asks ("where are we", "state of X", "continue X") | all in delivery, or the one named |
30
+
31
+ `delivered` prints exactly once, at Completion. `delay` prints only when the owner asks why a
32
+ delivery took long.
33
+
34
+ Every verb prints the same plain Markdown on every harness (owner ruling 2026-08-31); where a
35
+ harness can paint it (Claude Code, through 316a's message-display hook), it substitutes a
36
+ painted rendering of that same output, never a different one, and no skill or script branches
37
+ on harness name to decide.
32
38
 
33
39
  ## Depth for small work
34
40
 
35
- For small work in auto mode the mid-flight briefings collapse to one. Only the How briefing fires, and
36
- it folds in what the What and Why briefings would have said: the work picked up and the approach
37
- chosen go into its State line. The Exec briefing folds into the final owner report at End. Larger
38
- work sends all four. The shape does not change: still State, then Risk, then Call, and the per-stage
39
- content above still says what each line covers. This is a depth cut, not a new report. A delivery
40
- still ends with `outcome.md` plus one owner report.
41
+ For small work in auto mode, only the How-boundary `state` screen prints mid-flight (its
42
+ `Changed` row names what the What and Why steps did, since there is no separate briefing per
43
+ stage any more). Larger work prints `state` at every trigger in the table above. This is a
44
+ depth cut, not a different report: the screen's shape never changes, only how often it fires.
45
+ A delivery still ends with `outcome.md` plus one `delivered` screen.
41
46
 
42
47
  ## One report per audience
43
48
 
44
49
  A delivery produces exactly two artifacts: `outcome.md` (authored by `plastic-intent-ending`)
45
- and one EM-to-CTO owner report at the End stage. No stage or skill restates a delivery
46
- already written to `outcome.md`; point at it instead. Skills do not open with a banner that
47
- names the skill or restates the intent id and name the owner just typed. Announce only what
48
- the reader cannot already know: an error, a result, a choice with its reason, or a handoff.
50
+ and one `delivered` screen at the End stage. No stage or skill restates a delivery already
51
+ written to `outcome.md`; point at it instead. Skills do not open with a banner that names the
52
+ skill or restates the intent id and name the owner just typed. Announce only what the reader
53
+ cannot already know: an error, a result, a choice with its reason, or a handoff.
49
54
 
50
55
  ## Boundary vs intent 74
51
56
 
52
57
  Intent 74's report contract (`references/agent-report-contract.md`) is the INTERNAL,
53
58
  machine-checked handoff from a dispatched specialist back to the orchestrator: a structured
54
- envelope plus a per-role payload. This contract is the OUTWARD human briefing, orchestrator to
55
- user, in prose. Different direction, different audience, different form. The orchestrator
56
- CONSUMES the intent 74 report to WRITE the human briefing defined here. The two never merge.
59
+ envelope plus a per-role payload. This contract is the OUTWARD screen shown to the owner.
60
+ Different direction, different audience, different form. The orchestrator reads the intent 74
61
+ report and reflects it into the record (savepoint, outcome.md) that `report-screen` then
62
+ renders. The two never merge.
57
63
 
58
64
  ## Brevity: point, don't repeat
59
65
 
60
- Surface rules are owned by the `writing-style` skill. This contract does not restate them, in full
61
- or in summary. It defines the report's shape only: what to say, in what order, and when to stop.
62
- Apply the `writing-style` skill for the wording.
66
+ Surface rules are owned by the `writing-style` skill. This contract does not restate them. Its
67
+ job is naming which screen prints when, not the wording inside it - `report-screen` derives
68
+ every cell from the record (D14), so there is no prose left to style here.
63
69
 
64
70
  ## Emission: guided vs auto
65
71
 
66
- In guided mode, the briefing lands at each stage boundary and the human acts on the Call line
67
- before the next stage starts.
72
+ In guided mode, `state` prints at each stage boundary and the human decides before the next
73
+ stage starts.
68
74
 
69
- In auto mode, for larger work the orchestrator still emits the briefing at each boundary, as a
70
- running EM-to-CTO account. For small work only the How briefing fires; see `## Depth for small work` above for what it
71
- folds in. The Call line becomes the go-ahead the orchestrator takes itself and moves on, except at
72
- the existing hard stops (destructive action without a safe alternative, project-path confirm).
75
+ In auto mode, `state` prints at every trigger for larger work; for small work only the How
76
+ boundary prints (see `## Depth for small work` above). The orchestrator takes the go-ahead
77
+ itself and moves on, except at the existing hard stops (destructive action without a safe
78
+ alternative, project-path confirm).
@@ -103,20 +103,25 @@ For a live intent's directory:
103
103
  the next thing the stage needs (see the matrix). The newest `## Insights` entry supplies
104
104
  the human-readable context; an entry marked `(autonomous)` means an auto team was
105
105
  delivering it, so say so and offer to hand back to `plastic-auto`.
106
- 5. **Print the intent screen as the first thing in the reply, then continue at that stage.**
106
+ 5. **Print the report screen as the first thing in the reply, then continue at that stage.**
107
107
  The screen must open the message with nothing before it. On Claude Code, a fail-open
108
108
  `MessageDisplay` hook recognizes a reply that opens this way and substitutes a styled ANSI
109
109
  rendering for it there; the transcript and every other harness keep exactly this plain
110
- form, and nothing about how the screen is printed here ever changes. Run
111
- `ruby ~/.plastic/scripts/intent-screen <intent_dir>` and print its output as it is: the
112
- title, the field table, and the Steps table come from the record, never by eye. Under it
110
+ form. For "where are we" on one named intent, run
111
+ `ruby ~/.plastic/scripts/report-screen state <intent_dir>` and print its output as it is:
112
+ the title, the field table, the `Changed` row, and the Steps table come from the record,
113
+ never by eye. For "where are we" with no intent named, run
114
+ `ruby ~/.plastic/scripts/report-screen state --all <store_root>` for the roster across every
115
+ in-delivery intent. Route "why did X take so long" to
116
+ `ruby ~/.plastic/scripts/report-screen delay <intent_dir>` instead - every verb prints the
117
+ same plain screen on any harness, painted only where the harness supports it, with no
118
+ branching on harness name. Under the `state` screen
113
119
  write **What this means** as two to four bullets in plain words (what the intent is for,
114
- what has landed, what is left, any defect named by step), then close with
115
- **needs input:** naming the first open step. The screen's shape is
116
- `~/.plastic/templates/intent-screen.md`; the script fills it, the session never edits the numbers.
117
- Then continue the work in the session's current mode. In auto mode the running team
118
- already holds the delivery lock; if a lock is held by a session that is gone, the
119
- `plastic-doctor` skill's lock section repairs or reclaims it.
120
+ what has landed, what is left, any defect named by step), then close with **needs input:**
121
+ naming the first open step. Then continue the work in the
122
+ session's current mode. In auto mode the running team already holds the delivery lock; if a
123
+ lock is held by a session that is gone, the `plastic-doctor` skill's lock section repairs or
124
+ reclaims it.
120
125
 
121
126
  ## Roadmap route: resume the mid-flight roadmap
122
127
 
@@ -63,8 +63,11 @@ on disk is what the record becomes, so before the call:
63
63
 
64
64
  Author outcome.md yourself when it deserves prose: copy `templates/outcome.md`,
65
65
  set the frontmatter to `disposition: delivered` or `disposition: abandoned`, and
66
- fill `## Summary`, `## Delivered`, `## Verification`, `## Follow-ups`. On
67
- abandon, `## Summary` states the abandonment reason and the trail (see Pivot
66
+ fill `## Summary`, `## Delivered`, `## Verification`, `## Follow-ups`. Each
67
+ `## Delivered` row is one thing delivered in plain wording a reader recognizes,
68
+ not a method name or an implementation summary (that detail belongs in
69
+ `## Summary`) - it becomes a row of `report-screen delivered`'s post-delivery
70
+ screen. On abandon, `## Summary` states the abandonment reason and the trail (see Pivot
68
71
  below). A placeholder outcome.md is backfilled from the record instead, with the
69
72
  close's disposition and the `--outcome-summary` line as its summary. Also author
70
73
  the rich INDEX entry note now (a short line in the store's existing
@@ -61,6 +61,8 @@ Run Step 0 (Sync Worktree First) before this step.
61
61
 
62
62
  Dispatch ONE executor subagent and give it the whole delivery: every task's full text from `plan.md` (pasted in, never a file reference), every action file with its failure-mode matrix, the checklist items it must tick, the project context from CLAUDE.md, the active intent context from `{ID}--{slug}.md`, and the worktree path. In auto mode this is the `plastic-executor` agent; elsewhere use the `implementer-prompt.md` template. The executor writes the matrix's tests and commits them red, implements the consolidated action in order, ticks each item as it lands (see `## Tick-as-you-land`), and drives the test suite green.
63
63
 
64
+ After each commit lands (the red commit and every commit after it), append a `Commit` line to the savepoint ledger: `ruby ~/.plastic/scripts/savepoint-note <intent_dir> --kind Commit --text "<sha> <what it proves>"` (intent 317, D17). This is what feeds `report-screen delay`; a commit with no line is a gap the delay report cannot explain.
65
+
64
66
  Read its response by code:
65
67
  - DONE or DONE_WITH_CONCERNS → proceed to Step 3.
66
68
  - NEEDS_CONTEXT → provide the missing context, re-dispatch the executor.
@@ -69,6 +71,10 @@ Read its response by code:
69
71
  ### Step 3: Review by Risk
70
72
  Apply the auto skill's risk rule to the executor's return and the diff: a matrix row no test could prove, a diff touching a hook, the lock, the worktree code, the installer, or a release file, a DONE_WITH_CONCERNS or a deviation from the matrix, or an owner-facing surface no test pins. When a rule fires, dispatch the post-execution reviewer with `code-quality-reviewer-prompt.md` (a separate agent with fresh context, never the maker); if it returns changes, re-dispatch the executor to fix them, then run the suite once more. When no rule fires, the green suite is the review.
71
73
 
74
+ Whenever a review verdict returns - the plan review before code, or the post-execution review above - the lead appends a `Review` line: `ruby ~/.plastic/scripts/savepoint-note <intent_dir> --kind Review --text "<verdict, what changed>"` (intent 317, D17). This is the other half of what `report-screen delay` reads.
75
+
76
+ **The D19 heading convention.** An action file's `## Delivered` row (in `outcome.md`) is proven by whichever `actions/ACTION_N.md` heading carries that row's label as a standalone token - `### Row A -` proves row A, `### S1 -` proves row S1. Write action-file section headings so the label they prove is unambiguous (never a substring another label could also match, like `A` inside `AB`); `report-screen delivered`'s Proven-by column renders `not recorded` when no heading matches.
77
+
72
78
  ### Step 4: Update Intent and Complete
73
79
  Capture observations in `## Insights`. When ALL checklist items are checked:
74
80
 
@@ -7,10 +7,15 @@ disposition: delivered|abandoned
7
7
  (what was delivered)
8
8
 
9
9
  ## Delivered
10
+ <!-- One row per thing delivered, in plain wording a reader recognizes, not
11
+ an implementation summary; the technical detail belongs in ## Summary. -->
10
12
  - ...
11
13
 
12
14
  ## Verification
13
15
  - <acceptance criterion> — verified by ... → result
14
16
 
17
+ ## Needs you
18
+ None
19
+
15
20
  ## Follow-ups
16
21
  None
@@ -0,0 +1,11 @@
1
+ ## ▶ {{id}} · {{name}}
2
+
3
+ | | | |
4
+ | --- | --- | --- |
5
+ {{fields.rows}}
6
+
7
+ **Steps**
8
+
9
+ | Step | Status | What |
10
+ | --- | --- | --- |
11
+ {{steps.rows}}