@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.21
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/agents/plastic-enforcer.md +3 -1
- package/agents/plastic-executor.md +9 -3
- package/bin/test +24 -4
- package/hooks/call-budget +4 -0
- package/hooks/hooks.json +24 -0
- package/hooks/message-display +134 -0
- package/hooks/statusline +4 -27
- package/package.json +2 -2
- package/scripts/agent-report +8 -2
- package/scripts/dashboard.rb +297 -18
- package/scripts/doctor.rb +652 -5
- package/scripts/end-intent +219 -0
- package/scripts/hook-call-budget +222 -0
- package/scripts/hook-capture +25 -113
- package/scripts/hook-message-display +81 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-session-start +45 -7
- package/scripts/index-projection +74 -0
- package/scripts/intent-screen +77 -0
- package/scripts/lib/action_graph_shim.rb +277 -0
- package/scripts/lib/arm.rb +26 -1
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/compact_instructions.rb +5 -5
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/day_summary.rb +7 -2
- package/scripts/lib/doctor_core.rb +102 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +137 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/hook_registry.rb +28 -2
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +141 -6
- package/scripts/lib/intent_screen.rb +309 -0
- package/scripts/lib/intent_screen_ansi.rb +262 -0
- package/scripts/lib/message_display.rb +586 -0
- package/scripts/lib/meter_watch.rb +179 -0
- package/scripts/lib/node_file.rb +214 -0
- package/scripts/lib/node_ids.rb +99 -0
- package/scripts/lib/node_ledger.rb +377 -0
- package/scripts/lib/node_packet.rb +908 -0
- package/scripts/lib/node_return.rb +199 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/outcome_report.rb +440 -0
- package/scripts/lib/packet_wrapper.rb +132 -0
- package/scripts/lib/ready_set.rb +462 -0
- package/scripts/lib/release_guard.rb +16 -0
- package/scripts/lib/report_screen.rb +1967 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +155 -5
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/roadmap_savepoint.rb +62 -12
- package/scripts/lib/runner_absorb.rb +620 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +506 -0
- package/scripts/lib/runner_policy.rb +173 -0
- package/scripts/lib/runner_proposals.rb +275 -0
- package/scripts/lib/runner_rewind.rb +201 -0
- package/scripts/lib/runner_sweep.rb +231 -0
- package/scripts/lib/savepoint.rb +149 -12
- package/scripts/lib/screen_paint.rb +555 -0
- package/scripts/lib/screens/dashboard.rb +20 -0
- package/scripts/lib/screens/plan.rb +18 -0
- package/scripts/lib/screens/roadmap.rb +15 -0
- package/scripts/lib/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +128 -0
- package/scripts/lib/session_usage.rb +190 -0
- package/scripts/lib/verify_intent.rb +33 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/meter-watch +57 -0
- package/scripts/node-packet +92 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/plastic-lock +8 -1
- package/scripts/read-config +3 -3
- package/scripts/ready-set +126 -0
- package/scripts/release-check +118 -0
- package/scripts/report-screen +281 -0
- package/scripts/roadmap-graph +119 -0
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/runner +397 -0
- package/scripts/savepoint-note +69 -0
- package/scripts/session-usage +56 -0
- package/scripts/spawn-preamble +9 -2
- package/scripts/validate-work-graph +39 -0
- package/scripts/verify-intent +2 -1
- package/skills/auto/SKILL.md +41 -34
- package/skills/auto/references/human-report-contract.md +136 -54
- package/skills/conventions/references/locks-and-worktrees.md +12 -0
- package/skills/dashboard/SKILL.md +13 -2
- package/skills/dashboard/templates/dashboard-global.md +1 -1
- package/skills/dashboard/templates/dashboard-project.md +2 -2
- package/skills/doctor/SKILL.md +10 -4
- package/skills/intent-continuing/SKILL.md +51 -41
- package/skills/intent-continuing/references/board-fill.md +9 -0
- package/skills/intent-continuing/references/boarding-matrix.md +6 -5
- package/skills/intent-continuing/references/context-management.md +1 -1
- package/skills/intent-ending/SKILL.md +36 -16
- package/skills/intent-executing/SKILL.md +21 -5
- package/skills/intent-executing/implementer-prompt.md +6 -1
- package/skills/intent-speccing/SKILL.md +7 -4
- package/skills/releasing/SKILL.md +39 -0
- package/skills/releasing/references/promotion-and-tagging.md +10 -6
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/roadmap/SKILL.md +26 -0
- package/skills/roadmap/references/file-format.md +10 -0
- package/templates/config.yml +3 -3
- package/templates/dashboard-screen.md +22 -0
- package/templates/display-fixture.md +21 -0
- package/templates/graph.md +16 -0
- package/templates/intent-screen.md +17 -0
- package/templates/node-decision.md +11 -0
- package/templates/node-research.md +11 -0
- package/templates/node-verify.md +13 -0
- package/templates/node-work.md +22 -0
- package/templates/outcome.md +19 -1
- package/templates/report-plan.md +15 -0
- package/templates/report-roadmap-delivered.md +10 -0
- package/templates/report-roadmap-plan.md +9 -0
- package/templates/report-roadmap-state.md +9 -0
- package/templates/report-state.md +11 -0
- package/templates/roadmap.md +13 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
# IntentScreen (intent 316) - fills templates/intent-screen.md from one intent's
|
|
4
|
+
# record: the intent file, the tier's INDEX.md, savepoint.md and checklist.md.
|
|
5
|
+
# Every number on the screen comes from here so the session never writes one by
|
|
6
|
+
# eye. Pure: explicit paths in, a Markdown string out; no ENV, no Dir.pwd.
|
|
7
|
+
#
|
|
8
|
+
# Intent 316a fixed three defects the field code inherited into both the plain
|
|
9
|
+
# renderer and the ANSI renderer (scripts/lib/intent_screen_ansi.rb): the
|
|
10
|
+
# Insight row dumping a multi-clause remainder into the note column, an empty
|
|
11
|
+
# "What this means" heading rendering bold with nothing under it, and step
|
|
12
|
+
# text cut mid-sentence. `step_text`, `insight_fields` and `next_fields` are
|
|
13
|
+
# public so the ANSI renderer reuses the exact same trims (D3) rather than
|
|
14
|
+
# re-deriving them and drifting.
|
|
15
|
+
#
|
|
16
|
+
# Harness-agnostic core: no harness assumption lives here.
|
|
17
|
+
module IntentScreen
|
|
18
|
+
BAR_WIDTH = 20
|
|
19
|
+
ON = "█"
|
|
20
|
+
OFF = "░"
|
|
21
|
+
PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
|
|
22
|
+
SECTIONS = %w[Active Future Completed Abandoned].freeze
|
|
23
|
+
ITEM_RE = /^\s*- \[([ xX])\]\s+(.*)$/
|
|
24
|
+
# Em dash and en dash added (intent 316a O1e): a checklist item written
|
|
25
|
+
# "S1 — text" (the em dash every checklist this intent writes, and the one a
|
|
26
|
+
# reviewer reads, uses) kept its prefix under the old character class and
|
|
27
|
+
# rendered "S1 [ open ] S1 — text" on screen. Intent 317a (A1) made the
|
|
28
|
+
# separator optional with required whitespace: "S1 text" (the shape real
|
|
29
|
+
# checklists use) kept its label and doubled in next_fields; a bare "S1"
|
|
30
|
+
# has no following text and stays prose.
|
|
31
|
+
STEP_PREFIX_RE = /\A(?:Step|S)\s*\d+\s*(?:[-:·—–]\s*|\s+)(?=\S)/i
|
|
32
|
+
INSIGHT_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s+·\s+\S+\s+·\s+.+?\s+—\s+(.+)\z/
|
|
33
|
+
SAVEPOINT_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s{2,}(\S+)\s{2,}(.+?)\s*\z/
|
|
34
|
+
# Intent 317, D6: field-2 tokens that are genuine lifecycle stages. A ledger
|
|
35
|
+
# can also carry non-lifecycle lines (`Lock takeover: ...`, and 317's own
|
|
36
|
+
# `Review`/`Commit`); those must never be mistaken for the current stage.
|
|
37
|
+
# Placed here, clear of STEP_PREFIX_RE above (316a edits that one).
|
|
38
|
+
LIFECYCLE_STAGES = %w[What Why How Exec Done].freeze
|
|
39
|
+
|
|
40
|
+
# True iff a raw savepoint ledger line's field 2 is a genuine lifecycle
|
|
41
|
+
# stage (post-execution-review finding 5: centralizes what spawn-preamble
|
|
42
|
+
# and agent-report used to each copy-paste beside the constant above).
|
|
43
|
+
def self.lifecycle_line?(line)
|
|
44
|
+
parts = line.to_s.split(/\s{2,}/)
|
|
45
|
+
parts.length >= 2 && LIFECYCLE_STAGES.include?(parts[1])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Word-boundary truncation caps (intent 316a D3/O1a/O1c). Never a clause
|
|
49
|
+
# trim: a clause trim on step text destroys a pinned `OPEN:` row
|
|
50
|
+
# (test/intent_screen_test.rb:171-178) that a mid-sentence cut would eat.
|
|
51
|
+
INSIGHT_VALUE_MAX = 72
|
|
52
|
+
INSIGHT_NOTE_MAX = 96
|
|
53
|
+
NEXT_VALUE_MAX = 72
|
|
54
|
+
STEP_TEXT_MAX = 110
|
|
55
|
+
|
|
56
|
+
# Where a resume lands, from the ledger's last line (the boarding matrix).
|
|
57
|
+
def self.landing_stage(stage, milestone)
|
|
58
|
+
case stage
|
|
59
|
+
when "Done" then "Done"
|
|
60
|
+
when "What" then "Why"
|
|
61
|
+
when "Why" then milestone.to_s.include?("spec.md") ? "How" : "Why"
|
|
62
|
+
when "How" then milestone.to_s.include?("checklist.md") ? "Exec" : "How"
|
|
63
|
+
when "Exec" then milestone.to_s.include?("outcome.md") ? "ready to complete" : "Exec"
|
|
64
|
+
else "Why"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.intent_dir?(dir)
|
|
69
|
+
return false unless dir && File.directory?(dir)
|
|
70
|
+
|
|
71
|
+
base = File.basename(dir)
|
|
72
|
+
return false unless base.match?(/\A[0-9][0-9a-z]*--[\w-]+\z/)
|
|
73
|
+
|
|
74
|
+
File.exist?(File.join(dir, "#{base}.md"))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.render(intent_dir:, store_root:, template:)
|
|
78
|
+
base = File.basename(intent_dir)
|
|
79
|
+
id = base.split("--", 2).first
|
|
80
|
+
intent_text = File.read(File.join(intent_dir, "#{base}.md"))
|
|
81
|
+
|
|
82
|
+
fields = {}
|
|
83
|
+
fields.merge!(store_fields(store_root))
|
|
84
|
+
status, title = index_fields(store_root, id)
|
|
85
|
+
fields["status"] = status
|
|
86
|
+
fields["status.note"] = status == "unlisted" ? "no INDEX.md line names this id" : "listed under ## #{status} in INDEX.md"
|
|
87
|
+
fields["id"] = id
|
|
88
|
+
fields["name"] = title || fallback_name(intent_text)
|
|
89
|
+
fields.merge!(savepoint_fields(intent_dir, intent_text))
|
|
90
|
+
items = checklist_items(intent_dir)
|
|
91
|
+
fields.merge!(progress_fields(items))
|
|
92
|
+
fields.merge!(next_fields(items, status, checklist_present: items_present?(intent_dir)))
|
|
93
|
+
fields.merge!(insight_fields(intent_text))
|
|
94
|
+
fields["steps.rows"] = steps_rows(items)
|
|
95
|
+
|
|
96
|
+
out = template.dup
|
|
97
|
+
fields.each { |k, v| out = out.gsub("{{#{k}}}", v.to_s) }
|
|
98
|
+
out.gsub(/\n{3,}/, "\n\n")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# --- store ---------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def self.store_fields(store_root)
|
|
104
|
+
parent = File.basename(File.dirname(store_root))
|
|
105
|
+
if parent == "projects"
|
|
106
|
+
slug = File.basename(store_root)
|
|
107
|
+
{ "store" => "project:#{slug}", "store.note" => "the #{slug} project store" }
|
|
108
|
+
else
|
|
109
|
+
{ "store" => "global", "store.note" => "the global store" }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# --- INDEX.md -----------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def self.index_fields(store_root, id)
|
|
116
|
+
path = File.join(store_root, "INDEX.md")
|
|
117
|
+
return ["unlisted", nil] unless File.exist?(path)
|
|
118
|
+
|
|
119
|
+
section = nil
|
|
120
|
+
File.foreach(path) do |line|
|
|
121
|
+
if line.start_with?("## ")
|
|
122
|
+
name = line[3..].strip
|
|
123
|
+
section = SECTIONS.include?(name) ? name : nil
|
|
124
|
+
next
|
|
125
|
+
end
|
|
126
|
+
next unless section && line.strip.start_with?("- [")
|
|
127
|
+
|
|
128
|
+
m = line.match(/\A\s*- \[#{Regexp.escape(id)}\s+[-—]\s+(.+?)\]\(/)
|
|
129
|
+
return [section, m[1].strip] if m
|
|
130
|
+
end
|
|
131
|
+
["unlisted", nil]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def self.fallback_name(intent_text)
|
|
135
|
+
m = intent_text.match(/^intent:\s*["']?(.+?)["']?\s*$/)
|
|
136
|
+
text = m ? m[1] : ""
|
|
137
|
+
text.length > 60 ? "#{text[0, 57]}..." : text
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# --- savepoint.md ---------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def self.savepoint_fields(intent_dir, intent_text)
|
|
143
|
+
path = File.join(intent_dir, "savepoint.md")
|
|
144
|
+
lines = File.exist?(path) ? File.readlines(path).map(&:strip).reject(&:empty?) : []
|
|
145
|
+
matched = lines.reverse.map { |l| l.match(SAVEPOINT_RE) }.compact
|
|
146
|
+
last = matched.first
|
|
147
|
+
unless last
|
|
148
|
+
return { "stage" => "Why", "stage.note" => "no savepoint line yet",
|
|
149
|
+
"savepoint" => "none", "savepoint.note" => "" }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# D6: the STAGE PICK is guarded to the last LIFECYCLE line (What/Why/How/
|
|
153
|
+
# Exec/Done), so a trailing Lock/Review/Commit line cannot be mistaken for
|
|
154
|
+
# the stage. The Savepoint field below still shows the TRUE last line,
|
|
155
|
+
# whatever its kind - that is what a savepoint is.
|
|
156
|
+
lifecycle_last = matched.find { |m| LIFECYCLE_STAGES.include?(m[2]) }
|
|
157
|
+
stage_source = lifecycle_last || last
|
|
158
|
+
|
|
159
|
+
ts, stage, milestone = last[1], last[2], last[3]
|
|
160
|
+
landing = landing_stage(stage_source[2], stage_source[3])
|
|
161
|
+
delivered = lines.map { |l| l.match(SAVEPOINT_RE) }.compact.map { |m| m[2] }.uniq
|
|
162
|
+
delivered &= %w[What Why How Exec]
|
|
163
|
+
note = if landing == "Done"
|
|
164
|
+
"delivered; the record is immutable"
|
|
165
|
+
elsif landing == "ready to complete"
|
|
166
|
+
"outcome.md is real; run the ending procedure"
|
|
167
|
+
else
|
|
168
|
+
"#{delivered.join(', ')} delivered; the work is open"
|
|
169
|
+
end
|
|
170
|
+
{ "stage" => landing, "stage.note" => note,
|
|
171
|
+
"savepoint" => "#{stage} · #{milestone}", "savepoint.note" => human_time(ts) }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def self.human_time(ts)
|
|
175
|
+
m = ts.match(/\A(\d{4}-\d\d-\d\d)T(\d\d:\d\d)/)
|
|
176
|
+
m ? "#{m[1]} #{m[2]} UTC" : ts
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# --- checklist.md --------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
def self.items_present?(intent_dir)
|
|
182
|
+
path = File.join(intent_dir, "checklist.md")
|
|
183
|
+
return false unless File.exist?(path)
|
|
184
|
+
|
|
185
|
+
!File.read(path).lstrip.start_with?(PLACEHOLDER_SENTINEL)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def self.checklist_items(intent_dir)
|
|
189
|
+
return [] unless items_present?(intent_dir)
|
|
190
|
+
|
|
191
|
+
File.readlines(File.join(intent_dir, "checklist.md")).filter_map do |line|
|
|
192
|
+
m = line.match(ITEM_RE)
|
|
193
|
+
next unless m
|
|
194
|
+
|
|
195
|
+
text = m[2].strip
|
|
196
|
+
next if text == "..."
|
|
197
|
+
|
|
198
|
+
{ done: m[1] != " ", text: text.sub(STEP_PREFIX_RE, "") }
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def self.progress_fields(items)
|
|
203
|
+
total = items.length
|
|
204
|
+
done = items.count { |i| i[:done] }
|
|
205
|
+
on = total.zero? ? 0 : (done * BAR_WIDTH) / total
|
|
206
|
+
bar = (ON * on) + (OFF * (BAR_WIDTH - on))
|
|
207
|
+
note = if total.zero?
|
|
208
|
+
"no checklist yet"
|
|
209
|
+
elsif done == total
|
|
210
|
+
"all steps done"
|
|
211
|
+
else
|
|
212
|
+
"#{total - done} steps open"
|
|
213
|
+
end
|
|
214
|
+
{ "progress.bar" => bar, "progress.done" => done.to_s, "progress.total" => total.to_s,
|
|
215
|
+
"progress.note" => note }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# `escape_pipes:` (intent 316a O1d, default true) keeps the plain Markdown
|
|
219
|
+
# table's pipe-escaping; the ANSI renderer, which never emits a table,
|
|
220
|
+
# passes `escape_pipes: false` to get the raw value instead of a literal
|
|
221
|
+
# `\|`. Named to not shadow the module's own `escape` method.
|
|
222
|
+
def self.next_fields(items, status, checklist_present:, escape_pipes: true)
|
|
223
|
+
return { "next" => "", "next.note" => "" } if %w[Completed Abandoned].include?(status)
|
|
224
|
+
return { "next" => "write checklist.md", "next.note" => "How" } unless checklist_present
|
|
225
|
+
|
|
226
|
+
idx = items.index { |i| !i[:done] }
|
|
227
|
+
return { "next" => "", "next.note" => "all steps done" } unless idx
|
|
228
|
+
|
|
229
|
+
head, = split_first_clause(items[idx][:text])
|
|
230
|
+
head = truncate_words(head, NEXT_VALUE_MAX)
|
|
231
|
+
head = escape(head) if escape_pipes
|
|
232
|
+
{ "next" => "S#{idx + 1} · #{head}", "next.note" => "first open step" }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def self.steps_rows(items)
|
|
236
|
+
return "| | | no steps yet |" if items.empty?
|
|
237
|
+
|
|
238
|
+
items.each_with_index.map do |item, i|
|
|
239
|
+
"| S#{i + 1} | #{item[:done] ? 'done' : 'open'} | #{escape(step_text(item[:text]))} |"
|
|
240
|
+
end.join("\n")
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Public (intent 316a O1c) so the ANSI renderer trims step text identically:
|
|
244
|
+
# word-boundary truncation only, never a clause trim, at STEP_TEXT_MAX.
|
|
245
|
+
def self.step_text(text)
|
|
246
|
+
truncate_words(text, STEP_TEXT_MAX)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def self.escape(text)
|
|
250
|
+
text.gsub("|", "\\|")
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# --- ## Insights ----------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
def self.insight_fields(intent_text, escape_pipes: true)
|
|
256
|
+
section = intent_text.split(/^## Insights\s*$/, 2)[1].to_s.split(/^## /, 2)[0].to_s
|
|
257
|
+
entry = section.lines.map(&:strip).reverse.map { |l| l.match(INSIGHT_RE) }.compact.first
|
|
258
|
+
return { "insight" => "none yet", "insight.note" => "" } unless entry
|
|
259
|
+
|
|
260
|
+
ts, text = entry[1], entry[2].strip
|
|
261
|
+
head, tail = split_first_clause(text)
|
|
262
|
+
value = truncate_words(head, INSIGHT_VALUE_MAX)
|
|
263
|
+
tail = tail.empty? ? "" : truncate_words(tail, INSIGHT_NOTE_MAX)
|
|
264
|
+
note = tail.empty? ? human_time(ts) : "#{human_time(ts)} · #{tail}"
|
|
265
|
+
if escape_pipes
|
|
266
|
+
{ "insight" => escape(value), "insight.note" => escape(note) }
|
|
267
|
+
else
|
|
268
|
+
{ "insight" => value, "insight.note" => note }
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# First clause of `text`, and at most one following clause as the tail.
|
|
273
|
+
# Anything past the second clause is discarded (intent 316a O1a): the old
|
|
274
|
+
# behavior dumped the ENTIRE remainder into the note (an 800-character
|
|
275
|
+
# real-world tail starting mid-list). Boundary is a `.` or `;` immediately
|
|
276
|
+
# followed by whitespace-then-more or end of string, so "alpha.2" and "2.0"
|
|
277
|
+
# are never mistaken for clause ends.
|
|
278
|
+
def self.split_first_clause(text)
|
|
279
|
+
head, rest = clause_and_rest(text)
|
|
280
|
+
return [head, ""] unless rest
|
|
281
|
+
|
|
282
|
+
second, more = clause_and_rest(rest)
|
|
283
|
+
tail = more ? second : rest
|
|
284
|
+
[head, tail]
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Returns [clause_without_terminal_punctuation, remainder_or_nil]. `nil` for
|
|
288
|
+
# the remainder means either no boundary exists at all, or the boundary
|
|
289
|
+
# sits at the absolute end of `text` (a single trailing clause with nothing
|
|
290
|
+
# after it) — both cases where there is no SECOND clause to fold in.
|
|
291
|
+
def self.clause_and_rest(text)
|
|
292
|
+
m = text.match(/\A(.+?)[.;](\s+(.*)|\z)/m)
|
|
293
|
+
return [text, nil] unless m
|
|
294
|
+
|
|
295
|
+
remainder = m[2].to_s.strip
|
|
296
|
+
remainder.empty? ? [m[1], nil] : [m[1], remainder]
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Word-boundary truncation with a trailing "…" when cut, never mid-word and
|
|
300
|
+
# never a clause trim (intent 316a D3).
|
|
301
|
+
def self.truncate_words(text, max)
|
|
302
|
+
return text if text.length <= max
|
|
303
|
+
return "…" if max <= 1
|
|
304
|
+
|
|
305
|
+
cut = text[0, max - 1].rindex(" ")
|
|
306
|
+
cut = max - 1 if cut.nil? || cut.zero?
|
|
307
|
+
"#{text[0, cut].rstrip}…"
|
|
308
|
+
end
|
|
309
|
+
end
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "intent_screen"
|
|
5
|
+
|
|
6
|
+
# IntentScreenAnsi (intent 316a, O2) - renders one intent screen with raw
|
|
7
|
+
# truecolor ANSI escapes, productionizing 318's mockup--render.rb. Standard
|
|
8
|
+
# library only. Calls the SAME public IntentScreen.* field methods
|
|
9
|
+
# scripts/intent-screen calls (store_fields, index_fields, savepoint_fields,
|
|
10
|
+
# checklist_items, progress_fields, next_fields, insight_fields,
|
|
11
|
+
# items_present?, fallback_name, step_text) and re-derives nothing, so every
|
|
12
|
+
# field the ANSI block prints is the identical value the plain screen prints
|
|
13
|
+
# (D3), just carried through a different layout.
|
|
14
|
+
#
|
|
15
|
+
# `color:` is a constructor/call argument, never an environment read (D18):
|
|
16
|
+
# the plain path (`color: false`) is one call away and testable without
|
|
17
|
+
# touching NO_COLOR or a TTY. No ENV, no Dir.pwd, no Dir.home.
|
|
18
|
+
#
|
|
19
|
+
# Harness-agnostic core: no harness assumption lives here. `markdown_safe:`
|
|
20
|
+
# (intent 316a1, D3/D5) is the one choice a caller supplies rather than a
|
|
21
|
+
# choice this module makes for itself: a display surface that passes raw
|
|
22
|
+
# ANSI through untouched should not inherit a concession it never needed.
|
|
23
|
+
# See docs/reference/harness-adapters.md for which caller asks for it and
|
|
24
|
+
# why.
|
|
25
|
+
module IntentScreenAnsi
|
|
26
|
+
ESC = "\e"
|
|
27
|
+
RESET = "#{ESC}[0m".freeze
|
|
28
|
+
BOLD = "#{ESC}[1m".freeze
|
|
29
|
+
TEAL = "#{ESC}[38;2;45;212;191m".freeze
|
|
30
|
+
AMBER = "#{ESC}[38;2;245;158;11m".freeze
|
|
31
|
+
GRAPHITE_BG = "#{ESC}[48;2;31;41;55m".freeze
|
|
32
|
+
MIDGREY = "#{ESC}[38;2;148;163;184m".freeze
|
|
33
|
+
NEARWHITE = "#{ESC}[38;2;243;244;246m".freeze
|
|
34
|
+
|
|
35
|
+
BAR_CELLS = 24
|
|
36
|
+
EIGHTHS = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"].freeze
|
|
37
|
+
|
|
38
|
+
# Intent 317a1 (D14): the approved design is a 115-column layout, measured
|
|
39
|
+
# from design--terminal-output.html:52-58 with its tags stripped.
|
|
40
|
+
DEFAULT_WIDTH = 115
|
|
41
|
+
|
|
42
|
+
ELLIPSIS = "…"
|
|
43
|
+
|
|
44
|
+
# Intent 317a1 (D9-D11, D15): the three-column field table's geometry.
|
|
45
|
+
# ANSI_RE strips escapes so padding measures what the terminal actually
|
|
46
|
+
# draws (D10); VALUE_COL_MAX is the design's own value-column figure and
|
|
47
|
+
# the owner's "50 chars" (mockup--report-screen.md:74); NOTE_FLOOR is the
|
|
48
|
+
# minimum note length that earns the own-line fallback when the note alone
|
|
49
|
+
# (not the value) is what overflows the same-line budget.
|
|
50
|
+
ANSI_RE = /\e\[[0-9;]*m/.freeze
|
|
51
|
+
VALUE_COL_MAX = 50
|
|
52
|
+
NOTE_FLOOR = 24
|
|
53
|
+
|
|
54
|
+
def self.visible_width(text)
|
|
55
|
+
text.to_s.gsub(ANSI_RE, "").length
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.render(intent_dir:, store_root:, color: true, width: DEFAULT_WIDTH, markdown_safe: false)
|
|
59
|
+
base = File.basename(intent_dir)
|
|
60
|
+
id = base.split("--", 2).first
|
|
61
|
+
intent_text = File.read(File.join(intent_dir, "#{base}.md"))
|
|
62
|
+
|
|
63
|
+
fields = {}
|
|
64
|
+
fields.merge!(IntentScreen.store_fields(store_root))
|
|
65
|
+
status, title = IntentScreen.index_fields(store_root, id)
|
|
66
|
+
fields["status"] = status
|
|
67
|
+
fields["status.note"] = status == "unlisted" ? "no INDEX.md line names this id" : "listed under ## #{status} in INDEX.md"
|
|
68
|
+
fields["id"] = id
|
|
69
|
+
fields["name"] = title || IntentScreen.fallback_name(intent_text)
|
|
70
|
+
fields.merge!(IntentScreen.savepoint_fields(intent_dir, intent_text))
|
|
71
|
+
items = IntentScreen.checklist_items(intent_dir)
|
|
72
|
+
fields.merge!(IntentScreen.progress_fields(items))
|
|
73
|
+
fields.merge!(IntentScreen.next_fields(items, status, checklist_present: IntentScreen.items_present?(intent_dir), escape_pipes: false))
|
|
74
|
+
fields.merge!(IntentScreen.insight_fields(intent_text, escape_pipes: false))
|
|
75
|
+
fields.transform_values! { |v| markdown_safe ? clean(v) : v }
|
|
76
|
+
|
|
77
|
+
done_n = fields["progress.done"].to_i
|
|
78
|
+
total_n = fields["progress.total"].to_i
|
|
79
|
+
|
|
80
|
+
out = +""
|
|
81
|
+
out << fit("▶ #{fields['id']} · #{fields['name']}", width) { |t| styled(t, color, BOLD, NEARWHITE) }
|
|
82
|
+
out << "\n\n"
|
|
83
|
+
|
|
84
|
+
# The 4th column marks a row whose value is already a finished, pre-fit
|
|
85
|
+
# string (the Progress bar, built above from styled glyphs plus a count)
|
|
86
|
+
# rather than raw field text still needing `fit_plain`. Naming that
|
|
87
|
+
# explicitly here reads better than testing the value for a leading ESC
|
|
88
|
+
# byte further down, which is really just asking "is this the Progress
|
|
89
|
+
# row?" through a type check.
|
|
90
|
+
field_rows = [
|
|
91
|
+
["Store", fields["store"], fields["store.note"], false],
|
|
92
|
+
["Status", fields["status"], fields["status.note"], false],
|
|
93
|
+
["Stage", fields["stage"], fields["stage.note"], false],
|
|
94
|
+
["Savepoint", fields["savepoint"], fields["savepoint.note"], false],
|
|
95
|
+
["Progress", "#{render_bar(done_n, total_n, color)} #{done_n} / #{total_n}", fields["progress.note"], true],
|
|
96
|
+
["Next", fields["next"], fields["next.note"], false],
|
|
97
|
+
["Insight", fields["insight"], fields["insight.note"], false],
|
|
98
|
+
]
|
|
99
|
+
key_width = field_rows.map { |k, _, _, _| k.length }.max
|
|
100
|
+
|
|
101
|
+
out << field_table_lines(field_rows, width: width, color: color, key_width: key_width)
|
|
102
|
+
|
|
103
|
+
out << "\n"
|
|
104
|
+
out << fit("Steps", width) { |t| styled(t, color, BOLD, NEARWHITE) }
|
|
105
|
+
out << "\n\n"
|
|
106
|
+
|
|
107
|
+
if items.empty?
|
|
108
|
+
out << " no steps yet\n"
|
|
109
|
+
else
|
|
110
|
+
# Padded to the widest label (matrix B2): at 10+ steps "S10" is one
|
|
111
|
+
# column wider than "S1..S9", and without padding every badge past S9
|
|
112
|
+
# drifts out of column with the rows above it.
|
|
113
|
+
label_width = "S#{items.size}".length
|
|
114
|
+
items.each_with_index do |item, i|
|
|
115
|
+
num = "S#{i + 1}".ljust(label_width)
|
|
116
|
+
badge = status_cell(item[:done], color)
|
|
117
|
+
prefix_plain = " #{num} [ #{item[:done] ? 'done' : 'open'} ] "
|
|
118
|
+
text_budget = [width - prefix_plain.length, 0].max
|
|
119
|
+
step = IntentScreen.step_text(item[:text])
|
|
120
|
+
text = fit_plain(markdown_safe ? clean(step) : step, text_budget)
|
|
121
|
+
out << " #{num} [#{badge}] #{text}\n"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
out
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# --- shared field-table geometry (D9-D12, D14, D15) ------------------------
|
|
129
|
+
|
|
130
|
+
# The field table's basis, cap, budget arithmetic, both fallbacks, and the
|
|
131
|
+
# right-strip, shared by `render` above and `ScreenPaint.paint_table`'s
|
|
132
|
+
# `**Key**` branch, so the two renderers can never drift apart (D12) - the
|
|
133
|
+
# duplication this replaced was character-for-character identical apart
|
|
134
|
+
# from the module prefix. `rows` is `[key, value, note]`, or `[key, value,
|
|
135
|
+
# note, true]` when `value` is already a finished, pre-fit string (the
|
|
136
|
+
# Progress bar) rather than raw text still needing `fit_plain`.
|
|
137
|
+
#
|
|
138
|
+
# Notes become a third column, padded to the widest RENDERED noted value
|
|
139
|
+
# (capped at VALUE_COL_MAX, D15) so every note starts at one raw-text
|
|
140
|
+
# position; a row whose value or note will not fit drops to the
|
|
141
|
+
# note-on-its-own-line form instead of squeezing anything invisibly. When
|
|
142
|
+
# the same-line budget has already collapsed to zero or less, the note
|
|
143
|
+
# falls straight to its own line rather than being fed to `fit` and
|
|
144
|
+
# silently returning "" - the note must be cut with a visible ellipsis or
|
|
145
|
+
# kept whole, never dropped outright (317a1 post-exec review, finding 4).
|
|
146
|
+
def self.field_table_lines(rows, width:, color:, key_width:)
|
|
147
|
+
prefix_width = key_width + 4 # " " + key.ljust + " "
|
|
148
|
+
|
|
149
|
+
rendered_rows = rows.map do |key, value, note, prebuilt|
|
|
150
|
+
value_budget = [width - prefix_width, 0].max
|
|
151
|
+
value_text = prebuilt ? value : fit_plain(value.to_s, value_budget)
|
|
152
|
+
[key.to_s, value_text, note.to_s]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
noted_widths = rendered_rows.filter_map { |_, value_text, note| visible_width(value_text) unless note.empty? }
|
|
156
|
+
value_col = [noted_widths.max.to_i, VALUE_COL_MAX].min
|
|
157
|
+
note_budget = width - prefix_width - value_col - 2
|
|
158
|
+
|
|
159
|
+
out = +""
|
|
160
|
+
rendered_rows.each do |key, value_text, note|
|
|
161
|
+
row = " #{styled(key.ljust(key_width), color, BOLD)} #{value_text}"
|
|
162
|
+
if note.empty?
|
|
163
|
+
out << row.rstrip << "\n"
|
|
164
|
+
next
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
value_fits = visible_width(value_text) <= value_col
|
|
168
|
+
note_width = visible_width(note)
|
|
169
|
+
note_overflows = note_width > note_budget
|
|
170
|
+
note_fits = !note_overflows || (note_budget.positive? && note_width <= NOTE_FLOOR)
|
|
171
|
+
if value_fits && note_fits
|
|
172
|
+
pad = value_col - visible_width(value_text)
|
|
173
|
+
out << row << (" " * pad) << " " << fit(note, note_budget) { |t| styled(t, color, MIDGREY) } << "\n"
|
|
174
|
+
else
|
|
175
|
+
out << row.rstrip << "\n"
|
|
176
|
+
own_budget = [width - prefix_width, 0].max
|
|
177
|
+
out << (" " * prefix_width) << fit(note, own_budget) { |t| styled(t, color, MIDGREY) } << "\n"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
out
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# --- markdown-noise stripping, adapter-optional (intent 316a1, D3/D5) ------
|
|
184
|
+
#
|
|
185
|
+
# Not every display surface passes text through a Markdown renderer, so
|
|
186
|
+
# stripping is not this module's call to make (see `markdown_safe:` on
|
|
187
|
+
# `render` above; the justification for WHY a caller would ever ask for
|
|
188
|
+
# this lives with that caller, in scripts/lib/message_display.rb). When
|
|
189
|
+
# asked, strips backticks and neutralises `*`/`_` runs from a value.
|
|
190
|
+
# Single underscores are left alone: they are common inside ordinary words
|
|
191
|
+
# (`intent_screen.rb`) and GFM does not treat an intraword underscore as
|
|
192
|
+
# emphasis; only a run of 2+ (the bold marker `__`) is markdown-active.
|
|
193
|
+
def self.clean(text)
|
|
194
|
+
text.to_s.delete("`*").gsub(/_{2,}/, "")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# --- width cap (D15, matrix 18) --------------------------------------------
|
|
198
|
+
|
|
199
|
+
# Truncates `text` to `max` visible columns with a trailing ellipsis when
|
|
200
|
+
# cut, then yields the truncated plain text to the block for coloring.
|
|
201
|
+
# Coloring never adds visible width. The cap itself is harness-neutral: a
|
|
202
|
+
# fixed width, not a re-flow, is what lets a column layout survive whatever
|
|
203
|
+
# display eventually shows it — no display's own wrapping is assumed here.
|
|
204
|
+
def self.fit(text, max)
|
|
205
|
+
plain = fit_plain(text, max)
|
|
206
|
+
block_given? ? yield(plain) : plain
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def self.fit_plain(text, max)
|
|
210
|
+
return "" if max <= 0
|
|
211
|
+
return text if text.length <= max
|
|
212
|
+
return ELLIPSIS[0, max] if max <= 1
|
|
213
|
+
|
|
214
|
+
"#{text[0, max - 1]}#{ELLIPSIS}"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# --- palette ----------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def self.styled(text, color, *codes)
|
|
220
|
+
return text unless color
|
|
221
|
+
|
|
222
|
+
"#{codes.join}#{text}#{RESET}"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def self.status_cell(done, color)
|
|
226
|
+
label = done ? " done " : " open "
|
|
227
|
+
return label unless color
|
|
228
|
+
|
|
229
|
+
hue = done ? TEAL : AMBER
|
|
230
|
+
"#{hue}#{BOLD}#{label}#{RESET}"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# `.dup` matters, not just style (318's own note, carried forward): these
|
|
234
|
+
# constants are built via string interpolation, which frozen_string_literal
|
|
235
|
+
# does NOT freeze automatically — only static literals get that. `.freeze`
|
|
236
|
+
# above makes them immutable, but `bar << ...` below still needs its OWN
|
|
237
|
+
# mutable copy or it would raise (or, without the freeze, silently corrupt
|
|
238
|
+
# the shared constant for every later call in the same process — matrix 16).
|
|
239
|
+
def self.render_bar(done, total, color)
|
|
240
|
+
ratio = total.zero? ? 0.0 : done.to_f / total
|
|
241
|
+
|
|
242
|
+
unless color
|
|
243
|
+
on = total.zero? ? 0 : (done * BAR_CELLS) / total
|
|
244
|
+
return ("#" * on) + ("." * (BAR_CELLS - on))
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
units = (ratio * BAR_CELLS * 8).round.clamp(0, BAR_CELLS * 8)
|
|
248
|
+
full, rem = units.divmod(8)
|
|
249
|
+
full = [full, BAR_CELLS].min
|
|
250
|
+
|
|
251
|
+
bar = TEAL.dup
|
|
252
|
+
bar << ("█" * full)
|
|
253
|
+
if full < BAR_CELLS && rem.positive?
|
|
254
|
+
bar << EIGHTHS[rem]
|
|
255
|
+
full += 1
|
|
256
|
+
end
|
|
257
|
+
track = BAR_CELLS - full
|
|
258
|
+
bar << GRAPHITE_BG << (" " * track) if track.positive?
|
|
259
|
+
bar << RESET
|
|
260
|
+
bar
|
|
261
|
+
end
|
|
262
|
+
end
|