@zalom/plastic 2.0.0-alpha.12 → 2.0.0-alpha.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hooks/message-display +31 -2
- package/package.json +1 -1
- package/scripts/dashboard.rb +238 -8
- package/scripts/doctor.rb +291 -4
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/doctor_core.rb +97 -2
- package/scripts/lib/hook_replay.rb +128 -0
- package/scripts/lib/installer_core.rb +23 -3
- package/scripts/lib/message_display.rb +151 -37
- package/scripts/lib/report_screen.rb +1139 -35
- package/scripts/lib/roadmap_queue.rb +19 -2
- package/scripts/lib/roadmap_savepoint.rb +36 -7
- package/scripts/lib/savepoint.rb +12 -0
- package/scripts/lib/screen_paint.rb +244 -12
- 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_ledger.rb +4 -0
- package/scripts/lib/verify_intent.rb +33 -0
- package/scripts/report-screen +127 -10
- package/scripts/savepoint-note +11 -9
- package/skills/auto/SKILL.md +13 -12
- package/skills/auto/references/human-report-contract.md +83 -8
- 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 +28 -26
- package/skills/intent-continuing/references/board-fill.md +9 -0
- package/skills/intent-ending/SKILL.md +6 -4
- package/skills/intent-executing/SKILL.md +2 -0
- package/skills/intent-speccing/SKILL.md +7 -4
- package/skills/roadmap/SKILL.md +9 -0
- package/skills/roadmap/references/file-format.md +10 -0
- package/templates/dashboard-screen.md +22 -0
- package/templates/display-fixture.md +21 -0
- package/templates/intent-screen.md +1 -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 +1 -1
|
@@ -34,7 +34,9 @@ class RoadmapQueue
|
|
|
34
34
|
# Entry line parser, anchored on the status vocabulary rather than end of line, so a trailing
|
|
35
35
|
# parenthetical ("delivering (owner ruling...)") does not defeat the match. Accepts the em
|
|
36
36
|
# dash or a hyphen as the separator; roadmap .md files are store-internal and use the em dash.
|
|
37
|
-
|
|
37
|
+
# Intent 331c: group 3 captures the entry's own title text (between the id and the status
|
|
38
|
+
# separator), so a screen reader has it without a second parser; group 4 (was 3) is the status.
|
|
39
|
+
ENTRY = /\A-\s*\[([ xX])\]\s+(\S+)\s+(.*?)[—-]\s*(queued|delivering|delivered|abandoned|blocked)\b/.freeze
|
|
38
40
|
|
|
39
41
|
WAVE_HEADING = /\A###\s+(.+?)\s*\z/.freeze
|
|
40
42
|
|
|
@@ -57,6 +59,21 @@ class RoadmapQueue
|
|
|
57
59
|
analyze(mode: "which")
|
|
58
60
|
end
|
|
59
61
|
|
|
62
|
+
# Intent 331c (D6/R1): the one public reader for ONE roadmap's parsed, INDEX-reconciled shape,
|
|
63
|
+
# so a screen never carries a second parser that can drift from this class's own grammar. Same
|
|
64
|
+
# reconciliation (`reconcile`) and the same frontier selection (`frontier_for`, R17) `queue`/
|
|
65
|
+
# `which` use for the whole tier, scoped to the single file at `path`.
|
|
66
|
+
def roadmap(path)
|
|
67
|
+
parsed = reconcile([parse_roadmap(path)]).first
|
|
68
|
+
{
|
|
69
|
+
slug: parsed[:slug],
|
|
70
|
+
path: parsed[:path],
|
|
71
|
+
grouping: RoadmapSavepoint.grouping_heading(File.read(path)),
|
|
72
|
+
batches: parsed[:waves],
|
|
73
|
+
frontier: frontier_for(parsed),
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
60
77
|
private
|
|
61
78
|
|
|
62
79
|
def analyze(mode:)
|
|
@@ -124,7 +141,7 @@ class RoadmapQueue
|
|
|
124
141
|
current = { heading: m[1], entries: [] }
|
|
125
142
|
waves << current
|
|
126
143
|
elsif current && (em = stripped.match(ENTRY))
|
|
127
|
-
current[:entries] << { id: em[2],
|
|
144
|
+
current[:entries] << { id: em[2], text: em[3].strip, raw_status: em[4].downcase }
|
|
128
145
|
end
|
|
129
146
|
end
|
|
130
147
|
waves
|
|
@@ -66,6 +66,25 @@ module RoadmapSavepoint
|
|
|
66
66
|
File.read(ledger_path).each_line.filter_map { |line| parse_pair(line) }
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
# Public (intent 331c): a roadmap's own ledger, parsed into `[Time, event, detail]` triples in
|
|
70
|
+
# file order - so a screen reader never re-derives the "<iso> <event> <detail>" line shape.
|
|
71
|
+
# `roadmap_path` is the roadmap `.md` file, never the `.savepoint.md` sibling directly (mirrors
|
|
72
|
+
# `ledger_path_for`'s own convention). No paired ledger file -> `[]`, never an invented event.
|
|
73
|
+
def ledger_entries(roadmap_path)
|
|
74
|
+
ledger_path = ledger_path_for(roadmap_path)
|
|
75
|
+
return [] unless File.exist?(ledger_path)
|
|
76
|
+
|
|
77
|
+
File.readlines(ledger_path).filter_map do |line|
|
|
78
|
+
parts = line.strip.split(/\s{2,}/, 3)
|
|
79
|
+
next nil unless parts.length == 3
|
|
80
|
+
begin
|
|
81
|
+
[Time.iso8601(parts[0]), parts[1], parts[2]]
|
|
82
|
+
rescue ArgumentError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
69
88
|
def parse_pair(line)
|
|
70
89
|
parts = line.strip.split(/\s{2,}/)
|
|
71
90
|
parts.length >= 3 ? [parts[1], parts[2]] : nil
|
|
@@ -141,11 +160,13 @@ module RoadmapSavepoint
|
|
|
141
160
|
end
|
|
142
161
|
private_class_method :parse_log_time
|
|
143
162
|
|
|
163
|
+
# Public (intent 331c): the Log table on a roadmap's `delivered` screen classifies every
|
|
164
|
+
# `## Log` line through this same keyword vocabulary, so a screen reader never grows a second
|
|
165
|
+
# copy of KEYWORD_TABLE.
|
|
144
166
|
def classify_event(text)
|
|
145
167
|
hit = KEYWORD_TABLE.find { |regex, _event| text =~ regex }
|
|
146
168
|
hit && hit[1]
|
|
147
169
|
end
|
|
148
|
-
private_class_method :classify_event
|
|
149
170
|
|
|
150
171
|
WAVE_ENTRY = /\A-\s*\[([ xX])\]\s+(\S+)\s+.+—\s*(\S+)\s*\z/.freeze
|
|
151
172
|
|
|
@@ -217,13 +238,21 @@ module RoadmapSavepoint
|
|
|
217
238
|
# already calling `ledger_path_for`), so this is the smaller diff than a new shared module.
|
|
218
239
|
# Raises MissingGroupingHeading, naming the offending path, when neither heading is present.
|
|
219
240
|
def grouping_section_body(text, path: nil)
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
241
|
+
heading = grouping_heading(text)
|
|
242
|
+
unless heading
|
|
243
|
+
raise MissingGroupingHeading,
|
|
244
|
+
"#{path || '(unknown roadmap file)'}: found neither '## Batches' (canonical) nor " \
|
|
245
|
+
"'## Waves' (legacy) grouping heading"
|
|
223
246
|
end
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
247
|
+
text.match(/^##\s+#{Regexp.escape(heading)}\s*$(.*?)(?=^##\s|\z)/m)[1]
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Public (intent 331c): "Batches" or "Waves", whichever grouping heading `text` carries - the
|
|
251
|
+
# one owner of that label so a screen's own field row (and its entries table's column header)
|
|
252
|
+
# never hand-picks between them a second way. nil when neither heading is present (mirrors
|
|
253
|
+
# grouping_section_body's own detection, one call site cheaper than two).
|
|
254
|
+
def grouping_heading(text)
|
|
255
|
+
GROUPING_HEADINGS.find { |heading| text.match?(/^##\s+#{Regexp.escape(heading)}\s*$/) }
|
|
227
256
|
end
|
|
228
257
|
|
|
229
258
|
# Stable dedup on the `(event, detail)` pair, keeping the first occurrence in the given
|
package/scripts/lib/savepoint.rb
CHANGED
|
@@ -227,6 +227,18 @@ module Savepoint
|
|
|
227
227
|
append_savepoint_line(intent_dir, "Commit", text, now)
|
|
228
228
|
end
|
|
229
229
|
|
|
230
|
+
# The day the Report kind (below) shipped. `doctor`'s intent_reports_printed_check reads
|
|
231
|
+
# this so it never re-litigates a ledger recorded before the kind existed (intent 331f, R6).
|
|
232
|
+
REPORT_KIND_SINCE = "2026-09-05"
|
|
233
|
+
|
|
234
|
+
# Append a `Report` line: one per report screen printed (intent 331f, D2/D3). Same primitive
|
|
235
|
+
# as append_review_savepoint/append_commit_savepoint above, so the line shape, dedup, and
|
|
236
|
+
# timestamp format never drift. `savepoint_milestone` maps FILENAMES only, so a Report line
|
|
237
|
+
# is never mistaken for a file-landing lifecycle line by construction.
|
|
238
|
+
def self.append_report_savepoint(intent_dir, text, now: Time.now)
|
|
239
|
+
append_savepoint_line(intent_dir, "Report", text, now)
|
|
240
|
+
end
|
|
241
|
+
|
|
230
242
|
TERMINAL_DISPOSITIONS = %w[delivered abandoned].freeze
|
|
231
243
|
|
|
232
244
|
# Append the terminal bookend `Done delivered|abandoned`, written by the
|
|
@@ -17,11 +17,21 @@ require_relative "intent_screen_ansi"
|
|
|
17
17
|
# markdown_safe are caller arguments, exactly like IntentScreenAnsi before it
|
|
18
18
|
# (316a1); the 318 ceiling holds - the palette is IntentScreenAnsi's, no new
|
|
19
19
|
# colors, no box borders.
|
|
20
|
+
#
|
|
21
|
+
# Intent 331a (D6): a registry, so a new screen KIND is a new file
|
|
22
|
+
# (scripts/lib/screens/<kind>.rb, calling `register` on load), never a diff
|
|
23
|
+
# to this one. `paint:` is optional and defaults to the shared pipeline
|
|
24
|
+
# below - no shipped kind has its own palette; `classify`/`paint` branch on
|
|
25
|
+
# LINE SHAPE, never on kind. The registry's job is only that a new kind's
|
|
26
|
+
# opener is recognized without editing this file, and that a kind CAN
|
|
27
|
+
# supply its own paint lambda on the rare day one needs one.
|
|
20
28
|
module ScreenPaint
|
|
21
29
|
A = IntentScreenAnsi
|
|
22
30
|
|
|
23
31
|
# A screen's first line: "## ▶ id · name", "## ✔ id · name · delivered",
|
|
24
|
-
# "▶ In delivery · ...", "✔ id · name · delivered in ...".
|
|
32
|
+
# "▶ In delivery · ...", "✔ id · name · delivered in ...". Retained for
|
|
33
|
+
# reference (and as the union every shipped kind below decomposes into);
|
|
34
|
+
# `classify`/`paint` consult the registry, not this constant, directly.
|
|
25
35
|
OPENER_RE = /\A(?:## )?[▶✔] .+ · /.freeze
|
|
26
36
|
|
|
27
37
|
FIELD_LINE_RE = /\A(Stage|Next|Changed|Lead|Progress)(\s{2,})(.*)\z/.freeze
|
|
@@ -35,11 +45,145 @@ module ScreenPaint
|
|
|
35
45
|
# text is exactly "not recorded" greys wherever it appears.
|
|
36
46
|
EVIDENCE_PROOF_KINDS = %w[suite red ship doctor deposits verdict].freeze
|
|
37
47
|
EVIDENCE_DEVIATION_KINDS = %w[deviates].freeze
|
|
38
|
-
|
|
48
|
+
# "Reason" is the current header (D5, intent 331f); "Why" stays too so a screen captured
|
|
49
|
+
# before the rename still paints (the header map's own forgiving-reader guarantee).
|
|
50
|
+
NOTE_HEADERS = %w[Source Why Reason].freeze
|
|
39
51
|
NOT_RECORDED = "not recorded"
|
|
40
52
|
|
|
53
|
+
# Intent 331f (finding 1, post-exec review): the width bound a column shrink never crosses,
|
|
54
|
+
# and the glyphs that mark a column as a progress bar, never itself shrunk. Shared with
|
|
55
|
+
# ReportScreen.fit_table_block (report_screen.rb requires this file, not the other way
|
|
56
|
+
# around) so a markdown row and a painted row bound their columns the same way.
|
|
57
|
+
FIT_COLUMN_FLOOR = 8
|
|
58
|
+
PROGRESS_BAR_CHARS_RE = /[█░]/.freeze
|
|
59
|
+
|
|
60
|
+
@registry = {}
|
|
61
|
+
|
|
41
62
|
module_function
|
|
42
63
|
|
|
64
|
+
# Intent 331f1 (spec.md's acceptance rule): the conservative terminal cost used ONLY to
|
|
65
|
+
# enforce the 115-column bound - ANSI escapes stripped, then every character at or above
|
|
66
|
+
# U+1100 (the East Asian Wide/Ambiguous threshold; a block glyph like "█"/"░" sits well past
|
|
67
|
+
# it) counts two columns, everything else one. `IntentScreenAnsi.visible_width` stays a
|
|
68
|
+
# plain ANSI-stripped `.length` because it drives PADDING, not the bound - a terminal that
|
|
69
|
+
# draws "█" one column wide would misalign if padding used this conservative cost.
|
|
70
|
+
# Over-bounding only ever makes a row narrower, never wraps one, so using this rule for the
|
|
71
|
+
# bound alone is always safe.
|
|
72
|
+
WIDE_CODEPOINT_MIN = 0x1100
|
|
73
|
+
|
|
74
|
+
def display_columns(text)
|
|
75
|
+
text.to_s.gsub(A::ANSI_RE, "").each_char.sum { |c| c.ord >= WIDE_CODEPOINT_MIN ? 2 : 1 }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Truncate `text` to at most `max_chars` DISPLAY COLUMNS, cutting at the last whitespace at
|
|
79
|
+
# or before the limit (never mid-word) and appending a single ellipsis when truncation
|
|
80
|
+
# happens. The one shared implementation (intent 331f, finding 1; intent 331f1 finding A1):
|
|
81
|
+
# ReportScreen.truncate_on_word_boundary and dashboard.rb's own helper of the same name both
|
|
82
|
+
# delegate here. The ellipsis (U+2026) itself sits above WIDE_CODEPOINT_MIN, so it costs TWO
|
|
83
|
+
# display columns even though it is one character - the budget reserves that display width,
|
|
84
|
+
# not `ellipsis.length`, or every truncated cell lands one column over (finding A1). Any
|
|
85
|
+
# ellipsis already trailing the word-boundary slice is dropped before the fresh one is
|
|
86
|
+
# appended, so a value truncated twice (the assembled-row backstop truncating a cell that
|
|
87
|
+
# was already cut) never stacks a second ellipsis onto the first.
|
|
88
|
+
def truncate_on_word_boundary(text, max_chars)
|
|
89
|
+
t = text.to_s
|
|
90
|
+
return t if display_columns(t) <= max_chars
|
|
91
|
+
ellipsis = A::ELLIPSIS
|
|
92
|
+
budget = [max_chars - display_columns(ellipsis), 0].max
|
|
93
|
+
# Walk characters accumulating DISPLAY columns, not a raw character index: the row-level
|
|
94
|
+
# backstop calls this on an ALREADY-ASSEMBLED row that can carry several prior per-cell
|
|
95
|
+
# ellipses (each one two display columns for one character), so a plain `t[0, limit]`
|
|
96
|
+
# character slice can under-cut and still land over budget once its own wide characters
|
|
97
|
+
# are counted.
|
|
98
|
+
cols = 0
|
|
99
|
+
cut_at = 0
|
|
100
|
+
t.each_char do |c|
|
|
101
|
+
w = c.ord >= WIDE_CODEPOINT_MIN ? 2 : 1
|
|
102
|
+
break if cols + w > budget
|
|
103
|
+
cols += w
|
|
104
|
+
cut_at += 1
|
|
105
|
+
end
|
|
106
|
+
slice = t[0, cut_at]
|
|
107
|
+
cut = slice.rindex(/\s/)
|
|
108
|
+
slice = slice[0, cut] if cut && cut.positive?
|
|
109
|
+
slice = slice.rstrip
|
|
110
|
+
slice = slice.chomp(ellipsis) while slice.end_with?(ellipsis)
|
|
111
|
+
"#{slice}#{ellipsis}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Shrinks a row of column `widths` until their sum fits `budget`: the widest shrinkable
|
|
115
|
+
# column loses one column at a time, ties break toward the leftmost column, no column ever
|
|
116
|
+
# drops below its own `floors[i]` (FIT_COLUMN_FLOOR for every column when `floors` is
|
|
117
|
+
# omitted, the original behavior), and a column flagged in `bar_columns` (it carries a
|
|
118
|
+
# progress bar) is never touched regardless of its floor. The one shared shrink (intent
|
|
119
|
+
# 331f, finding 1; intent 331f1, S3): ReportScreen.fit_table_block's markdown row and
|
|
120
|
+
# ScreenPaint.paint_data_table's painted row both bound through this rather than carrying
|
|
121
|
+
# two loops that could drift apart. Returns a new array; the caller's own `widths` is left
|
|
122
|
+
# untouched.
|
|
123
|
+
def shrink_column_widths(widths, budget, bar_columns:, floors: nil)
|
|
124
|
+
floors ||= Array.new(widths.length, FIT_COLUMN_FLOOR)
|
|
125
|
+
widths = widths.dup
|
|
126
|
+
loop do
|
|
127
|
+
break if widths.sum <= budget
|
|
128
|
+
candidates = widths.each_index.select { |ci| !bar_columns[ci] && widths[ci] > floors[ci] }
|
|
129
|
+
break if candidates.empty?
|
|
130
|
+
target = candidates.max_by { |ci| [widths[ci], -ci] }
|
|
131
|
+
widths[target] -= 1
|
|
132
|
+
end
|
|
133
|
+
widths
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Intent 331f1, S3 (brief 4): the per-column floor a data-table shrink never crosses - a
|
|
137
|
+
# column never shrinks below its own header cell, below its natural width when that is at
|
|
138
|
+
# most 10 columns (the id case: an id column is already narrow, so "natural width" IS its
|
|
139
|
+
# floor and a uniform 8-column floor could still crush it), or below a bar cell (bar columns
|
|
140
|
+
# are handled separately, by `bar_columns:` above, but a caller may still pass a natural
|
|
141
|
+
# width here too - `[header_len, base].max` never fights that).
|
|
142
|
+
def column_floor(header_len, natural_width)
|
|
143
|
+
base = natural_width <= 10 ? natural_width : FIT_COLUMN_FLOOR
|
|
144
|
+
[header_len, base].max
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Intent 331f1 (post-execution review, findings P2/P3): a padded cell renders as its column
|
|
148
|
+
# width (characters, from `ljust`) plus that cell's own DISPLAY overage -
|
|
149
|
+
# `display_columns(cell) - cell.length` - because a progress-bar glyph or an already-embedded
|
|
150
|
+
# ellipsis costs more display columns than characters. Crediting overage only to columns
|
|
151
|
+
# flagged `bar_columns:` (the old rule) misses a padded cell that carries an ellipsis in an
|
|
152
|
+
# ordinary text column - exactly what ReportScreen.fit_row_cell hands roadmap_state's Intent
|
|
153
|
+
# column - so the assembled row can still land over the bound even though every column width
|
|
154
|
+
# was computed correctly. The real cost is per ROW, not per column: `rows_of_cells` is an
|
|
155
|
+
# array of rows, each an array of already-stripped, not-yet-padded/truncated cell strings, and
|
|
156
|
+
# this returns the worst row's total overage - the one number ScreenPaint.paint_data_table,
|
|
157
|
+
# ReportScreen.fit_table_block, and ReportScreen.fit_field_table_block all reserve out of their
|
|
158
|
+
# budget, so the three renderers spend one rule rather than three copies that can drift.
|
|
159
|
+
def row_display_overage(rows_of_cells)
|
|
160
|
+
rows_of_cells.map { |cells| cells.sum { |c| display_columns(c.to_s) - c.to_s.length } }.max || 0
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Registers a screen kind's opener grammar (a Regexp or a callable taking
|
|
164
|
+
# the stripped opener line and returning truthy/falsy), plus an optional
|
|
165
|
+
# `paint:` lambda for a kind that needs its own palette (`call(text,
|
|
166
|
+
# color:, width:, markdown_safe:)`). Idempotent by kind: registering the
|
|
167
|
+
# same kind again replaces its entry rather than adding a second one.
|
|
168
|
+
def register(kind, opener:, paint: nil)
|
|
169
|
+
@registry[kind.to_sym] = { opener: opener, paint: paint }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Every registered kind's name, shipped and caller-added alike.
|
|
173
|
+
def kinds
|
|
174
|
+
@registry.keys
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The first registered kind whose opener matches `text` (an already
|
|
178
|
+
# stripped, single line), or nil.
|
|
179
|
+
def opener_kind(text)
|
|
180
|
+
@registry.find { |_, entry| opener_matches?(entry[:opener], text) }&.first
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def opener_matches?(opener, text)
|
|
184
|
+
opener.respond_to?(:call) ? !!opener.call(text) : !!opener.match?(text)
|
|
185
|
+
end
|
|
186
|
+
|
|
43
187
|
# The classifier both paint and region_end share. `idx`/`opener_idx` give
|
|
44
188
|
# the positional rule its footing: the line right after a title is the meta
|
|
45
189
|
# line (delivered/delay print one), recognizable by its " · " separators.
|
|
@@ -47,7 +191,7 @@ module ScreenPaint
|
|
|
47
191
|
text = line.chomp
|
|
48
192
|
stripped = text.strip
|
|
49
193
|
return :blank if stripped.empty?
|
|
50
|
-
return :opener if
|
|
194
|
+
return :opener if text == stripped && !opener_kind(stripped).nil?
|
|
51
195
|
return :table if text.lstrip.start_with?("|")
|
|
52
196
|
return :bold if BOLD_LEAD_RE.match?(stripped) && text == stripped
|
|
53
197
|
return :meta if idx && opener_idx && idx == opener_idx + 1 && stripped.include?(" · ")
|
|
@@ -56,7 +200,7 @@ module ScreenPaint
|
|
|
56
200
|
return :step if STEP_LINE_RE.match?(text)
|
|
57
201
|
return :timeline if TIMELINE_RE.match?(text)
|
|
58
202
|
return :count if COUNT_LINE_RE.match?(stripped)
|
|
59
|
-
return :closer if ["None", "not recorded", "No intents in delivery."].include?(stripped)
|
|
203
|
+
return :closer if ["None", "not recorded", "No intents in delivery.", "No intents delivered in this session."].include?(stripped)
|
|
60
204
|
:unknown
|
|
61
205
|
end
|
|
62
206
|
|
|
@@ -101,7 +245,20 @@ module ScreenPaint
|
|
|
101
245
|
lines = text.to_s.lines
|
|
102
246
|
first_idx = lines.index { |l| !l.strip.empty? }
|
|
103
247
|
return nil if first_idx.nil?
|
|
104
|
-
|
|
248
|
+
|
|
249
|
+
kind = opener_kind(lines[first_idx].strip)
|
|
250
|
+
# Intent 330 (D7/O3.26): a screen that is nothing but a single known
|
|
251
|
+
# closer line (e.g. "No intents delivered in this session.") has no
|
|
252
|
+
# opener to require - it is already the whole, honest message.
|
|
253
|
+
return nil unless kind || classify(lines[first_idx]) == :closer
|
|
254
|
+
|
|
255
|
+
# Intent 331a (D6): a registered kind MAY supply its own paint lambda;
|
|
256
|
+
# when it does, this whole call delegates to it instead of the shared
|
|
257
|
+
# pipeline below. No shipped kind does.
|
|
258
|
+
if kind
|
|
259
|
+
custom = @registry[kind][:paint]
|
|
260
|
+
return custom.call(text, color: color, width: width, markdown_safe: markdown_safe) if custom
|
|
261
|
+
end
|
|
105
262
|
|
|
106
263
|
out = +""
|
|
107
264
|
table = []
|
|
@@ -182,8 +339,17 @@ module ScreenPaint
|
|
|
182
339
|
row.split("|", -1).map(&:strip)[1..-2].to_a
|
|
183
340
|
end
|
|
184
341
|
|
|
342
|
+
# Intent 331f1 (finding A3): the ONE classifier, called by both this painter and
|
|
343
|
+
# ReportScreen.fit_table_block's field-vs-data dispatch, so a table is never fitted by one
|
|
344
|
+
# rule and painted by another. A block is a field table when EVERY non-separator,
|
|
345
|
+
# non-blank-scaffold row's first cell is bold - not just the first row (the old rule):
|
|
346
|
+
# a data table whose header cell happens to be bold (e.g. "**Kind**") still has ordinary,
|
|
347
|
+
# unbold data rows underneath it, so it stays a data table. A block with nothing left after
|
|
348
|
+
# stripping separators/scaffolding classifies as neither (false).
|
|
185
349
|
def field_table?(rows)
|
|
186
|
-
rows.
|
|
350
|
+
candidates = rows.reject { |r| SEPARATOR_RE.match?(r) || r.gsub(/[\s|]/, "").empty? }
|
|
351
|
+
return false if candidates.empty?
|
|
352
|
+
candidates.all? { |r| cells_of(r).first.to_s.start_with?("**") }
|
|
187
353
|
end
|
|
188
354
|
|
|
189
355
|
# A field table ("| | | |" scaffold, "| **Key** | value | note |" rows)
|
|
@@ -196,11 +362,11 @@ module ScreenPaint
|
|
|
196
362
|
rows = rows.reject { |r| SEPARATOR_RE.match?(r) || r.gsub(/[\s|]/, "").empty? }
|
|
197
363
|
return "" if rows.empty?
|
|
198
364
|
|
|
199
|
-
if
|
|
365
|
+
if field_table?(rows)
|
|
200
366
|
return paint_field_table(rows, color: color, width: width, markdown_safe: markdown_safe)
|
|
201
367
|
end
|
|
202
368
|
|
|
203
|
-
paint_data_table(rows, color: color, markdown_safe: markdown_safe)
|
|
369
|
+
paint_data_table(rows, color: color, width: width, markdown_safe: markdown_safe)
|
|
204
370
|
end
|
|
205
371
|
|
|
206
372
|
# Intent 317a1 (O3, D9-D11, D14, D15): the same three-column geometry as
|
|
@@ -225,17 +391,66 @@ module ScreenPaint
|
|
|
225
391
|
# bare `widths[ci]`) is the ragged-row guard: `ReportScreen.escape` writes
|
|
226
392
|
# `\|` while `cells_of` still splits on every `|`, so a row can carry more
|
|
227
393
|
# cells than its header without either side ever raising.
|
|
228
|
-
|
|
394
|
+
#
|
|
395
|
+
# Intent 331f (finding 1, post-exec review): padding every cell to its column's max across
|
|
396
|
+
# ALL rows can paint a row wider than `width` even when every plain row on its own measured
|
|
397
|
+
# under the bound (D7 is on the RENDERED row) - a row short in one column but maximal in
|
|
398
|
+
# another paints wider than its own plain row ever was. `width` bounds the column widths the
|
|
399
|
+
# same way ReportScreen.fit_table_block bounds a markdown row's, through the one shared
|
|
400
|
+
# shrink, before any cell is padded or joined.
|
|
401
|
+
def paint_data_table(rows, color:, width:, markdown_safe:)
|
|
229
402
|
grid = rows.map { |r| cells_of(r).map { |c| clean(c, markdown_safe) } }
|
|
230
|
-
|
|
403
|
+
ncols = grid.first.length
|
|
404
|
+
widths = (0...ncols).map { |i| grid.map { |r| r[i].to_s.length }.max }
|
|
405
|
+
bar_columns = (0...ncols).map { |i| grid.any? { |r| r[i].to_s =~ PROGRESS_BAR_CHARS_RE } }
|
|
406
|
+
# Intent 331f1 (post-exec review, P2/P3): the shared row-overage rule, not a per-column bar
|
|
407
|
+
# credit - see ScreenPaint.row_display_overage's own comment for why the bar-only credit
|
|
408
|
+
# missed a padded cell carrying an ellipsis.
|
|
409
|
+
overage = row_display_overage(grid)
|
|
410
|
+
# Intent 331f1, S3 (brief 4): per-column minimums - never below the header cell, never
|
|
411
|
+
# below a natural width of 10 or less (the id case).
|
|
412
|
+
header_len = (0...ncols).map { |i| grid.first[i].to_s.length }
|
|
413
|
+
floors = (0...ncols).map { |i| bar_columns[i] ? widths[i] : column_floor(header_len[i], widths[i]) }
|
|
414
|
+
budget = width - (2 + 2 * (ncols - 1)) - overage
|
|
415
|
+
widths = shrink_column_widths(widths, budget, bar_columns: bar_columns, floors: floors)
|
|
231
416
|
kind_col = grid.first.first == "Kind" ? 0 : nil
|
|
232
417
|
note_col = grid.first.index { |h| NOTE_HEADERS.include?(h) }
|
|
233
418
|
|
|
234
419
|
out = +""
|
|
235
420
|
grid.each_with_index do |cols, ri|
|
|
236
421
|
last_ci = cols.length - 1
|
|
237
|
-
|
|
238
|
-
|
|
422
|
+
texts = cols.each_with_index.map do |cell, ci|
|
|
423
|
+
text = cell.to_s
|
|
424
|
+
w = widths[ci]
|
|
425
|
+
text = truncate_on_word_boundary(text, w) if w && text.length > w
|
|
426
|
+
ci == last_ci ? text : text.ljust(w || 0)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Intent 331f1 (post-exec review, P4): the row-level backstop. Even with every column
|
|
430
|
+
# pinned at its floor, the assembled PLAIN row can still exceed `width` once padding/bar/
|
|
431
|
+
# ellipsis overage is counted - shrink the widest SHRINKABLE cell's PLAIN TEXT (never a
|
|
432
|
+
# bar column, and always before styling, since truncating an already-styled string cuts
|
|
433
|
+
# ANSI escapes mid-sequence) until the row fits or nothing is left to shrink.
|
|
434
|
+
loop do
|
|
435
|
+
row_dw = display_columns((" " + texts.join(" ")).rstrip)
|
|
436
|
+
break if row_dw <= width
|
|
437
|
+
excess = row_dw - width
|
|
438
|
+
shrinkable = (0...ncols).reject { |ci| bar_columns[ci] }
|
|
439
|
+
break if shrinkable.empty?
|
|
440
|
+
target = shrinkable.max_by { |ci| display_columns(texts[ci]) }
|
|
441
|
+
cur_dw = display_columns(texts[target])
|
|
442
|
+
# A bare ellipsis alone already costs 2 display columns; below that floor there is
|
|
443
|
+
# nothing left to cut. Also bail the moment a cut makes no progress (a text already at
|
|
444
|
+
# or under the ellipsis floor re-truncates to the same "…" forever) - a row this wide
|
|
445
|
+
# even at every floor is the documented extreme case, not an infinite loop.
|
|
446
|
+
break if cur_dw <= 2
|
|
447
|
+
shrunk = truncate_on_word_boundary(texts[target].rstrip, [cur_dw - excess, 2].max)
|
|
448
|
+
break if display_columns(shrunk) >= cur_dw
|
|
449
|
+
texts[target] = shrunk
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
cells = texts.each_with_index.map do |padded, ci|
|
|
453
|
+
cell = cols[ci]
|
|
239
454
|
# An empty cell never gets styled (317a1 post-exec review, finding
|
|
240
455
|
# 1): `A.styled("", ...)` still emits a color-open/RESET pair around
|
|
241
456
|
# nothing visible, and that hides the join separator's own trailing
|
|
@@ -273,4 +488,21 @@ module ScreenPaint
|
|
|
273
488
|
def clean(text, markdown_safe)
|
|
274
489
|
markdown_safe ? A.clean(text) : text
|
|
275
490
|
end
|
|
491
|
+
|
|
492
|
+
# --- the five shipped kinds (intent 331a, D6) -----------------------------
|
|
493
|
+
#
|
|
494
|
+
# Each opener is a strict subset of OPENER_RE, decomposed by shape rather
|
|
495
|
+
# than by any per-kind palette: `intent`/`state` share the exact template
|
|
496
|
+
# line (templates/intent-screen.md and templates/report-state.md both open
|
|
497
|
+
# with "## ▶ {{id}} · {{name}}"); `delivered` narrows to the "## ✔ id ·
|
|
498
|
+
# name · delivered" shape report_screen.rb emits; `roster` and `delay`
|
|
499
|
+
# cover every bare (no "## ") glyph line, which is exactly ScreenPaint's
|
|
500
|
+
# original, single OPENER_RE decomposed into its "## "-prefixed half and
|
|
501
|
+
# its bare half - the union is unchanged, so no existing screen stops
|
|
502
|
+
# being recognized.
|
|
503
|
+
register(:intent, opener: /\A## [▶✔] .+ · /)
|
|
504
|
+
register(:state, opener: /\A## [▶✔] .+ · /)
|
|
505
|
+
register(:delivered, opener: /\A## ✔ .+ · /)
|
|
506
|
+
register(:roster, opener: /\A▶ .+ · /)
|
|
507
|
+
register(:delay, opener: /\A✔ .+ · /)
|
|
276
508
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "../screen_paint"
|
|
5
|
+
|
|
6
|
+
# The dashboard screen kind (intent 331d, D4). Its opener is a stricter
|
|
7
|
+
# subset of the already-registered :intent opener (screen_paint.rb:337,
|
|
8
|
+
# registered first), so ScreenPaint.opener_kind never actually answers
|
|
9
|
+
# :dashboard on a live paint call - that is deliberate (R2): :dashboard is
|
|
10
|
+
# tested on its OWN grammar (the OPENER constant below), never on
|
|
11
|
+
# opener_kind's answer, which cannot fail meaningfully here. No custom paint
|
|
12
|
+
# lambda (R3): every line of the screen classifies under the shared
|
|
13
|
+
# field-table/data-table grammar ScreenPaint.paint already carries.
|
|
14
|
+
module Screens
|
|
15
|
+
module Dashboard
|
|
16
|
+
OPENER = /\A## ▶ (?:global|project:[a-z0-9][a-z0-9_-]*) · dashboard\z/.freeze
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
ScreenPaint.register(:dashboard, opener: Screens::Dashboard::OPENER)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "../screen_paint"
|
|
5
|
+
|
|
6
|
+
# Intent 331b (D2/D4): the plan screen's grammar - "## ▶ {id} · {name} ·
|
|
7
|
+
# plan" - registers through 331a's kind registry, a file under
|
|
8
|
+
# scripts/lib/screens/ rather than a diff to screen_paint.rb. F1 (spec.md):
|
|
9
|
+
# opener_kind answers the FIRST registered match, and :intent's own opener
|
|
10
|
+
# (/\A## [▶✔] .+ · /) already matches this title, so this kind's opener is
|
|
11
|
+
# never actually reached - it still names the kind's home and its true
|
|
12
|
+
# grammar, and that is fine, because paint: nil is what D4's "with the state
|
|
13
|
+
# palette" asks for anyway: the shared pipeline branches on LINE SHAPE, never
|
|
14
|
+
# on kind, and no shipped kind carries its own palette. Narrowing :intent's
|
|
15
|
+
# opener from this file to reach a :plan-specific paint lambda is exactly
|
|
16
|
+
# the forbidden loophole spec.md's F1 names - it would silently un-paint the
|
|
17
|
+
# intent and state screens for every other caller, so it never happens here.
|
|
18
|
+
ScreenPaint.register(:plan, opener: /\A## ▶ .+ · plan\z/, paint: nil)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "../screen_paint"
|
|
5
|
+
|
|
6
|
+
# Intent 331c (D7): the roadmap screen kinds. A new screen kind is a file, never a diff to
|
|
7
|
+
# screen_paint.rb (331a's registry, D6): registers `:roadmap_plan`, `:roadmap_state`, and
|
|
8
|
+
# `:roadmap_delivered`, openers that are strict subsets of the shipped intent/delivered openers
|
|
9
|
+
# (`## ▶ ... · roadmap · plan`, `## ▶ ... · roadmap`, `## ✔ ... · roadmap · delivered`), exactly
|
|
10
|
+
# as 331a's five shipped kinds already overlap each other. No `paint:` lambda is supplied: the
|
|
11
|
+
# palette stays IntentScreenAnsi's shared pipeline (the 318 ceiling) - screen_paint.rb's body is
|
|
12
|
+
# not touched by this file.
|
|
13
|
+
ScreenPaint.register(:roadmap_plan, opener: /\A## ▶ .+ · roadmap · plan\z/)
|
|
14
|
+
ScreenPaint.register(:roadmap_state, opener: /\A## ▶ .+ · roadmap\z/)
|
|
15
|
+
ScreenPaint.register(:roadmap_delivered, opener: /\A## ✔ .+ · roadmap · delivered\z/)
|
|
@@ -29,6 +29,10 @@ module SessionLedger
|
|
|
29
29
|
|
|
30
30
|
SESSIONS_DIR = ".sessions"
|
|
31
31
|
TMP_DIR = ".tmp"
|
|
32
|
+
# Named here, not in a CLI, so a caller can reference the key without the
|
|
33
|
+
# literal harness-branded env var name appearing in its own source (a few
|
|
34
|
+
# scripts are guarded against naming a harness at all).
|
|
35
|
+
SESSION_ID_ENV_KEY = "CLAUDE_CODE_SESSION_ID"
|
|
32
36
|
DAY_ID = /\A\d{8}\z/
|
|
33
37
|
EVENTS = %w[Item Done Note]
|
|
34
38
|
STATES = { pending: "~", open: " ", done: "x", moved: ">", dropped: "-", promoted: "^" }.freeze
|
|
@@ -70,6 +70,10 @@ module VerifyIntent
|
|
|
70
70
|
lines.concat(diffstat_lines)
|
|
71
71
|
checks[:diffstat] = diffstat_check
|
|
72
72
|
|
|
73
|
+
report_lines_out, report_check = run_report_lines_check(intent_dir: intent_dir)
|
|
74
|
+
lines.concat(report_lines_out)
|
|
75
|
+
checks[:report] = report_check
|
|
76
|
+
|
|
73
77
|
if Worktree.blank?(suite)
|
|
74
78
|
checks[:suite] = { status: "skipped" }
|
|
75
79
|
else
|
|
@@ -235,6 +239,35 @@ module VerifyIntent
|
|
|
235
239
|
end
|
|
236
240
|
end
|
|
237
241
|
|
|
242
|
+
# --- check: the Report savepoint lines (intent 331f, F17) ------------------------
|
|
243
|
+
|
|
244
|
+
REPORT_LINE_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s{2,}(\S+)\s{2,}(.+?)\s*\z/.freeze
|
|
245
|
+
|
|
246
|
+
# Every `Report`-kind savepoint line for this intent, oldest first: [timestamp, text].
|
|
247
|
+
# Never fails or gates anything (D3: the diffstat check already prints a summary block,
|
|
248
|
+
# this folds into the same verdict so verify-intent surfaces them too) - a delivery with
|
|
249
|
+
# no Report line is visible to `doctor`'s intent_reports_printed_check instead.
|
|
250
|
+
def report_lines(intent_dir)
|
|
251
|
+
path = File.join(intent_dir, "savepoint.md")
|
|
252
|
+
return [] unless File.exist?(path)
|
|
253
|
+
|
|
254
|
+
File.readlines(path).filter_map do |line|
|
|
255
|
+
m = line.strip.match(REPORT_LINE_RE)
|
|
256
|
+
next nil unless m && m[2] == "Report"
|
|
257
|
+
[m[1], m[3]]
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def run_report_lines_check(intent_dir:)
|
|
262
|
+
entries = report_lines(intent_dir)
|
|
263
|
+
if entries.empty?
|
|
264
|
+
[["report lines: none recorded"], { status: "pass", lines: [] }]
|
|
265
|
+
else
|
|
266
|
+
lines = ["report lines:"] + entries.map { |ts, text| "#{ts} Report #{text}" }
|
|
267
|
+
[lines, { status: "pass", lines: entries }]
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
238
271
|
# --- check 4: the optional suite --------------------------------------------------
|
|
239
272
|
|
|
240
273
|
# The supplied command is very likely a ruby command, so it is spawned with RUBYOPT
|