@zalom/plastic 2.0.0-alpha.7 → 2.0.0-alpha.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hooks/message-display +9 -1
- package/package.json +1 -1
- package/scripts/agent-report +8 -2
- package/scripts/end-intent +93 -0
- package/scripts/hook-message-display +12 -5
- package/scripts/lib/day_summary.rb +7 -2
- package/scripts/lib/installer_core.rb +4 -0
- package/scripts/lib/intent_screen.rb +28 -4
- package/scripts/lib/message_display.rb +35 -127
- package/scripts/lib/report_screen.rb +648 -0
- package/scripts/lib/savepoint.rb +14 -0
- package/scripts/lib/screen_paint.rb +218 -0
- package/scripts/report-screen +120 -0
- package/scripts/savepoint-note +67 -0
- package/scripts/spawn-preamble +9 -2
- package/skills/auto/SKILL.md +10 -6
- package/skills/auto/references/human-report-contract.md +59 -53
- package/skills/conventions/references/locks-and-worktrees.md +12 -0
- package/skills/intent-continuing/SKILL.md +15 -10
- package/skills/intent-ending/SKILL.md +8 -2
- package/skills/intent-executing/SKILL.md +6 -0
- package/templates/outcome.md +14 -1
- package/templates/report-state.md +11 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "intent_screen_ansi"
|
|
5
|
+
|
|
6
|
+
# ScreenPaint (intent 317a, D1) - the paint seam 317's Needs-you named. Parses
|
|
7
|
+
# the plain Markdown screens our own renderers emit (intent, state, roster,
|
|
8
|
+
# delivered, delay) and re-lays them out in the shipped intent-screen ANSI
|
|
9
|
+
# vocabulary. A parser and RE-LAYOUTER, not a colorizer (A5): the plain
|
|
10
|
+
# screens are pipe tables whose scaffolding rows only disappear under a
|
|
11
|
+
# Markdown renderer, so the painter drops them and rebuilds the layout;
|
|
12
|
+
# content-survival is the contract - every value and note survives, nothing
|
|
13
|
+
# is invented, and text it does not recognize returns nil so every caller
|
|
14
|
+
# fails open to plain.
|
|
15
|
+
#
|
|
16
|
+
# Harness-agnostic core: no harness assumption lives here. No ENV, no TTY. Color, width, and
|
|
17
|
+
# markdown_safe are caller arguments, exactly like IntentScreenAnsi before it
|
|
18
|
+
# (316a1); the 318 ceiling holds - the palette is IntentScreenAnsi's, no new
|
|
19
|
+
# colors, no box borders.
|
|
20
|
+
module ScreenPaint
|
|
21
|
+
A = IntentScreenAnsi
|
|
22
|
+
|
|
23
|
+
# A screen's first line: "## ▶ id · name", "## ✔ id · name · delivered",
|
|
24
|
+
# "▶ In delivery · ...", "✔ id · name · delivered in ...".
|
|
25
|
+
OPENER_RE = /\A(?:## )?[▶✔] .+ · /.freeze
|
|
26
|
+
|
|
27
|
+
FIELD_LINE_RE = /\A(Stage|Next|Changed|Lead|Progress)(\s{2,})(.*)\z/.freeze
|
|
28
|
+
STEP_LINE_RE = /\A(S\d+)\s+\[ (open|done) \]\s+(.*)\z/.freeze
|
|
29
|
+
TIMELINE_RE = /\A(\d\d:\d\d)\s{2}(\S+)\s{2}(.*)\z/.freeze
|
|
30
|
+
COUNT_LINE_RE = /\A\d+ open( · .*)?\z/.freeze
|
|
31
|
+
BOLD_LEAD_RE = /\A\*\*([^*]+)\*\*(.*)\z/.freeze
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# The classifier both paint and region_end share. `idx`/`opener_idx` give
|
|
36
|
+
# the positional rule its footing: the line right after a title is the meta
|
|
37
|
+
# line (delivered/delay print one), recognizable by its " · " separators.
|
|
38
|
+
def classify(line, idx: nil, opener_idx: nil)
|
|
39
|
+
text = line.chomp
|
|
40
|
+
stripped = text.strip
|
|
41
|
+
return :blank if stripped.empty?
|
|
42
|
+
return :opener if OPENER_RE.match?(stripped) && text == stripped
|
|
43
|
+
return :table if text.lstrip.start_with?("|")
|
|
44
|
+
return :bold if BOLD_LEAD_RE.match?(stripped) && text == stripped
|
|
45
|
+
return :meta if idx && opener_idx && idx == opener_idx + 1 && stripped.include?(" · ")
|
|
46
|
+
return :indented if text.start_with?(" ")
|
|
47
|
+
return :field if FIELD_LINE_RE.match?(text)
|
|
48
|
+
return :step if STEP_LINE_RE.match?(text)
|
|
49
|
+
return :timeline if TIMELINE_RE.match?(text)
|
|
50
|
+
return :count if COUNT_LINE_RE.match?(stripped)
|
|
51
|
+
return :closer if ["None", "not recorded", "No intents in delivery."].include?(stripped)
|
|
52
|
+
:unknown
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Where the screen region ends inside a larger message (B10): walk from the
|
|
56
|
+
# opener while every line classifies; the first unknown line - ordinary
|
|
57
|
+
# prose, a prose bullet - is the boundary. Never consumes past the screen.
|
|
58
|
+
def region_end(lines, start_idx)
|
|
59
|
+
i = start_idx + 1
|
|
60
|
+
while i < lines.length
|
|
61
|
+
kind = classify(lines[i], idx: i, opener_idx: start_idx)
|
|
62
|
+
break if kind == :unknown
|
|
63
|
+
# A bare "**Section**" head belongs to the screen only when what follows
|
|
64
|
+
# is still grammar; "**What this means**" over prose bullets is the
|
|
65
|
+
# model's own commentary and stays outside, unsplit (B10).
|
|
66
|
+
if kind == :bold && bare_bold?(lines[i]) && !grammar_follows?(lines, i, start_idx)
|
|
67
|
+
break
|
|
68
|
+
end
|
|
69
|
+
i += 1
|
|
70
|
+
end
|
|
71
|
+
# Trailing blanks belong to the message, not the screen.
|
|
72
|
+
i -= 1 while i > start_idx + 1 && lines[i - 1].strip.empty?
|
|
73
|
+
i
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def bare_bold?(line)
|
|
77
|
+
m = BOLD_LEAD_RE.match(line.strip)
|
|
78
|
+
m && m[2].to_s.strip.empty?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def grammar_follows?(lines, idx, opener_idx)
|
|
82
|
+
j = idx + 1
|
|
83
|
+
j += 1 while j < lines.length && lines[j].strip.empty?
|
|
84
|
+
return false if j >= lines.length
|
|
85
|
+
kind = classify(lines[j], idx: j, opener_idx: opener_idx)
|
|
86
|
+
kind != :unknown && kind != :opener
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The painter. Returns the ANSI (or plain re-laid, when color: false) text,
|
|
90
|
+
# or nil when the input does not open with a screen title or carries a line
|
|
91
|
+
# outside the grammar - the caller's cue to print the original untouched.
|
|
92
|
+
def paint(text, color: true, width: A::DEFAULT_WIDTH, markdown_safe: false)
|
|
93
|
+
lines = text.to_s.lines
|
|
94
|
+
first_idx = lines.index { |l| !l.strip.empty? }
|
|
95
|
+
return nil if first_idx.nil?
|
|
96
|
+
return nil unless classify(lines[first_idx]) == :opener
|
|
97
|
+
|
|
98
|
+
out = +""
|
|
99
|
+
table = []
|
|
100
|
+
ok = true
|
|
101
|
+
|
|
102
|
+
flush = lambda do
|
|
103
|
+
next if table.empty?
|
|
104
|
+
out << paint_table(table, color: color, width: width, markdown_safe: markdown_safe)
|
|
105
|
+
table.clear
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
lines.each_with_index do |line, idx|
|
|
109
|
+
kind = classify(line, idx: idx, opener_idx: first_idx)
|
|
110
|
+
if kind == :table
|
|
111
|
+
table << line.strip
|
|
112
|
+
next
|
|
113
|
+
end
|
|
114
|
+
flush.call
|
|
115
|
+
case kind
|
|
116
|
+
when :opener
|
|
117
|
+
t = clean(line.strip.sub(/\A## /, ""), markdown_safe)
|
|
118
|
+
out << A.fit(t, width) { |s| A.styled(s, color, A::BOLD, A::NEARWHITE) } << "\n"
|
|
119
|
+
when :meta
|
|
120
|
+
out << A.fit(clean(line.strip, markdown_safe), width) { |s| A.styled(s, color, A::MIDGREY) } << "\n"
|
|
121
|
+
when :bold
|
|
122
|
+
m = BOLD_LEAD_RE.match(line.strip)
|
|
123
|
+
head = A.styled(clean(m[1], markdown_safe), color, A::BOLD, A::NEARWHITE)
|
|
124
|
+
out << head << clean(m[2], markdown_safe) << "\n"
|
|
125
|
+
when :indented
|
|
126
|
+
out << A.fit_plain(clean(line.chomp, markdown_safe), width) << "\n"
|
|
127
|
+
when :field
|
|
128
|
+
m = FIELD_LINE_RE.match(line.chomp)
|
|
129
|
+
out << A.styled(m[1].ljust(8), color, A::BOLD) << " " << clean(m[3], markdown_safe) << "\n"
|
|
130
|
+
when :step
|
|
131
|
+
m = STEP_LINE_RE.match(line.chomp)
|
|
132
|
+
badge = A.status_cell(m[2] == "done", color)
|
|
133
|
+
out << "#{m[1].ljust(4)} [#{badge}] #{A.fit_plain(clean(m[3], markdown_safe), width - 12)}\n"
|
|
134
|
+
when :timeline
|
|
135
|
+
m = TIMELINE_RE.match(line.chomp)
|
|
136
|
+
out << A.styled(m[1], color, A::MIDGREY) << " " << A.styled(m[2].ljust(6), color, A::BOLD) \
|
|
137
|
+
<< " " << clean(m[3], markdown_safe) << "\n"
|
|
138
|
+
when :count
|
|
139
|
+
out << A.fit(line.strip, width) { |s| A.styled(s, color, A::MIDGREY) } << "\n"
|
|
140
|
+
when :closer
|
|
141
|
+
out << A.styled(line.strip, color, A::MIDGREY) << "\n"
|
|
142
|
+
when :blank
|
|
143
|
+
out << "\n"
|
|
144
|
+
else
|
|
145
|
+
ok = false
|
|
146
|
+
break
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
flush.call
|
|
150
|
+
return nil unless ok
|
|
151
|
+
|
|
152
|
+
paint_bars(out.gsub(/\n{3,}/, "\n\n"), color)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# --- tables -----------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
SEPARATOR_RE = /\A\|[\s:|-]+\|?\z/.freeze
|
|
158
|
+
|
|
159
|
+
def cells_of(row)
|
|
160
|
+
row.split("|", -1).map(&:strip)[1..-2].to_a
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def field_table?(rows)
|
|
164
|
+
rows.first&.gsub(/[\s|]/, "") == "" || cells_of(rows.first).first.to_s.start_with?("**")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# A field table ("| | | |" scaffold, "| **Key** | value | note |" rows)
|
|
168
|
+
# re-lays as the intent screen's vertical field block: bold key, value,
|
|
169
|
+
# mid-grey note on its own line. A data table re-lays as padded columns
|
|
170
|
+
# with a bold header, done/open cells colored, no pipes anywhere.
|
|
171
|
+
def paint_table(rows, color:, width:, markdown_safe:)
|
|
172
|
+
rows = rows.reject { |r| SEPARATOR_RE.match?(r) || r.gsub(/[\s|]/, "").empty? }
|
|
173
|
+
return "" if rows.empty?
|
|
174
|
+
|
|
175
|
+
if cells_of(rows.first).first.to_s.start_with?("**")
|
|
176
|
+
out = +""
|
|
177
|
+
key_w = rows.map { |r| cells_of(r).first.to_s.gsub("*", "").length }.max
|
|
178
|
+
rows.each do |row|
|
|
179
|
+
key, value, note = cells_of(row)
|
|
180
|
+
key = key.to_s.gsub("*", "")
|
|
181
|
+
out << " #{A.styled(key.ljust(key_w), color, A::BOLD)} #{clean(value.to_s, markdown_safe)}\n"
|
|
182
|
+
next if note.to_s.empty?
|
|
183
|
+
out << (" " * (key_w + 4)) << A.fit(clean(note, markdown_safe), width - key_w - 4) { |s| A.styled(s, color, A::MIDGREY) } << "\n"
|
|
184
|
+
end
|
|
185
|
+
return out
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
grid = rows.map { |r| cells_of(r).map { |c| clean(c, markdown_safe) } }
|
|
189
|
+
widths = grid.first.each_index.map { |i| grid.map { |r| r[i].to_s.length }.max }
|
|
190
|
+
out = +""
|
|
191
|
+
grid.each_with_index do |cols, ri|
|
|
192
|
+
cells = cols.each_with_index.map do |cell, ci|
|
|
193
|
+
padded = cell.to_s.ljust(widths[ci])
|
|
194
|
+
if ri.zero?
|
|
195
|
+
A.styled(padded, color, A::BOLD)
|
|
196
|
+
elsif cell == "done"
|
|
197
|
+
A.styled(padded, color, A::TEAL)
|
|
198
|
+
elsif cell == "open"
|
|
199
|
+
A.styled(padded, color, A::AMBER)
|
|
200
|
+
else
|
|
201
|
+
padded
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
out << " " << cells.join(" ").rstrip << "\n"
|
|
205
|
+
end
|
|
206
|
+
out
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def paint_bars(text, color)
|
|
210
|
+
return text unless color
|
|
211
|
+
text.gsub(/█+/) { |run| "#{A::TEAL}#{run}#{A::RESET}" }
|
|
212
|
+
.gsub(/░+/) { |run| "#{A::MIDGREY}#{run}#{A::RESET}" }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def clean(text, markdown_safe)
|
|
216
|
+
markdown_safe ? A.clean(text) : text
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
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 ScreenPaint (intent 317a, D1), the parser/re-layouter
|
|
16
|
+
# in the shared TUI core. Selection is by capability, never by harness:
|
|
17
|
+
# NO_COLOR forces plain, a non-TTY stdout stays plain unless
|
|
18
|
+
# PLASTIC_FORCE_COLOR=1 (the test seam), and an unparseable screen falls
|
|
19
|
+
# open to the plain text untouched.
|
|
20
|
+
#
|
|
21
|
+
# Exit codes:
|
|
22
|
+
# 0 - the screen is on stdout
|
|
23
|
+
# 2 - usage error, or the path/store is not what the verb expects (one line
|
|
24
|
+
# on stderr, stdout empty)
|
|
25
|
+
|
|
26
|
+
require_relative "lib/report_screen"
|
|
27
|
+
require_relative "lib/intent_screen"
|
|
28
|
+
require_relative "lib/screen_paint"
|
|
29
|
+
|
|
30
|
+
def usage_abort(message)
|
|
31
|
+
warn "report-screen: #{message}"
|
|
32
|
+
exit 2
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The one place allowed to shell out (D2/D20): resolves the nearest git tag
|
|
36
|
+
# reachable from the repo this script lives in, so `render_delivered`'s
|
|
37
|
+
# version field and the `ship` evidence row are never invented. The pure
|
|
38
|
+
# module never reads git itself; this is injected as `tag_reader:`.
|
|
39
|
+
def git_tag_reader(repo_root)
|
|
40
|
+
lambda do |_intent_dir|
|
|
41
|
+
return nil unless File.exist?(File.join(repo_root, ".git"))
|
|
42
|
+
tag = `git -C #{repo_root} describe --tags --abbrev=0 2>/dev/null`.strip
|
|
43
|
+
tag.empty? ? nil : tag
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
args = ARGV.dup
|
|
48
|
+
verb = args.shift
|
|
49
|
+
changed = nil
|
|
50
|
+
ansi = false
|
|
51
|
+
template_path = nil
|
|
52
|
+
positional = []
|
|
53
|
+
|
|
54
|
+
while (arg = args.shift)
|
|
55
|
+
case arg
|
|
56
|
+
when "--changed"
|
|
57
|
+
usage_abort("--changed needs a value") if args.empty?
|
|
58
|
+
changed = args.shift
|
|
59
|
+
when "--ansi"
|
|
60
|
+
ansi = true
|
|
61
|
+
when "--all"
|
|
62
|
+
positional << "--all"
|
|
63
|
+
when "--template"
|
|
64
|
+
template_path = args.shift or usage_abort("--template 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
|
+
ansi_enabled = ansi && ENV["NO_COLOR"].to_s.empty? &&
|
|
77
|
+
($stdout.tty? || ENV["PLASTIC_FORCE_COLOR"] == "1")
|
|
78
|
+
|
|
79
|
+
def paint(text, ansi_enabled, _renderer_path = nil)
|
|
80
|
+
return text unless ansi_enabled
|
|
81
|
+
ScreenPaint.paint(text, color: true) || text
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
case verb
|
|
85
|
+
when "state"
|
|
86
|
+
if all_mode
|
|
87
|
+
usage_abort("usage: report-screen state --all <store_root>") unless target
|
|
88
|
+
store_root = File.expand_path(target)
|
|
89
|
+
usage_abort("#{store_root} is not a store (no INDEX.md)") unless File.exist?(File.join(store_root, "INDEX.md"))
|
|
90
|
+
out = ReportScreen.render_roster(store_root, changed: changed)
|
|
91
|
+
$stdout.write paint(out, ansi_enabled)
|
|
92
|
+
else
|
|
93
|
+
usage_abort("usage: report-screen state <intent_dir> [--changed \"<text>\"]") unless target
|
|
94
|
+
intent_dir = File.expand_path(target)
|
|
95
|
+
usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
|
|
96
|
+
store_root = File.expand_path("../..", intent_dir)
|
|
97
|
+
template_path ||= File.expand_path("../templates/report-state.md", __dir__)
|
|
98
|
+
usage_abort("template not found at #{template_path}") unless File.exist?(template_path)
|
|
99
|
+
out = ReportScreen.render_state(intent_dir: intent_dir, store_root: store_root, changed: changed,
|
|
100
|
+
template: File.read(template_path))
|
|
101
|
+
$stdout.write paint(out, ansi_enabled)
|
|
102
|
+
end
|
|
103
|
+
when "delivered"
|
|
104
|
+
usage_abort("usage: report-screen delivered <intent_dir>") unless target
|
|
105
|
+
intent_dir = File.expand_path(target)
|
|
106
|
+
usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
|
|
107
|
+
repo_root = File.expand_path("..", __dir__)
|
|
108
|
+
out = ReportScreen.render_delivered(intent_dir: intent_dir, tag_reader: git_tag_reader(repo_root))
|
|
109
|
+
$stdout.write paint(out, ansi_enabled)
|
|
110
|
+
when "delay"
|
|
111
|
+
usage_abort("usage: report-screen delay <intent_dir>") unless target
|
|
112
|
+
intent_dir = File.expand_path(target)
|
|
113
|
+
usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
|
|
114
|
+
out = ReportScreen.render_delay(intent_dir: intent_dir)
|
|
115
|
+
$stdout.write paint(out, ansi_enabled)
|
|
116
|
+
else
|
|
117
|
+
usage_abort("unknown verb #{verb.inspect} (use state|delivered|delay)")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
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
|
|
|
@@ -252,7 +253,8 @@ every choice is non-destructive and the team has full autonomy.
|
|
|
252
253
|
Read `../plastic-conventions/references/completion-and-done.md` for what "intent done" means.
|
|
253
254
|
|
|
254
255
|
1. Verify every checklist item is checked and the suite is green once on the branch.
|
|
255
|
-
2. Write `outcome.md` from `~/.plastic/templates/outcome.md` with `disposition: delivered
|
|
256
|
+
2. Write `outcome.md` from `~/.plastic/templates/outcome.md` with `disposition: delivered`,
|
|
257
|
+
`## Delivered` as the labeled table whose row labels match the action-file headings (317a).
|
|
256
258
|
3. Release, if configured: match the working directory against `~/.plastic/projects.yml`, read
|
|
257
259
|
`project.yml`'s `release` block, and act on `on_complete` (`commit`, `commit_and_push`,
|
|
258
260
|
`manual`), `verify` (green proceeds; red follows `on_red`: `fix_and_retry` up to twice,
|
|
@@ -272,6 +274,8 @@ Read `../plastic-conventions/references/completion-and-done.md` for what "intent
|
|
|
272
274
|
first, or pass `--discard-worktree-changes` deliberately); 3 means the lock survived the
|
|
273
275
|
disarm (`/plastic-doctor check the lock status`); 6 means the structure check refused. Never
|
|
274
276
|
leave an orphaned worktree; run `git worktree prune` on a stale reference.
|
|
277
|
+
6. Print `ruby ~/.plastic/scripts/report-screen delivered <intent_dir>` once (D15): this is the
|
|
278
|
+
owner report at End, replacing the old prose Done briefing.
|
|
275
279
|
|
|
276
280
|
## Error Handling
|
|
277
281
|
|
|
@@ -284,8 +288,8 @@ leaves the project broken.
|
|
|
284
288
|
|
|
285
289
|
- Read `references/agent-architecture.md` for the team model, the risk list, the headless note,
|
|
286
290
|
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
|
|
291
|
+
- Read `references/human-report-contract.md` for the three report screens and the five
|
|
292
|
+
triggers before printing the How or Completion screen above.
|
|
289
293
|
- Read `references/agent-report-contract.md` for the completion report format when reading a
|
|
290
294
|
dispatched agent's return or synthesizing one.
|
|
291
295
|
- 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,3 +103,15 @@ what gets written down.
|
|
|
103
103
|
| Exec | code on the intent branch, checklist checked off | heartbeat; code edits confined to the provisioned worktree; delegates write under the owner's lock | checklist boxes; savepoint milestones; the day-ledger line promotes when a project file lands |
|
|
104
104
|
| End (done) | mandatory `outcome.md` (`disposition: delivered\|abandoned`), INDEX moves to Completed or Abandoned | ordered End tail: verify, merge and remove worktrees, disarm clears `delivery.lock`, then the pointer is purge-eligible, and the QMD reindex runs LAST (after purge); `end-intent` backfills a placeholder `outcome.md` from the record and its structure check reports (never refuses) | savepoint `Done delivered` (or `abandoned`); takeover audits, if any, remain in savepoint.md |
|
|
105
105
|
| Maintenance (Future, Terminal, or Active-with-a-stale-or-no-lock) | `revisions.md` move-and-record entries | detects (never acquires) `delivery.lock`; defers and reports while the target's lock is FRESH (`Lock.fresh?`); a stale or absent lock is not-active, maintenance proceeds | append-only, rule-tagged `revisions.md` entry written in the same operation as the change, or the change is refused; lands via a fresh branch off store main merged back as one closed op, never `git add -A` |
|
|
106
|
+
|
|
107
|
+
## The write guard is not residue
|
|
108
|
+
|
|
109
|
+
`<type>.write.lock` (usually `delivery.write.lock`) is a deliberate sibling
|
|
110
|
+
inode used only for `flock`: no owner, no timestamp, no content, and it is
|
|
111
|
+
NEVER unlinked - deleting it while a writer holds the flock hands the next
|
|
112
|
+
writer a fresh inode at the same path, so two writers hold "the" guard at
|
|
113
|
+
once (see `scripts/lib/lock.rb`, the write-guard comment). A zero-byte
|
|
114
|
+
`*.write.lock` in a completed intent directory is by design; no cleaner may
|
|
115
|
+
sweep it, and it is already inside the store's `*.lock` gitignore rule.
|
|
116
|
+
(Intent 317a, A2: a review misread it as stale residue and nearly shipped
|
|
117
|
+
the sweep.)
|