@zalom/plastic 2.0.0-alpha.4 → 2.0.0-alpha.6
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/hooks.json +12 -0
- package/hooks/message-display +71 -0
- package/package.json +1 -1
- package/scripts/dashboard.rb +39 -10
- package/scripts/doctor.rb +163 -0
- package/scripts/hook-capture +19 -7
- package/scripts/hook-message-display +65 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-session-start +23 -1
- package/scripts/intent-screen +25 -4
- package/scripts/lib/doctor_core.rb +4 -3
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/hook_registry.rb +12 -0
- package/scripts/lib/installer_core.rb +26 -1
- package/scripts/lib/intent_screen.rb +79 -17
- package/scripts/lib/intent_screen_ansi.rb +190 -0
- package/scripts/lib/message_display.rb +371 -0
- package/scripts/lib/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +124 -0
- package/skills/intent-continuing/SKILL.md +5 -1
- package/templates/intent-screen.md +0 -5
|
@@ -18,6 +18,18 @@ require_relative "session_ledger"
|
|
|
18
18
|
module DoctorSessionLedger
|
|
19
19
|
ORPHAN_TTL_SECONDS = 24 * 60 * 60
|
|
20
20
|
|
|
21
|
+
# Row H (spec D9): a `.tmp/<sid>/` directory with no `current` pointer is a
|
|
22
|
+
# DIFFERENT orphan class than ORPHAN_TTL_SECONDS's -- a session where
|
|
23
|
+
# session start never ran at all (a `-p` print session, a resumed
|
|
24
|
+
# background job, an unregistered SessionStart hook), not a session that
|
|
25
|
+
# ran and then never closed. No choice of session id ever creates a
|
|
26
|
+
# pointer for that class, so it would otherwise stay invisible for the
|
|
27
|
+
# full 24 hours (or forever, once hook-capture/hook-record stop creating
|
|
28
|
+
# it at all). Short relative to ORPHAN_TTL_SECONDS on purpose: a session
|
|
29
|
+
# between its own start and its first pointer write is normal and must not
|
|
30
|
+
# be flagged, but that window is seconds, not hours.
|
|
31
|
+
NO_POINTER_TTL_SECONDS = 5 * 60
|
|
32
|
+
|
|
21
33
|
# Seconds since the session's last heartbeat: the ISO-8601 content of `heartbeat`,
|
|
22
34
|
# else that file's mtime, else the directory's mtime.
|
|
23
35
|
def heartbeat_age(dir, now)
|
|
@@ -37,6 +49,7 @@ module DoctorSessionLedger
|
|
|
37
49
|
|
|
38
50
|
store_dir = File.join(plastic_home, "store")
|
|
39
51
|
orphans = orphaned_session_dirs(store_dir, now)
|
|
52
|
+
no_pointer = no_pointer_session_dirs(store_dir, now)
|
|
40
53
|
shape = day_ledger_shape_problems(store_dir)
|
|
41
54
|
|
|
42
55
|
checks = []
|
|
@@ -54,6 +67,20 @@ module DoctorSessionLedger
|
|
|
54
67
|
"session is gone: a live session rewrites its heartbeat on every " \
|
|
55
68
|
"prompt and edit, so only a listed directory may be removed, by hand.")
|
|
56
69
|
end
|
|
70
|
+
checks << if no_pointer.empty?
|
|
71
|
+
check(category: "session_ledger", name: "no_pointer_session_tmp", status: "pass",
|
|
72
|
+
message: "No .tmp/<session>/ directory has gone without a `current` pointer for " \
|
|
73
|
+
"longer than #{NO_POINTER_TTL_SECONDS} seconds")
|
|
74
|
+
else
|
|
75
|
+
check(category: "session_ledger", name: "no_pointer_session_tmp", status: "warn",
|
|
76
|
+
message: "#{no_pointer.size} .tmp/<session>/ director#{no_pointer.size == 1 ? "y" : "ies"} " \
|
|
77
|
+
"with no `current` pointer for longer than #{NO_POINTER_TTL_SECONDS} seconds " \
|
|
78
|
+
"(session start never ran for this session)",
|
|
79
|
+
details: no_pointer, fixable: true,
|
|
80
|
+
fix_hint: "Remove each listed .tmp/<session>/ directory after confirming that " \
|
|
81
|
+
"session never started: no `current` pointer means session start never " \
|
|
82
|
+
"ran for it, so this is not a live session missing a checklist entry.")
|
|
83
|
+
end
|
|
57
84
|
checks << if shape.empty?
|
|
58
85
|
check(category: "session_ledger", name: "day_ledger_shape", status: "pass",
|
|
59
86
|
message: "Every .sessions/ entry is a YYYYMMDD day directory with its <day>.md file")
|
|
@@ -86,6 +113,31 @@ module DoctorSessionLedger
|
|
|
86
113
|
[] # unreadable .tmp/: nothing to report, never a crash
|
|
87
114
|
end
|
|
88
115
|
|
|
116
|
+
# Row H (spec D9): `.tmp/<sid>/` directories with no `current` pointer, past
|
|
117
|
+
# NO_POINTER_TTL_SECONDS. A different signal than #orphaned_session_dirs:
|
|
118
|
+
# that one is age-only and blind to whether a pointer exists at all, so a
|
|
119
|
+
# young no-pointer dir (a session between its start and its first pointer
|
|
120
|
+
# write) must not appear here even though it may well appear there once it
|
|
121
|
+
# ages past ORPHAN_TTL_SECONDS -- the two checks answer different questions
|
|
122
|
+
# and a dir can legitimately show up in neither, either, or both.
|
|
123
|
+
def no_pointer_session_dirs(store_dir, now)
|
|
124
|
+
tmp_root = SessionLedger.tmp_root(store_dir)
|
|
125
|
+
return [] unless File.directory?(tmp_root)
|
|
126
|
+
|
|
127
|
+
Dir.children(tmp_root).sort.filter_map do |name|
|
|
128
|
+
dir = File.join(tmp_root, name)
|
|
129
|
+
next unless File.directory?(dir)
|
|
130
|
+
next if File.exist?(File.join(dir, "current"))
|
|
131
|
+
|
|
132
|
+
age = heartbeat_age(dir, now)
|
|
133
|
+
next if age <= NO_POINTER_TTL_SECONDS
|
|
134
|
+
|
|
135
|
+
"global: #{dir} (session #{name}, no `current` pointer, last heartbeat #{age.round}s ago)"
|
|
136
|
+
end
|
|
137
|
+
rescue SystemCallError
|
|
138
|
+
[] # unreadable .tmp/: nothing to report, never a crash
|
|
139
|
+
end
|
|
140
|
+
|
|
89
141
|
def day_ledger_shape_problems(store_dir)
|
|
90
142
|
root = SessionLedger.sessions_root(store_dir)
|
|
91
143
|
return [] unless File.directory?(root)
|
|
@@ -54,6 +54,18 @@ module HookRegistry
|
|
|
54
54
|
{ "name" => "capture", "status" => "Capturing prompt into the session ledger..." },
|
|
55
55
|
] },
|
|
56
56
|
],
|
|
57
|
+
# Intent 316a (D6): Claude Code only, no Codex projection — MessageDisplay
|
|
58
|
+
# is not one of CODEX_LIVE_STATE_EVENTS, so codex_hooks_json (below) never
|
|
59
|
+
# picks it up; codex_hook_names stays exactly what it was (pinned by
|
|
60
|
+
# test/hook_registry_test.rb:82 and :110-111). Fires on every streamed
|
|
61
|
+
# chunk of every assistant message (D11); the launcher (hooks/message-
|
|
62
|
+
# display) decides with shell builtins and forks nothing on the common
|
|
63
|
+
# case, execing Ruby only for a candidate message.
|
|
64
|
+
"MessageDisplay" => [
|
|
65
|
+
{ "matcher" => "", "hooks" => [
|
|
66
|
+
{ "name" => "message-display", "status" => "" },
|
|
67
|
+
] },
|
|
68
|
+
],
|
|
57
69
|
}
|
|
58
70
|
end
|
|
59
71
|
|
|
@@ -76,8 +76,25 @@ class InstallerCore
|
|
|
76
76
|
@agents = agents
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
+
# Reads the package version from `package.json` when present (the source
|
|
80
|
+
# tree, and any dev checkout), falling back to the bare `VERSION` file the
|
|
81
|
+
# installer copies to `~/.plastic` (row B, spec 315b): an installed system
|
|
82
|
+
# carries VERSION but never package.json, so `rollback.rb` and `update.rb`
|
|
83
|
+
# constructing an InstallerCore with `package_root: ~/.plastic` crashed with
|
|
84
|
+
# a raw Errno::ENOENT before this fallback existed. Raises a named error,
|
|
85
|
+
# not a bare ENOENT, when neither file exists, so the caller is not
|
|
86
|
+
# misdirected toward the wrong missing file.
|
|
79
87
|
def read_package_version(root)
|
|
80
|
-
File.
|
|
88
|
+
package_json = File.join(root, "package.json")
|
|
89
|
+
version_file = File.join(root, "VERSION")
|
|
90
|
+
|
|
91
|
+
if File.exist?(package_json)
|
|
92
|
+
JSON.parse(File.read(package_json))["version"]
|
|
93
|
+
elsif File.exist?(version_file)
|
|
94
|
+
File.read(version_file).strip
|
|
95
|
+
else
|
|
96
|
+
raise "cannot determine the package version: neither #{package_json} nor #{version_file} exists"
|
|
97
|
+
end
|
|
81
98
|
end
|
|
82
99
|
|
|
83
100
|
# --- Channel derivation (the channel is encoded in the version string) ---
|
|
@@ -426,6 +443,14 @@ class InstallerCore
|
|
|
426
443
|
"scripts/lib/intent_screen.rb" => "scripts/lib/intent_screen.rb",
|
|
427
444
|
"scripts/intent-screen" => "scripts/intent-screen",
|
|
428
445
|
"scripts/hook-savepoint" => "scripts/hook-savepoint",
|
|
446
|
+
# Intent 316a: hooks/* only glob-copies scripts/*, never scripts/lib/*
|
|
447
|
+
# (see hook_files above), so the two lib files a require_relative
|
|
448
|
+
# between themselves are unguarded there — these three literal
|
|
449
|
+
# entries are their only protection (test/install_sync_test.rb:23-29
|
|
450
|
+
# greps installer_core.rb's own source text for "scripts/<name>").
|
|
451
|
+
"scripts/lib/intent_screen_ansi.rb" => "scripts/lib/intent_screen_ansi.rb",
|
|
452
|
+
"scripts/lib/message_display.rb" => "scripts/lib/message_display.rb",
|
|
453
|
+
"scripts/hook-message-display" => "scripts/hook-message-display",
|
|
429
454
|
}
|
|
430
455
|
end
|
|
431
456
|
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
# encoding: UTF-8
|
|
2
2
|
# frozen_string_literal: true
|
|
3
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
|
|
4
|
+
# record: the intent file, the tier's INDEX.md, savepoint.md and checklist.md.
|
|
5
5
|
# Every number on the screen comes from here so the session never writes one by
|
|
6
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.
|
|
7
15
|
module IntentScreen
|
|
8
16
|
BAR_WIDTH = 20
|
|
9
17
|
ON = "█"
|
|
@@ -11,10 +19,22 @@ module IntentScreen
|
|
|
11
19
|
PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
|
|
12
20
|
SECTIONS = %w[Active Future Completed Abandoned].freeze
|
|
13
21
|
ITEM_RE = /^\s*- \[([ xX])\]\s+(.*)$/
|
|
14
|
-
|
|
22
|
+
# Em dash and en dash added (intent 316a O1e): a checklist item written
|
|
23
|
+
# "S1 — text" (the em dash every checklist this intent writes, and the one a
|
|
24
|
+
# reviewer reads, uses) kept its prefix under the old character class and
|
|
25
|
+
# rendered "S1 [ open ] S1 — text" on screen.
|
|
26
|
+
STEP_PREFIX_RE = /\A(?:Step|S)\s*\d+\s*[-:·—–]\s*/i
|
|
15
27
|
INSIGHT_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s+·\s+\S+\s+·\s+.+?\s+—\s+(.+)\z/
|
|
16
28
|
SAVEPOINT_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s{2,}(\S+)\s{2,}(.+?)\s*\z/
|
|
17
29
|
|
|
30
|
+
# Word-boundary truncation caps (intent 316a D3/O1a/O1c). Never a clause
|
|
31
|
+
# trim: a clause trim on step text destroys a pinned `OPEN:` row
|
|
32
|
+
# (test/intent_screen_test.rb:171-178) that a mid-sentence cut would eat.
|
|
33
|
+
INSIGHT_VALUE_MAX = 72
|
|
34
|
+
INSIGHT_NOTE_MAX = 96
|
|
35
|
+
NEXT_VALUE_MAX = 72
|
|
36
|
+
STEP_TEXT_MAX = 110
|
|
37
|
+
|
|
18
38
|
# Where a resume lands, from the ledger's last line (the boarding matrix).
|
|
19
39
|
def self.landing_stage(stage, milestone)
|
|
20
40
|
case stage
|
|
@@ -54,8 +74,6 @@ module IntentScreen
|
|
|
54
74
|
fields.merge!(next_fields(items, status, checklist_present: items_present?(intent_dir)))
|
|
55
75
|
fields.merge!(insight_fields(intent_text))
|
|
56
76
|
fields["steps.rows"] = steps_rows(items)
|
|
57
|
-
fields["meaning"] = ""
|
|
58
|
-
fields["close"] = ""
|
|
59
77
|
|
|
60
78
|
out = template.dup
|
|
61
79
|
fields.each { |k, v| out = out.gsub("{{#{k}}}", v.to_s) }
|
|
@@ -171,7 +189,11 @@ module IntentScreen
|
|
|
171
189
|
"progress.note" => note }
|
|
172
190
|
end
|
|
173
191
|
|
|
174
|
-
|
|
192
|
+
# `escape_pipes:` (intent 316a O1d, default true) keeps the plain Markdown
|
|
193
|
+
# table's pipe-escaping; the ANSI renderer, which never emits a table,
|
|
194
|
+
# passes `escape_pipes: false` to get the raw value instead of a literal
|
|
195
|
+
# `\|`. Named to not shadow the module's own `escape` method.
|
|
196
|
+
def self.next_fields(items, status, checklist_present:, escape_pipes: true)
|
|
175
197
|
return { "next" => "", "next.note" => "" } if %w[Completed Abandoned].include?(status)
|
|
176
198
|
return { "next" => "write checklist.md", "next.note" => "How" } unless checklist_present
|
|
177
199
|
|
|
@@ -179,43 +201,83 @@ module IntentScreen
|
|
|
179
201
|
return { "next" => "", "next.note" => "all steps done" } unless idx
|
|
180
202
|
|
|
181
203
|
head, = split_first_clause(items[idx][:text])
|
|
182
|
-
|
|
204
|
+
head = truncate_words(head, NEXT_VALUE_MAX)
|
|
205
|
+
head = escape(head) if escape_pipes
|
|
206
|
+
{ "next" => "S#{idx + 1} · #{head}", "next.note" => "first open step" }
|
|
183
207
|
end
|
|
184
208
|
|
|
185
209
|
def self.steps_rows(items)
|
|
186
210
|
return "| | | no steps yet |" if items.empty?
|
|
187
211
|
|
|
188
212
|
items.each_with_index.map do |item, i|
|
|
189
|
-
"| S#{i + 1} | #{item[:done] ? 'done' : 'open'} | #{escape(item[:text])} |"
|
|
213
|
+
"| S#{i + 1} | #{item[:done] ? 'done' : 'open'} | #{escape(step_text(item[:text]))} |"
|
|
190
214
|
end.join("\n")
|
|
191
215
|
end
|
|
192
216
|
|
|
217
|
+
# Public (intent 316a O1c) so the ANSI renderer trims step text identically:
|
|
218
|
+
# word-boundary truncation only, never a clause trim, at STEP_TEXT_MAX.
|
|
219
|
+
def self.step_text(text)
|
|
220
|
+
truncate_words(text, STEP_TEXT_MAX)
|
|
221
|
+
end
|
|
222
|
+
|
|
193
223
|
def self.escape(text)
|
|
194
224
|
text.gsub("|", "\\|")
|
|
195
225
|
end
|
|
196
226
|
|
|
197
227
|
# --- ## Insights ----------------------------------------------------------------
|
|
198
228
|
|
|
199
|
-
def self.insight_fields(intent_text)
|
|
229
|
+
def self.insight_fields(intent_text, escape_pipes: true)
|
|
200
230
|
section = intent_text.split(/^## Insights\s*$/, 2)[1].to_s.split(/^## /, 2)[0].to_s
|
|
201
231
|
entry = section.lines.map(&:strip).reverse.map { |l| l.match(INSIGHT_RE) }.compact.first
|
|
202
232
|
return { "insight" => "none yet", "insight.note" => "" } unless entry
|
|
203
233
|
|
|
204
234
|
ts, text = entry[1], entry[2].strip
|
|
205
235
|
head, tail = split_first_clause(text)
|
|
236
|
+
value = truncate_words(head, INSIGHT_VALUE_MAX)
|
|
237
|
+
tail = tail.empty? ? "" : truncate_words(tail, INSIGHT_NOTE_MAX)
|
|
206
238
|
note = tail.empty? ? human_time(ts) : "#{human_time(ts)} · #{tail}"
|
|
207
|
-
|
|
239
|
+
if escape_pipes
|
|
240
|
+
{ "insight" => escape(value), "insight.note" => escape(note) }
|
|
241
|
+
else
|
|
242
|
+
{ "insight" => value, "insight.note" => note }
|
|
243
|
+
end
|
|
208
244
|
end
|
|
209
245
|
|
|
246
|
+
# First clause of `text`, and at most one following clause as the tail.
|
|
247
|
+
# Anything past the second clause is discarded (intent 316a O1a): the old
|
|
248
|
+
# behavior dumped the ENTIRE remainder into the note (an 800-character
|
|
249
|
+
# real-world tail starting mid-list). Boundary is a `.` or `;` immediately
|
|
250
|
+
# followed by whitespace-then-more or end of string, so "alpha.2" and "2.0"
|
|
251
|
+
# are never mistaken for clause ends.
|
|
210
252
|
def self.split_first_clause(text)
|
|
211
|
-
|
|
212
|
-
head
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
tail = "#{head[cut..].strip} #{tail}".strip
|
|
217
|
-
head = head[0, cut].strip
|
|
218
|
-
end
|
|
253
|
+
head, rest = clause_and_rest(text)
|
|
254
|
+
return [head, ""] unless rest
|
|
255
|
+
|
|
256
|
+
second, more = clause_and_rest(rest)
|
|
257
|
+
tail = more ? second : rest
|
|
219
258
|
[head, tail]
|
|
220
259
|
end
|
|
260
|
+
|
|
261
|
+
# Returns [clause_without_terminal_punctuation, remainder_or_nil]. `nil` for
|
|
262
|
+
# the remainder means either no boundary exists at all, or the boundary
|
|
263
|
+
# sits at the absolute end of `text` (a single trailing clause with nothing
|
|
264
|
+
# after it) — both cases where there is no SECOND clause to fold in.
|
|
265
|
+
def self.clause_and_rest(text)
|
|
266
|
+
m = text.match(/\A(.+?)[.;](\s+(.*)|\z)/m)
|
|
267
|
+
return [text, nil] unless m
|
|
268
|
+
|
|
269
|
+
remainder = m[2].to_s.strip
|
|
270
|
+
remainder.empty? ? [m[1], nil] : [m[1], remainder]
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Word-boundary truncation with a trailing "…" when cut, never mid-word and
|
|
274
|
+
# never a clause trim (intent 316a D3).
|
|
275
|
+
def self.truncate_words(text, max)
|
|
276
|
+
return text if text.length <= max
|
|
277
|
+
return "…" if max <= 1
|
|
278
|
+
|
|
279
|
+
cut = text[0, max - 1].rindex(" ")
|
|
280
|
+
cut = max - 1 if cut.nil? || cut.zero?
|
|
281
|
+
"#{text[0, cut].rstrip}…"
|
|
282
|
+
end
|
|
221
283
|
end
|
|
@@ -0,0 +1,190 @@
|
|
|
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
|
+
module IntentScreenAnsi
|
|
19
|
+
ESC = "\e"
|
|
20
|
+
RESET = "#{ESC}[0m".freeze
|
|
21
|
+
BOLD = "#{ESC}[1m".freeze
|
|
22
|
+
TEAL = "#{ESC}[38;2;45;212;191m".freeze
|
|
23
|
+
AMBER = "#{ESC}[38;2;245;158;11m".freeze
|
|
24
|
+
GRAPHITE_BG = "#{ESC}[48;2;31;41;55m".freeze
|
|
25
|
+
MIDGREY = "#{ESC}[38;2;148;163;184m".freeze
|
|
26
|
+
NEARWHITE = "#{ESC}[38;2;243;244;246m".freeze
|
|
27
|
+
|
|
28
|
+
BAR_CELLS = 24
|
|
29
|
+
EIGHTHS = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"].freeze
|
|
30
|
+
|
|
31
|
+
DEFAULT_WIDTH = 100
|
|
32
|
+
|
|
33
|
+
ELLIPSIS = "…"
|
|
34
|
+
|
|
35
|
+
def self.render(intent_dir:, store_root:, color: true, width: DEFAULT_WIDTH)
|
|
36
|
+
base = File.basename(intent_dir)
|
|
37
|
+
id = base.split("--", 2).first
|
|
38
|
+
intent_text = File.read(File.join(intent_dir, "#{base}.md"))
|
|
39
|
+
|
|
40
|
+
fields = {}
|
|
41
|
+
fields.merge!(IntentScreen.store_fields(store_root))
|
|
42
|
+
status, title = IntentScreen.index_fields(store_root, id)
|
|
43
|
+
fields["status"] = status
|
|
44
|
+
fields["status.note"] = status == "unlisted" ? "no INDEX.md line names this id" : "listed under ## #{status} in INDEX.md"
|
|
45
|
+
fields["id"] = id
|
|
46
|
+
fields["name"] = title || IntentScreen.fallback_name(intent_text)
|
|
47
|
+
fields.merge!(IntentScreen.savepoint_fields(intent_dir, intent_text))
|
|
48
|
+
items = IntentScreen.checklist_items(intent_dir)
|
|
49
|
+
fields.merge!(IntentScreen.progress_fields(items))
|
|
50
|
+
fields.merge!(IntentScreen.next_fields(items, status, checklist_present: IntentScreen.items_present?(intent_dir), escape_pipes: false))
|
|
51
|
+
fields.merge!(IntentScreen.insight_fields(intent_text, escape_pipes: false))
|
|
52
|
+
fields.transform_values! { |v| clean(v) }
|
|
53
|
+
|
|
54
|
+
done_n = fields["progress.done"].to_i
|
|
55
|
+
total_n = fields["progress.total"].to_i
|
|
56
|
+
|
|
57
|
+
out = +""
|
|
58
|
+
out << fit("▶ #{fields['id']} · #{fields['name']}", width) { |t| styled(t, color, BOLD, NEARWHITE) }
|
|
59
|
+
out << "\n\n"
|
|
60
|
+
|
|
61
|
+
# The 4th column marks a row whose value is already a finished, pre-fit
|
|
62
|
+
# string (the Progress bar, built above from styled glyphs plus a count)
|
|
63
|
+
# rather than raw field text still needing `fit_plain`. Naming that
|
|
64
|
+
# explicitly here reads better than testing the value for a leading ESC
|
|
65
|
+
# byte further down, which is really just asking "is this the Progress
|
|
66
|
+
# row?" through a type check.
|
|
67
|
+
field_rows = [
|
|
68
|
+
["Store", fields["store"], fields["store.note"], false],
|
|
69
|
+
["Status", fields["status"], fields["status.note"], false],
|
|
70
|
+
["Stage", fields["stage"], fields["stage.note"], false],
|
|
71
|
+
["Savepoint", fields["savepoint"], fields["savepoint.note"], false],
|
|
72
|
+
["Progress", "#{render_bar(done_n, total_n, color)} #{done_n} / #{total_n}", fields["progress.note"], true],
|
|
73
|
+
["Next", fields["next"], fields["next.note"], false],
|
|
74
|
+
["Insight", fields["insight"], fields["insight.note"], false],
|
|
75
|
+
]
|
|
76
|
+
key_width = field_rows.map { |k, _, _, _| k.length }.max
|
|
77
|
+
prefix_width = key_width + 4 # " " + key.ljust + " "
|
|
78
|
+
|
|
79
|
+
field_rows.each do |key, value, note, prebuilt|
|
|
80
|
+
value_budget = [width - prefix_width, 0].max
|
|
81
|
+
value_text = prebuilt ? value : fit_plain(value, value_budget)
|
|
82
|
+
out << " #{styled(key.ljust(key_width), color, BOLD)} #{value_text}\n"
|
|
83
|
+
next if note.to_s.empty?
|
|
84
|
+
|
|
85
|
+
note_budget = [width - prefix_width, 0].max
|
|
86
|
+
indent = " " * prefix_width
|
|
87
|
+
out << "#{indent}#{fit(note, note_budget) { |t| styled(t, color, MIDGREY) }}\n"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
out << "\n"
|
|
91
|
+
out << fit("Steps", width) { |t| styled(t, color, BOLD, NEARWHITE) }
|
|
92
|
+
out << "\n\n"
|
|
93
|
+
|
|
94
|
+
if items.empty?
|
|
95
|
+
out << " no steps yet\n"
|
|
96
|
+
else
|
|
97
|
+
# Padded to the widest label (matrix B2): at 10+ steps "S10" is one
|
|
98
|
+
# column wider than "S1..S9", and without padding every badge past S9
|
|
99
|
+
# drifts out of column with the rows above it.
|
|
100
|
+
label_width = "S#{items.size}".length
|
|
101
|
+
items.each_with_index do |item, i|
|
|
102
|
+
num = "S#{i + 1}".ljust(label_width)
|
|
103
|
+
badge = status_cell(item[:done], color)
|
|
104
|
+
prefix_plain = " #{num} [ #{item[:done] ? 'done' : 'open'} ] "
|
|
105
|
+
text_budget = [width - prefix_plain.length, 0].max
|
|
106
|
+
text = fit_plain(clean(IntentScreen.step_text(item[:text])), text_budget)
|
|
107
|
+
out << " #{num} [#{badge}] #{text}\n"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
out
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# --- markdown-noise stripping (intent 316a, S1 answer 5 / matrix 19b) ------
|
|
115
|
+
#
|
|
116
|
+
# `displayContent` is still Markdown-processed by Claude Code even inside a
|
|
117
|
+
# raw ANSI block (a live capture showed backticks silently stripped from
|
|
118
|
+
# step text). Strip backticks and neutralise `*`/`_` runs from every value
|
|
119
|
+
# before it reaches the block, so nothing is left for that pass to act on.
|
|
120
|
+
# Single underscores are left alone: they are common inside ordinary words
|
|
121
|
+
# (`intent_screen.rb`) and GFM does not treat an intraword underscore as
|
|
122
|
+
# emphasis; only a run of 2+ (the bold marker `__`) is markdown-active.
|
|
123
|
+
def self.clean(text)
|
|
124
|
+
text.to_s.delete("`*").gsub(/_{2,}/, "")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# --- width cap (D15, matrix 18) --------------------------------------------
|
|
128
|
+
|
|
129
|
+
# Truncates `text` (already markdown-clean) to `max` visible columns with a
|
|
130
|
+
# trailing ellipsis when cut, then yields the truncated plain text to the
|
|
131
|
+
# block for coloring. Coloring never adds visible width.
|
|
132
|
+
def self.fit(text, max)
|
|
133
|
+
plain = fit_plain(text, max)
|
|
134
|
+
block_given? ? yield(plain) : plain
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def self.fit_plain(text, max)
|
|
138
|
+
return "" if max <= 0
|
|
139
|
+
return text if text.length <= max
|
|
140
|
+
return ELLIPSIS[0, max] if max <= 1
|
|
141
|
+
|
|
142
|
+
"#{text[0, max - 1]}#{ELLIPSIS}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# --- palette ----------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
def self.styled(text, color, *codes)
|
|
148
|
+
return text unless color
|
|
149
|
+
|
|
150
|
+
"#{codes.join}#{text}#{RESET}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def self.status_cell(done, color)
|
|
154
|
+
label = done ? " done " : " open "
|
|
155
|
+
return label unless color
|
|
156
|
+
|
|
157
|
+
hue = done ? TEAL : AMBER
|
|
158
|
+
"#{hue}#{BOLD}#{label}#{RESET}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# `.dup` matters, not just style (318's own note, carried forward): these
|
|
162
|
+
# constants are built via string interpolation, which frozen_string_literal
|
|
163
|
+
# does NOT freeze automatically — only static literals get that. `.freeze`
|
|
164
|
+
# above makes them immutable, but `bar << ...` below still needs its OWN
|
|
165
|
+
# mutable copy or it would raise (or, without the freeze, silently corrupt
|
|
166
|
+
# the shared constant for every later call in the same process — matrix 16).
|
|
167
|
+
def self.render_bar(done, total, color)
|
|
168
|
+
ratio = total.zero? ? 0.0 : done.to_f / total
|
|
169
|
+
|
|
170
|
+
unless color
|
|
171
|
+
on = total.zero? ? 0 : (done * BAR_CELLS) / total
|
|
172
|
+
return ("#" * on) + ("." * (BAR_CELLS - on))
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
units = (ratio * BAR_CELLS * 8).round.clamp(0, BAR_CELLS * 8)
|
|
176
|
+
full, rem = units.divmod(8)
|
|
177
|
+
full = [full, BAR_CELLS].min
|
|
178
|
+
|
|
179
|
+
bar = TEAL.dup
|
|
180
|
+
bar << ("█" * full)
|
|
181
|
+
if full < BAR_CELLS && rem.positive?
|
|
182
|
+
bar << EIGHTHS[rem]
|
|
183
|
+
full += 1
|
|
184
|
+
end
|
|
185
|
+
track = BAR_CELLS - full
|
|
186
|
+
bar << GRAPHITE_BG << (" " * track) if track.positive?
|
|
187
|
+
bar << RESET
|
|
188
|
+
bar
|
|
189
|
+
end
|
|
190
|
+
end
|