@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.
Files changed (127) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/bin/test +24 -4
  4. package/hooks/call-budget +4 -0
  5. package/hooks/hooks.json +24 -0
  6. package/hooks/message-display +134 -0
  7. package/hooks/statusline +4 -27
  8. package/package.json +2 -2
  9. package/scripts/agent-report +8 -2
  10. package/scripts/dashboard.rb +297 -18
  11. package/scripts/doctor.rb +652 -5
  12. package/scripts/end-intent +219 -0
  13. package/scripts/hook-call-budget +222 -0
  14. package/scripts/hook-capture +25 -113
  15. package/scripts/hook-message-display +81 -0
  16. package/scripts/hook-record +12 -4
  17. package/scripts/hook-session-start +45 -7
  18. package/scripts/index-projection +74 -0
  19. package/scripts/intent-screen +77 -0
  20. package/scripts/lib/action_graph_shim.rb +277 -0
  21. package/scripts/lib/arm.rb +26 -1
  22. package/scripts/lib/atomic_write.rb +31 -0
  23. package/scripts/lib/compact_instructions.rb +5 -5
  24. package/scripts/lib/core_integrity.rb +71 -0
  25. package/scripts/lib/dashboard_screen.rb +40 -0
  26. package/scripts/lib/day_summary.rb +7 -2
  27. package/scripts/lib/doctor_core.rb +102 -5
  28. package/scripts/lib/doctor_session_ledger.rb +52 -0
  29. package/scripts/lib/graph_edges.rb +137 -0
  30. package/scripts/lib/graph_file.rb +246 -0
  31. package/scripts/lib/graph_tree.rb +98 -0
  32. package/scripts/lib/guarded_append.rb +155 -0
  33. package/scripts/lib/hook_registry.rb +28 -2
  34. package/scripts/lib/hook_replay.rb +211 -0
  35. package/scripts/lib/index_projection.rb +201 -0
  36. package/scripts/lib/installer_core.rb +141 -6
  37. package/scripts/lib/intent_screen.rb +309 -0
  38. package/scripts/lib/intent_screen_ansi.rb +262 -0
  39. package/scripts/lib/message_display.rb +586 -0
  40. package/scripts/lib/meter_watch.rb +179 -0
  41. package/scripts/lib/node_file.rb +214 -0
  42. package/scripts/lib/node_ids.rb +99 -0
  43. package/scripts/lib/node_ledger.rb +377 -0
  44. package/scripts/lib/node_packet.rb +908 -0
  45. package/scripts/lib/node_return.rb +199 -0
  46. package/scripts/lib/node_worktree.rb +337 -0
  47. package/scripts/lib/outcome_report.rb +440 -0
  48. package/scripts/lib/packet_wrapper.rb +132 -0
  49. package/scripts/lib/ready_set.rb +462 -0
  50. package/scripts/lib/release_guard.rb +16 -0
  51. package/scripts/lib/report_screen.rb +1967 -0
  52. package/scripts/lib/roadmap_graph.rb +210 -0
  53. package/scripts/lib/roadmap_migration.rb +95 -0
  54. package/scripts/lib/roadmap_queue.rb +155 -5
  55. package/scripts/lib/roadmap_render.rb +150 -0
  56. package/scripts/lib/roadmap_savepoint.rb +62 -12
  57. package/scripts/lib/runner_absorb.rb +620 -0
  58. package/scripts/lib/runner_answer.rb +206 -0
  59. package/scripts/lib/runner_core.rb +194 -0
  60. package/scripts/lib/runner_dispatch.rb +506 -0
  61. package/scripts/lib/runner_policy.rb +173 -0
  62. package/scripts/lib/runner_proposals.rb +275 -0
  63. package/scripts/lib/runner_rewind.rb +201 -0
  64. package/scripts/lib/runner_sweep.rb +231 -0
  65. package/scripts/lib/savepoint.rb +149 -12
  66. package/scripts/lib/screen_paint.rb +555 -0
  67. package/scripts/lib/screens/dashboard.rb +20 -0
  68. package/scripts/lib/screens/plan.rb +18 -0
  69. package/scripts/lib/screens/roadmap.rb +15 -0
  70. package/scripts/lib/session_git.rb +49 -18
  71. package/scripts/lib/session_ledger.rb +128 -0
  72. package/scripts/lib/session_usage.rb +190 -0
  73. package/scripts/lib/verify_intent.rb +33 -0
  74. package/scripts/lib/work_graph_validator.rb +201 -0
  75. package/scripts/meter-watch +57 -0
  76. package/scripts/node-packet +92 -0
  77. package/scripts/node-transition +291 -0
  78. package/scripts/outcome-report +74 -0
  79. package/scripts/plastic-lock +8 -1
  80. package/scripts/read-config +3 -3
  81. package/scripts/ready-set +126 -0
  82. package/scripts/release-check +118 -0
  83. package/scripts/report-screen +281 -0
  84. package/scripts/roadmap-graph +119 -0
  85. package/scripts/roadmap-savepoint +7 -0
  86. package/scripts/runner +397 -0
  87. package/scripts/savepoint-note +69 -0
  88. package/scripts/session-usage +56 -0
  89. package/scripts/spawn-preamble +9 -2
  90. package/scripts/validate-work-graph +39 -0
  91. package/scripts/verify-intent +2 -1
  92. package/skills/auto/SKILL.md +41 -34
  93. package/skills/auto/references/human-report-contract.md +136 -54
  94. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  95. package/skills/dashboard/SKILL.md +13 -2
  96. package/skills/dashboard/templates/dashboard-global.md +1 -1
  97. package/skills/dashboard/templates/dashboard-project.md +2 -2
  98. package/skills/doctor/SKILL.md +10 -4
  99. package/skills/intent-continuing/SKILL.md +51 -41
  100. package/skills/intent-continuing/references/board-fill.md +9 -0
  101. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  102. package/skills/intent-continuing/references/context-management.md +1 -1
  103. package/skills/intent-ending/SKILL.md +36 -16
  104. package/skills/intent-executing/SKILL.md +21 -5
  105. package/skills/intent-executing/implementer-prompt.md +6 -1
  106. package/skills/intent-speccing/SKILL.md +7 -4
  107. package/skills/releasing/SKILL.md +39 -0
  108. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  109. package/skills/releasing/references/release-lines.md +1 -1
  110. package/skills/roadmap/SKILL.md +26 -0
  111. package/skills/roadmap/references/file-format.md +10 -0
  112. package/templates/config.yml +3 -3
  113. package/templates/dashboard-screen.md +22 -0
  114. package/templates/display-fixture.md +21 -0
  115. package/templates/graph.md +16 -0
  116. package/templates/intent-screen.md +17 -0
  117. package/templates/node-decision.md +11 -0
  118. package/templates/node-research.md +11 -0
  119. package/templates/node-verify.md +13 -0
  120. package/templates/node-work.md +22 -0
  121. package/templates/outcome.md +19 -1
  122. package/templates/report-plan.md +15 -0
  123. package/templates/report-roadmap-delivered.md +10 -0
  124. package/templates/report-roadmap-plan.md +9 -0
  125. package/templates/report-roadmap-state.md +9 -0
  126. package/templates/report-state.md +11 -0
  127. 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