@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 +1 -1
- package/scripts/agent-report +8 -2
- package/scripts/lib/installer_core.rb +3 -0
- package/scripts/lib/intent_screen.rb +23 -2
- 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
package/package.json
CHANGED
package/scripts/agent-report
CHANGED
|
@@ -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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
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
|
|
@@ -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
|
package/scripts/spawn-preamble
CHANGED
|
@@ -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
|
-
|
|
122
|
-
|
|
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
|
package/skills/auto/SKILL.md
CHANGED
|
@@ -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.
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
288
|
-
How
|
|
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 (
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
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,
|
|
67
|
-
|
|
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,
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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
|
|
111
|
-
`ruby ~/.plastic/scripts/
|
|
112
|
-
title, the field table, and the Steps table come from the record,
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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`.
|
|
67
|
-
|
|
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
|
|
package/templates/outcome.md
CHANGED
|
@@ -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
|