@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/plastic-enforcer.md +3 -1
- package/agents/plastic-executor.md +9 -3
- package/bin/test +24 -4
- package/hooks/call-budget +4 -0
- package/hooks/hooks.json +24 -0
- package/hooks/message-display +134 -0
- package/hooks/statusline +4 -27
- package/package.json +2 -2
- package/scripts/agent-report +8 -2
- package/scripts/dashboard.rb +297 -18
- package/scripts/doctor.rb +652 -5
- package/scripts/end-intent +219 -0
- package/scripts/hook-call-budget +222 -0
- package/scripts/hook-capture +25 -113
- package/scripts/hook-message-display +81 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-session-start +45 -7
- package/scripts/index-projection +74 -0
- package/scripts/intent-screen +77 -0
- package/scripts/lib/action_graph_shim.rb +277 -0
- package/scripts/lib/arm.rb +26 -1
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/compact_instructions.rb +5 -5
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/day_summary.rb +7 -2
- package/scripts/lib/doctor_core.rb +102 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +137 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/hook_registry.rb +28 -2
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +141 -6
- package/scripts/lib/intent_screen.rb +309 -0
- package/scripts/lib/intent_screen_ansi.rb +262 -0
- package/scripts/lib/message_display.rb +586 -0
- package/scripts/lib/meter_watch.rb +179 -0
- package/scripts/lib/node_file.rb +214 -0
- package/scripts/lib/node_ids.rb +99 -0
- package/scripts/lib/node_ledger.rb +377 -0
- package/scripts/lib/node_packet.rb +908 -0
- package/scripts/lib/node_return.rb +199 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/outcome_report.rb +440 -0
- package/scripts/lib/packet_wrapper.rb +132 -0
- package/scripts/lib/ready_set.rb +462 -0
- package/scripts/lib/release_guard.rb +16 -0
- package/scripts/lib/report_screen.rb +1967 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +155 -5
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/roadmap_savepoint.rb +62 -12
- package/scripts/lib/runner_absorb.rb +620 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +506 -0
- package/scripts/lib/runner_policy.rb +173 -0
- package/scripts/lib/runner_proposals.rb +275 -0
- package/scripts/lib/runner_rewind.rb +201 -0
- package/scripts/lib/runner_sweep.rb +231 -0
- package/scripts/lib/savepoint.rb +149 -12
- package/scripts/lib/screen_paint.rb +555 -0
- package/scripts/lib/screens/dashboard.rb +20 -0
- package/scripts/lib/screens/plan.rb +18 -0
- package/scripts/lib/screens/roadmap.rb +15 -0
- package/scripts/lib/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +128 -0
- package/scripts/lib/session_usage.rb +190 -0
- package/scripts/lib/verify_intent.rb +33 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/meter-watch +57 -0
- package/scripts/node-packet +92 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/plastic-lock +8 -1
- package/scripts/read-config +3 -3
- package/scripts/ready-set +126 -0
- package/scripts/release-check +118 -0
- package/scripts/report-screen +281 -0
- package/scripts/roadmap-graph +119 -0
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/runner +397 -0
- package/scripts/savepoint-note +69 -0
- package/scripts/session-usage +56 -0
- package/scripts/spawn-preamble +9 -2
- package/scripts/validate-work-graph +39 -0
- package/scripts/verify-intent +2 -1
- package/skills/auto/SKILL.md +41 -34
- package/skills/auto/references/human-report-contract.md +136 -54
- package/skills/conventions/references/locks-and-worktrees.md +12 -0
- package/skills/dashboard/SKILL.md +13 -2
- package/skills/dashboard/templates/dashboard-global.md +1 -1
- package/skills/dashboard/templates/dashboard-project.md +2 -2
- package/skills/doctor/SKILL.md +10 -4
- package/skills/intent-continuing/SKILL.md +51 -41
- package/skills/intent-continuing/references/board-fill.md +9 -0
- package/skills/intent-continuing/references/boarding-matrix.md +6 -5
- package/skills/intent-continuing/references/context-management.md +1 -1
- package/skills/intent-ending/SKILL.md +36 -16
- package/skills/intent-executing/SKILL.md +21 -5
- package/skills/intent-executing/implementer-prompt.md +6 -1
- package/skills/intent-speccing/SKILL.md +7 -4
- package/skills/releasing/SKILL.md +39 -0
- package/skills/releasing/references/promotion-and-tagging.md +10 -6
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/roadmap/SKILL.md +26 -0
- package/skills/roadmap/references/file-format.md +10 -0
- package/templates/config.yml +3 -3
- package/templates/dashboard-screen.md +22 -0
- package/templates/display-fixture.md +21 -0
- package/templates/graph.md +16 -0
- package/templates/intent-screen.md +17 -0
- package/templates/node-decision.md +11 -0
- package/templates/node-research.md +11 -0
- package/templates/node-verify.md +13 -0
- package/templates/node-work.md +22 -0
- package/templates/outcome.md +19 -1
- package/templates/report-plan.md +15 -0
- package/templates/report-roadmap-delivered.md +10 -0
- package/templates/report-roadmap-plan.md +9 -0
- package/templates/report-roadmap-state.md +9 -0
- package/templates/report-state.md +11 -0
- package/templates/roadmap.md +13 -0
|
@@ -0,0 +1,1967 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# ReportScreen (intent 317) - the record readers plus the three renderers
|
|
5
|
+
# (state, delivered, delay) behind scripts/report-screen. Every rendered cell
|
|
6
|
+
# traces to a file on disk (D14): a missing source renders the exact string
|
|
7
|
+
# "not recorded", never a guess or a blank. Pure: explicit paths in, a string
|
|
8
|
+
# out. Dependency injection for anything reaching outside the fixture: the
|
|
9
|
+
# clock is passed as `now:`, git tag reading as `tag_reader:`, and the ANSI
|
|
10
|
+
# renderer path as `renderer_path:` (D2).
|
|
11
|
+
require "time"
|
|
12
|
+
require "json"
|
|
13
|
+
require "date"
|
|
14
|
+
require_relative "intent_screen"
|
|
15
|
+
require_relative "lock"
|
|
16
|
+
require_relative "session_ledger"
|
|
17
|
+
require_relative "roadmap_queue"
|
|
18
|
+
require_relative "roadmap_savepoint"
|
|
19
|
+
require_relative "roadmap_graph"
|
|
20
|
+
require_relative "graph_tree"
|
|
21
|
+
require_relative "screen_paint"
|
|
22
|
+
require_relative "outcome_report"
|
|
23
|
+
require_relative "node_file"
|
|
24
|
+
|
|
25
|
+
module ReportScreen
|
|
26
|
+
NOT_RECORDED = "not recorded"
|
|
27
|
+
|
|
28
|
+
# --- shared helpers ----------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def self.intent_basename(intent_dir)
|
|
31
|
+
File.basename(intent_dir)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.intent_id(intent_dir)
|
|
35
|
+
intent_basename(intent_dir).split("--", 2).first
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.intent_file_path(intent_dir)
|
|
39
|
+
File.join(intent_dir, "#{intent_basename(intent_dir)}.md")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.intent_text(intent_dir)
|
|
43
|
+
path = intent_file_path(intent_dir)
|
|
44
|
+
File.exist?(path) ? File.read(path) : nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.spec_text(intent_dir)
|
|
48
|
+
path = File.join(intent_dir, "spec.md")
|
|
49
|
+
File.exist?(path) ? File.read(path) : nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.outcome_text(intent_dir)
|
|
53
|
+
path = File.join(intent_dir, "outcome.md")
|
|
54
|
+
File.exist?(path) ? File.read(path) : nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The body of a top-level "## Heading" section, stopping at the next "## "
|
|
58
|
+
# heading (same idiom as IntentScreen.insight_fields). Returns "" when the
|
|
59
|
+
# heading is absent.
|
|
60
|
+
def self.section_of(text, heading)
|
|
61
|
+
return "" unless text
|
|
62
|
+
text.split(/^#{Regexp.escape(heading)}\s*$/, 2)[1].to_s.split(/^## /, 2)[0].to_s
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.escape(text)
|
|
66
|
+
text.to_s.gsub("|", "\\|")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# --- width bound (D7, intent 331f) --------------------------------------------
|
|
70
|
+
#
|
|
71
|
+
# ReportScreen.fit_screen(text, limit:) is the one shared pass every public render entry
|
|
72
|
+
# point in this file (and dashboard.rb's screen renderer) calls last, so no rendered row
|
|
73
|
+
# ever passes the limit. Input unchanged byte for byte when nothing is over the limit.
|
|
74
|
+
|
|
75
|
+
FIT_SCREEN_DEFAULT_LIMIT = 115
|
|
76
|
+
# The column floor and the progress-bar glyph regex are ScreenPaint's own (intent 331f,
|
|
77
|
+
# finding 1): ScreenPaint.paint_data_table shrinks a painted row's columns through the same
|
|
78
|
+
# rule this file's own fit_table_block uses, so both aliases point at the one definition
|
|
79
|
+
# rather than carrying a second copy that could drift.
|
|
80
|
+
FIT_SCREEN_COLUMN_FLOOR = ScreenPaint::FIT_COLUMN_FLOOR
|
|
81
|
+
PROGRESS_BAR_CHARS_RE = ScreenPaint::PROGRESS_BAR_CHARS_RE
|
|
82
|
+
|
|
83
|
+
# Truncate `text` to at most `max_chars`, cutting at the last whitespace at or before the
|
|
84
|
+
# limit (never mid-word) and appending a single ellipsis when truncation happens. The one
|
|
85
|
+
# shared implementation now lives on ScreenPaint (intent 331f, finding 1); dashboard.rb's own
|
|
86
|
+
# helper of the same name delegates here, and this delegates onward so neither caller's own
|
|
87
|
+
# name has to change.
|
|
88
|
+
def self.truncate_on_word_boundary(text, max_chars)
|
|
89
|
+
ScreenPaint.truncate_on_word_boundary(text, max_chars)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Split on every pipe, escaped or not - the SAME rule ScreenPaint.cells_of uses (R3), so the
|
|
93
|
+
# fitter and the painter can never count a row's columns differently. Raw (unstripped) cells,
|
|
94
|
+
# so callers can still tell a padded column from an unpadded one.
|
|
95
|
+
def self.raw_cells_of(row)
|
|
96
|
+
row.split("|", -1)[1..-2].to_a
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Where a title ends (D8, orchestrator ruling 2026-09-05). A title ends at the first colon
|
|
100
|
+
# FOLLOWED BY A SPACE, which is how a person writes a label before its explanation. Any
|
|
101
|
+
# colon would also cut inside a URL or a clock time and leave a name no reader recognizes:
|
|
102
|
+
# zlatkocodes intent 4 opens "About page redesign and header navigation order. Rebuild
|
|
103
|
+
# https://zlatkocodes.com/about/ ... styling: ..." and used to render as "... Rebuild https".
|
|
104
|
+
# A title can carry both boundaries, and then the earlier one is the name: zlatkocodes 4 also
|
|
105
|
+
# has a real label colon, 130 characters in, long after its opening sentence ends. With
|
|
106
|
+
# neither boundary the title is the whole line.
|
|
107
|
+
# A line that opens with its colon has no label to take, so it falls back the same way. The
|
|
108
|
+
# one implementation: dashboard.rb reads titles through this rather than splitting again.
|
|
109
|
+
TITLE_LABEL_RE = /\A(.*?): /m.freeze
|
|
110
|
+
TITLE_SENTENCE_RE = /\A(.*?[.!?])(?:\s|\z)/m.freeze
|
|
111
|
+
|
|
112
|
+
def self.title_before_colon(text, max: 120)
|
|
113
|
+
line = text.to_s.strip
|
|
114
|
+
candidates = [TITLE_LABEL_RE, TITLE_SENTENCE_RE].filter_map { |re| line[re, 1]&.strip }
|
|
115
|
+
.reject(&:empty?)
|
|
116
|
+
truncate_on_word_boundary(candidates.min_by(&:length) || line, max)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Intent 331f1 (RC1): every bound check below measures in DISPLAY COLUMNS
|
|
120
|
+
# (ScreenPaint.display_columns - ANSI stripped, a character at or above U+1100 counts two),
|
|
121
|
+
# not String#length - a bar row can pass a character-count check while still over the real
|
|
122
|
+
# 115-column bound, which is exactly why the suite stayed green while real screens rendered
|
|
123
|
+
# over it (spec.md's defect 3/4).
|
|
124
|
+
def self.fit_screen(text, limit: FIT_SCREEN_DEFAULT_LIMIT)
|
|
125
|
+
lines = text.to_s.lines
|
|
126
|
+
return text if lines.all? { |l| ScreenPaint.display_columns(l.chomp) <= limit }
|
|
127
|
+
|
|
128
|
+
out = +""
|
|
129
|
+
i = 0
|
|
130
|
+
while i < lines.length
|
|
131
|
+
if lines[i].lstrip.start_with?("|")
|
|
132
|
+
block = []
|
|
133
|
+
while i < lines.length && lines[i].lstrip.start_with?("|")
|
|
134
|
+
block << lines[i]
|
|
135
|
+
i += 1
|
|
136
|
+
end
|
|
137
|
+
out << fit_table_block(block, limit)
|
|
138
|
+
else
|
|
139
|
+
out << fit_plain_line(lines[i], limit)
|
|
140
|
+
i += 1
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
out
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def self.fit_plain_line(line, limit)
|
|
147
|
+
body = line.chomp
|
|
148
|
+
return line if ScreenPaint.display_columns(body) <= limit
|
|
149
|
+
ending = line[body.length..].to_s
|
|
150
|
+
"#{truncate_on_word_boundary(body, limit)}#{ending}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Intent 331f1 (finding A3/A5): field tables and data tables get their own fitters
|
|
154
|
+
# (ScreenPaint.field_table? is the ONE classifier both this and the painter use), and the
|
|
155
|
+
# block-level guard above already lets an already-fitting block - every row already at or
|
|
156
|
+
# under `limit` in display columns, exactly what ReportScreen.fit_row_cell/
|
|
157
|
+
# roadmap_state_entries_table already produce for the roadmap Batches table - through
|
|
158
|
+
# untouched, so a table-wide shrink never re-truncates a row a caller already sized
|
|
159
|
+
# correctly (A5).
|
|
160
|
+
def self.fit_table_block(block, limit)
|
|
161
|
+
return block.join if block.all? { |l| ScreenPaint.display_columns(l.chomp) <= limit }
|
|
162
|
+
|
|
163
|
+
rows = block.map(&:chomp)
|
|
164
|
+
return fit_field_table_block(block, limit) if ScreenPaint.field_table?(rows)
|
|
165
|
+
|
|
166
|
+
is_sep = rows.map { |r| r.match?(ScreenPaint::SEPARATOR_RE) }
|
|
167
|
+
raw_rows = rows.map { |r| raw_cells_of(r) }
|
|
168
|
+
ncols = raw_rows.map(&:length).max.to_i
|
|
169
|
+
return block.join if ncols.zero?
|
|
170
|
+
|
|
171
|
+
stripped_cols = Array.new(ncols) { [] }
|
|
172
|
+
stripped_rows = []
|
|
173
|
+
header_idx = raw_rows.each_index.find { |ri| !is_sep[ri] }
|
|
174
|
+
raw_rows.each_with_index do |cells, ri|
|
|
175
|
+
next if is_sep[ri]
|
|
176
|
+
row = (0...ncols).map { |ci| cells[ci].to_s.strip }
|
|
177
|
+
row.each_with_index { |c, ci| stripped_cols[ci] << c }
|
|
178
|
+
stripped_rows << row
|
|
179
|
+
end
|
|
180
|
+
widths = stripped_cols.map { |col| col.map(&:length).max.to_i }
|
|
181
|
+
|
|
182
|
+
bar_column = Array.new(ncols) { |ci| stripped_cols[ci].any? { |c| c =~ PROGRESS_BAR_CHARS_RE } }
|
|
183
|
+
# Intent 331f1 (post-exec review, P3): the shared row-overage rule (ScreenPaint.
|
|
184
|
+
# row_display_overage), not a per-column bar credit - the same fix as paint_data_table's
|
|
185
|
+
# own P2, so the two renderers cannot drift apart on what "fits" means.
|
|
186
|
+
overage = ScreenPaint.row_display_overage(stripped_rows)
|
|
187
|
+
# Intent 331f1, S3 (brief 4): per-column minimums - never below the header cell, never
|
|
188
|
+
# below a natural width of 10 or less (the id case).
|
|
189
|
+
header_len = Array.new(ncols) { |ci| header_idx ? raw_rows[header_idx][ci].to_s.strip.length : 0 }
|
|
190
|
+
floors = (0...ncols).map { |ci| bar_column[ci] ? widths[ci] : ScreenPaint.column_floor(header_len[ci], widths[ci]) }
|
|
191
|
+
|
|
192
|
+
# A column is "padded" when at least one non-last, non-separator raw cell carries more
|
|
193
|
+
# than the one mandatory space before its closing pipe - the ljust convention several
|
|
194
|
+
# tables in this file already use (state_rows, roster). Only such a column is re-padded
|
|
195
|
+
# after a shrink; an unpadded table stays unpadded.
|
|
196
|
+
padded_column = Array.new(ncols) do |ci|
|
|
197
|
+
next false if ci == ncols - 1
|
|
198
|
+
raw_rows.each_with_index.any? { |cells, ri| !is_sep[ri] && cells[ci].to_s.end_with?(" ") }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
budget = limit - (4 + 3 * (ncols - 1)) - overage
|
|
202
|
+
widths = ScreenPaint.shrink_column_widths(widths, budget, bar_columns: bar_column, floors: floors)
|
|
203
|
+
|
|
204
|
+
# 331f1a (D1/D2, plan-review ruling): a separator row passes through byte-identical
|
|
205
|
+
# whenever its OWN unfitted input already fits the bound - rebuilding it from the
|
|
206
|
+
# shrunk widths (with the `[w, 3].max` floor below) is what made it assemble wider
|
|
207
|
+
# than any data row in the first place, landing it as the only row the backstop ever
|
|
208
|
+
# cut (or, when the rebuilt form happened to still fit, wider than its own "---"
|
|
209
|
+
# input, which D1 forbids just as much). Only when even the unmodified input cannot
|
|
210
|
+
# fit does the old rebuild-and-backstop path apply - the bound wins there, which is
|
|
211
|
+
# exactly what test_fit_screen_backstops_an_unshrinkable_row and
|
|
212
|
+
# test_unshrinkable_data_table_is_still_bounded pin. A blank "| | | |" scaffold
|
|
213
|
+
# reaching this branch is classified as a separator by the same regex, so it gets
|
|
214
|
+
# the identical pass-through rule.
|
|
215
|
+
fitted_rows = raw_rows.each_with_index.map do |cells, ri|
|
|
216
|
+
if is_sep[ri]
|
|
217
|
+
original = rows[ri]
|
|
218
|
+
if ScreenPaint.display_columns(original) <= limit
|
|
219
|
+
original
|
|
220
|
+
else
|
|
221
|
+
"| #{widths.map { |w| "-" * [w, 3].max }.join(" | ")} |"
|
|
222
|
+
end
|
|
223
|
+
else
|
|
224
|
+
rendered = cells.each_with_index.map do |c, ci|
|
|
225
|
+
next c.to_s.strip if ci >= ncols
|
|
226
|
+
value = c.to_s.strip
|
|
227
|
+
value = truncate_on_word_boundary(value, widths[ci]) if value.length > widths[ci]
|
|
228
|
+
padded_column[ci] && ci != ncols - 1 ? value.ljust(widths[ci]) : value
|
|
229
|
+
end
|
|
230
|
+
"| #{rendered.join(' | ')} |"
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# F28: the unconditional backstop. Every shrinkable column may already sit at its floor
|
|
235
|
+
# and the assembled row can still be over the limit; truncate the whole row on a word
|
|
236
|
+
# boundary rather than let it survive past 115 - a data table's separator row included
|
|
237
|
+
# (test_fit_screen_backstops_an_unshrinkable_row), unlike the field-table fitter's own
|
|
238
|
+
# separator, which always passes through untouched (W2). A separator already passed
|
|
239
|
+
# through byte-identical above never trips this (it already fits by construction).
|
|
240
|
+
fitted_rows.map! { |r| ScreenPaint.display_columns(r) > limit ? truncate_on_word_boundary(r, limit) : r }
|
|
241
|
+
|
|
242
|
+
"#{fitted_rows.join("\n")}\n"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Intent 331f1 (S2, design): the field table's own fitter - a "| | | |" scaffold or
|
|
246
|
+
# "| --- | --- | --- |" separator row passes through byte for byte; the label column
|
|
247
|
+
# (first cell) takes its natural width and never shrinks or truncates; the VALUE column
|
|
248
|
+
# shrinks first, down to a floor of max(24, the widest bar cell in that column) so a
|
|
249
|
+
# progress bar is never cut; only then does the NOTE column shrink, and when what is left
|
|
250
|
+
# for it falls under ScreenPaint::FIT_COLUMN_FLOOR (8) columns the note is dropped whole
|
|
251
|
+
# (never squeezed to "in…") and the value reclaims the freed room, back up to its own
|
|
252
|
+
# natural width. A value that still cannot fit ends with an ellipsis; the value floor is
|
|
253
|
+
# never crossed even then, so the row may still exceed `limit` in that extreme case -
|
|
254
|
+
# there is no row-level backstop here (that backstop is the data-table branch's own, and
|
|
255
|
+
# it must never touch a field table's label cell).
|
|
256
|
+
#
|
|
257
|
+
# Intent 331f1 (post-exec review, P1): `label_w`/`value_w`/`note_w` and `budget` are character
|
|
258
|
+
# counts spent against the 115 DISPLAY-column bound - a bar row's glyphs (2 columns each) or
|
|
259
|
+
# an embedded ellipsis cost more display columns than characters, so a row can pass this
|
|
260
|
+
# arithmetic while still landing well over the real bound. `ScreenPaint.row_display_overage`
|
|
261
|
+
# reserves the worst row's own overage up front (P1-P3's shared fix); a fresh ellipsis this
|
|
262
|
+
# function's OWN truncation adds where none existed before can still leave a small residual,
|
|
263
|
+
# which the corrective loop below closes by re-measuring the actual assembled row and shrinking
|
|
264
|
+
# note (then value, never below its floor) by the exact excess.
|
|
265
|
+
#
|
|
266
|
+
# Intent 331f1 (P5): the label (and, when flagged, the value) column is re-padded exactly the
|
|
267
|
+
# way `fit_table_block`'s own `padded_column` rule would - ljust in CHARACTERS, never display
|
|
268
|
+
# columns, so a terminal drawing a bar glyph one column wide stays aligned - restoring the
|
|
269
|
+
# alignment a fitted field table lost.
|
|
270
|
+
def self.fit_field_table_block(block, limit)
|
|
271
|
+
return block.join if block.all? { |l| ScreenPaint.display_columns(l.chomp) <= limit }
|
|
272
|
+
|
|
273
|
+
rows = block.map(&:chomp)
|
|
274
|
+
is_sep = rows.map { |r| r.match?(ScreenPaint::SEPARATOR_RE) }
|
|
275
|
+
content_idx = rows.each_index.reject { |ri| is_sep[ri] }
|
|
276
|
+
return block.join if content_idx.empty?
|
|
277
|
+
|
|
278
|
+
raw_content = content_idx.map { |ri| raw_cells_of(rows[ri]) }
|
|
279
|
+
parsed = content_idx.map { |ri| ScreenPaint.cells_of(rows[ri]) }
|
|
280
|
+
ncols = parsed.map(&:length).max.to_i
|
|
281
|
+
return block.join if ncols.zero?
|
|
282
|
+
|
|
283
|
+
label_w = parsed.map { |c| c[0].to_s.length }.max.to_i
|
|
284
|
+
value_texts = parsed.map { |c| c[1].to_s }
|
|
285
|
+
natural_value_w = value_texts.map(&:length).max.to_i
|
|
286
|
+
bar_value_w = value_texts.select { |v| v =~ PROGRESS_BAR_CHARS_RE }.map(&:length).max.to_i
|
|
287
|
+
value_floor = [24, bar_value_w].max
|
|
288
|
+
value_w = natural_value_w
|
|
289
|
+
|
|
290
|
+
has_note = ncols > 2 && parsed.any? { |c| !c[2].to_s.empty? }
|
|
291
|
+
note_texts = has_note ? parsed.map { |c| c[2].to_s } : []
|
|
292
|
+
note_w = note_texts.map(&:length).max.to_i
|
|
293
|
+
|
|
294
|
+
gaps = ncols - 1
|
|
295
|
+
overage = ScreenPaint.row_display_overage(parsed.map { |c| (0...ncols).map { |ci| c[ci].to_s } })
|
|
296
|
+
budget = limit - (4 + 3 * gaps) - overage
|
|
297
|
+
overflow = (label_w + value_w + note_w) - budget
|
|
298
|
+
|
|
299
|
+
if overflow.positive?
|
|
300
|
+
shrink = [[overflow, value_w - value_floor].min, 0].max
|
|
301
|
+
value_w -= shrink
|
|
302
|
+
overflow -= shrink
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
if overflow.positive? && has_note
|
|
306
|
+
remaining_for_note = note_w - overflow
|
|
307
|
+
if remaining_for_note < FIT_SCREEN_COLUMN_FLOOR
|
|
308
|
+
freed = note_w
|
|
309
|
+
overflow -= freed
|
|
310
|
+
note_w = 0
|
|
311
|
+
has_note = false
|
|
312
|
+
value_w = [value_w - overflow, natural_value_w].min if overflow.negative?
|
|
313
|
+
else
|
|
314
|
+
note_w = remaining_for_note
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# P5: a column (never the last) is "padded" when at least one non-separator RAW cell already
|
|
319
|
+
# ends with two spaces before its closing pipe - the same `padded_column` convention
|
|
320
|
+
# `fit_table_block` uses (state_rows, roster).
|
|
321
|
+
padded_label = raw_content.any? { |cells| cells[0].to_s.end_with?(" ") }
|
|
322
|
+
padded_value = ncols > 2 && raw_content.any? { |cells| cells[1].to_s.end_with?(" ") }
|
|
323
|
+
|
|
324
|
+
render = lambda do
|
|
325
|
+
rows.each_index.map do |ri|
|
|
326
|
+
next rows[ri] if is_sep[ri]
|
|
327
|
+
cells = ScreenPaint.cells_of(rows[ri])
|
|
328
|
+
label = cells[0].to_s
|
|
329
|
+
value = cells[1].to_s
|
|
330
|
+
note = has_note ? cells[2].to_s : ""
|
|
331
|
+
|
|
332
|
+
value = truncate_on_word_boundary(value, value_w) if value.length > value_w && value !~ PROGRESS_BAR_CHARS_RE
|
|
333
|
+
note = truncate_on_word_boundary(note, note_w) if has_note && note.length > note_w
|
|
334
|
+
|
|
335
|
+
label = label.ljust(label_w) if padded_label
|
|
336
|
+
value = value.ljust(value_w) if padded_value
|
|
337
|
+
|
|
338
|
+
if has_note
|
|
339
|
+
"| #{label} | #{value} | #{note} |"
|
|
340
|
+
elsif ncols > 2
|
|
341
|
+
"| #{label} | #{value} | |"
|
|
342
|
+
else
|
|
343
|
+
"| #{label} | #{value} |"
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# The corrective pass (P1): measure what actually got assembled, and if it still runs over
|
|
349
|
+
# `limit`, shrink note (then value, down to its floor) by the exact excess and re-render.
|
|
350
|
+
# Bounded: each pass either shrinks a column or breaks, and there are at most two columns
|
|
351
|
+
# left to shrink once the label is fixed.
|
|
352
|
+
loop do
|
|
353
|
+
candidate = render.call
|
|
354
|
+
max_dw = content_idx.map { |ri| ScreenPaint.display_columns(candidate[ri]) }.max.to_i
|
|
355
|
+
break if max_dw <= limit
|
|
356
|
+
|
|
357
|
+
excess = max_dw - limit
|
|
358
|
+
progressed = false
|
|
359
|
+
if has_note && note_w.positive?
|
|
360
|
+
cut = [excess, note_w].min
|
|
361
|
+
note_w -= cut
|
|
362
|
+
excess -= cut
|
|
363
|
+
progressed = true if cut.positive?
|
|
364
|
+
if note_w < FIT_SCREEN_COLUMN_FLOOR
|
|
365
|
+
has_note = false
|
|
366
|
+
note_w = 0
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
if excess.positive? && value_w > value_floor
|
|
370
|
+
cut = [excess, value_w - value_floor].min
|
|
371
|
+
value_w -= cut
|
|
372
|
+
progressed = true if cut.positive?
|
|
373
|
+
end
|
|
374
|
+
break unless progressed
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
"#{render.call.join("\n")}\n"
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Intent 331f1 (design's final bullet): the shared budget dashboard.rb's screen_fit_intent
|
|
381
|
+
# and roadmap_state_entries_table's Intent cell both spend by - a title cell fitted to
|
|
382
|
+
# whatever the row's OTHER already-rendered cells leave it, measured in display columns
|
|
383
|
+
# (RC1: an `others` cell carrying a progress bar costs two columns per glyph, not one).
|
|
384
|
+
# `others` are the sibling cells as they will actually render; the scaffolding is the
|
|
385
|
+
# leading "| ", a " | " between every pair of cells, and the trailing " |".
|
|
386
|
+
def self.fit_row_cell(title, others, max: FIT_SCREEN_DEFAULT_LIMIT)
|
|
387
|
+
scaffolding = 2 + (3 * others.length) + 2
|
|
388
|
+
budget = max - scaffolding - others.sum { |c| ScreenPaint.display_columns(c.to_s) }
|
|
389
|
+
return "" if budget <= 0
|
|
390
|
+
truncate_on_word_boundary(title, budget)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def self.frontmatter(intent_dir)
|
|
394
|
+
text = intent_text(intent_dir)
|
|
395
|
+
return {} unless text && text.start_with?("---")
|
|
396
|
+
parts = text.split("---", 3)
|
|
397
|
+
return {} if parts.length < 3
|
|
398
|
+
require "yaml"
|
|
399
|
+
require "date"
|
|
400
|
+
YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
|
|
401
|
+
rescue StandardError
|
|
402
|
+
{}
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def self.research_intent?(intent_dir)
|
|
406
|
+
Array(frontmatter(intent_dir)["tags"]).map(&:to_s).include?("research")
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Intent 330 (D12): the shared fence walker feeding split_by_headings AND
|
|
410
|
+
# table_rows. A line matching \A\s{0,3}(```+|~~~+) while closed opens a
|
|
411
|
+
# fence and remembers the marker character and its length; while open, a
|
|
412
|
+
# line whose marker is the SAME character and at least as long, with only
|
|
413
|
+
# whitespace after it, closes the fence. Inside a fence every line is body
|
|
414
|
+
# - a leading "#" or a leading "|" included. A four-space-indented block is
|
|
415
|
+
# deliberately never a fence (the cap is 0-3 leading whitespace chars),
|
|
416
|
+
# which is the CommonMark indented-code case, out of scope on purpose
|
|
417
|
+
# (D12's stated limit). Yields [line, fenced] for every line, in order.
|
|
418
|
+
FENCE_LINE_RE = /\A\s{0,3}(`{3,}|~{3,})/.freeze
|
|
419
|
+
|
|
420
|
+
def self.each_fence_line(text)
|
|
421
|
+
return enum_for(:each_fence_line, text) unless block_given?
|
|
422
|
+
|
|
423
|
+
marker = nil # [character, length] of the currently open fence, or nil
|
|
424
|
+
text.to_s.each_line do |line|
|
|
425
|
+
if marker
|
|
426
|
+
yield line, true
|
|
427
|
+
m = line.match(FENCE_LINE_RE)
|
|
428
|
+
next unless m && m[1][0] == marker[0] && m[1].length >= marker[1]
|
|
429
|
+
next unless line.sub(FENCE_LINE_RE, "").strip.empty?
|
|
430
|
+
|
|
431
|
+
marker = nil
|
|
432
|
+
else
|
|
433
|
+
m = line.match(FENCE_LINE_RE)
|
|
434
|
+
if m
|
|
435
|
+
marker = [m[1][0], m[1].length]
|
|
436
|
+
yield line, true
|
|
437
|
+
else
|
|
438
|
+
yield line, false
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Markdown pipe-table data rows (header + separator skipped), each an array
|
|
445
|
+
# of trimmed cell strings. Tolerates leading prose before the table. Fence-
|
|
446
|
+
# aware (D12/O1.7): a pipe row inside a fenced example is never counted.
|
|
447
|
+
def self.table_rows(text)
|
|
448
|
+
lines = []
|
|
449
|
+
each_fence_line(text) do |line, fenced|
|
|
450
|
+
next if fenced
|
|
451
|
+
|
|
452
|
+
stripped = line.strip
|
|
453
|
+
lines << stripped if stripped.start_with?("|")
|
|
454
|
+
end
|
|
455
|
+
sep_idx = lines.index { |l| l.match?(/\A\|[\s:|-]+\|?\z/) }
|
|
456
|
+
return [] unless sep_idx
|
|
457
|
+
lines[(sep_idx + 1)..].map { |l| l.split("|", -1).map(&:strip)[1..-2].to_a }
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Every [heading_line, body] pair in a Markdown file, split on ANY heading
|
|
461
|
+
# line (any level). Used by proven_by (D19) so a section's own matrix rows
|
|
462
|
+
# are never confused with a sibling section's. Fence-aware (D12): a "#"
|
|
463
|
+
# line inside a fenced example never starts a new section.
|
|
464
|
+
def self.split_by_headings(text)
|
|
465
|
+
sections = []
|
|
466
|
+
heading = nil
|
|
467
|
+
body = +""
|
|
468
|
+
each_fence_line(text) do |line, fenced|
|
|
469
|
+
if !fenced && line.start_with?("#")
|
|
470
|
+
sections << [heading, body] if heading
|
|
471
|
+
heading = line.strip
|
|
472
|
+
body = +""
|
|
473
|
+
else
|
|
474
|
+
body << line
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
sections << [heading, body] if heading
|
|
478
|
+
sections
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def self.savepoint_lines(intent_dir)
|
|
482
|
+
path = File.join(intent_dir, "savepoint.md")
|
|
483
|
+
return [] unless File.exist?(path)
|
|
484
|
+
File.readlines(path).map(&:strip).reject(&:empty?).filter_map do |line|
|
|
485
|
+
m = line.match(IntentScreen::SAVEPOINT_RE)
|
|
486
|
+
m ? [m[1], m[2], m[3]] : nil
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def self.human_time(ts)
|
|
491
|
+
IntentScreen.human_time(ts)
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def self.title_for(intent_dir, store_root)
|
|
495
|
+
text = intent_text(intent_dir)
|
|
496
|
+
return "not recorded" unless text
|
|
497
|
+
if store_root
|
|
498
|
+
_status, title = IntentScreen.index_fields(store_root, intent_id(intent_dir))
|
|
499
|
+
return title if title
|
|
500
|
+
end
|
|
501
|
+
IntentScreen.fallback_name(text)
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def self.default_store_root(intent_dir)
|
|
505
|
+
File.expand_path("../..", intent_dir)
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# --- S3: record readers -------------------------------------------------------
|
|
509
|
+
|
|
510
|
+
# Row 21: the ## Intent section body only, never the frontmatter `intent:` line.
|
|
511
|
+
def self.asked(intent_dir)
|
|
512
|
+
text = intent_text(intent_dir)
|
|
513
|
+
return NOT_RECORDED unless text
|
|
514
|
+
body = section_of(text, "## Intent").strip
|
|
515
|
+
body.empty? ? NOT_RECORDED : body
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
|
|
519
|
+
|
|
520
|
+
# 317a S3 (A6): the note under Asked. Bulleted decisions in a real spec keep
|
|
521
|
+
# the historic "N decisions in spec.md"; a prose ## Decisions falls back to
|
|
522
|
+
# the highest D<n> it names; a placeholder spec falls through to the intent
|
|
523
|
+
# record's "### Decisions" (which section_of's "^## " anchor cannot reach);
|
|
524
|
+
# nothing anywhere says "decisions not recorded" - never a false 0, and the
|
|
525
|
+
# scaffold's "- ..." never counts as 1.
|
|
526
|
+
def self.decision_note(intent_dir)
|
|
527
|
+
spec = spec_text(intent_dir)
|
|
528
|
+
if spec && !spec.lstrip.start_with?(PLACEHOLDER_SENTINEL)
|
|
529
|
+
n = decisions_in(section_of(spec, "## Decisions"))
|
|
530
|
+
return "#{n} decisions in spec.md" if n.positive?
|
|
531
|
+
end
|
|
532
|
+
n = decisions_in(intent_text(intent_dir).to_s.split(/^### Decisions\s*$/, 2)[1].to_s.split(/^#+ /, 2)[0])
|
|
533
|
+
return "#{n} decisions in the intent record" if n.positive?
|
|
534
|
+
"decisions not recorded"
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def self.decisions_in(body)
|
|
538
|
+
bullets = body.to_s.lines.count { |l| s = l.lstrip; s.start_with?("- ") && s.strip != "- ..." }
|
|
539
|
+
return bullets if bullets.positive?
|
|
540
|
+
body.to_s.scan(/\bD(\d{1,3})\b/).flatten.map(&:to_i).max.to_i
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# Row 22: bullets under spec.md's ## Decisions only.
|
|
544
|
+
def self.decision_count(intent_dir)
|
|
545
|
+
text = spec_text(intent_dir)
|
|
546
|
+
return NOT_RECORDED unless text
|
|
547
|
+
section_of(text, "## Decisions").lines.count { |l| l.lstrip.start_with?("- ") }
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
# Rows 23/24: outcome.md's ## Delivered, table or bullet form.
|
|
551
|
+
def self.delivered_rows(intent_dir)
|
|
552
|
+
text = outcome_text(intent_dir)
|
|
553
|
+
return [] unless text
|
|
554
|
+
section = section_of(text, "## Delivered")
|
|
555
|
+
return [] if section.strip.empty?
|
|
556
|
+
|
|
557
|
+
rows = table_rows(section)
|
|
558
|
+
return rows.map { |cells| { label: cells[0].to_s, text: cells[1].to_s } } if rows.any?
|
|
559
|
+
|
|
560
|
+
bullet_rows(section).each_with_index.map do |text, i|
|
|
561
|
+
{ label: (i + 1).to_s, text: text }
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# 317a S1 (matrix S1a/S1b): a bullet row is its "- " line PLUS its wrapped
|
|
566
|
+
# continuation lines - outcome prose is hand-wrapped at ~100 columns, and
|
|
567
|
+
# taking one physical line truncated every real record mid-sentence. A blank
|
|
568
|
+
# line or a heading ends the row; prose after a blank is never swept in.
|
|
569
|
+
def self.bullet_rows(section)
|
|
570
|
+
rows = []
|
|
571
|
+
section.to_s.each_line do |line|
|
|
572
|
+
stripped = line.strip
|
|
573
|
+
if line.lstrip.start_with?("- ")
|
|
574
|
+
rows << line.lstrip.sub(/\A-\s*/, "").strip
|
|
575
|
+
elsif stripped.empty? || line.start_with?("#")
|
|
576
|
+
rows << nil unless rows.empty? || rows.last.nil?
|
|
577
|
+
elsif !rows.empty? && !rows.last.nil?
|
|
578
|
+
rows[rows.length - 1] = "#{rows.last} #{stripped}"
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
rows.compact
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# Intent 331b (plan.md, "The one non-additive edit"): the standalone-token
|
|
585
|
+
# rule, extracted so `action_file_for` (the plan screen's Action column)
|
|
586
|
+
# calls the exact same rule as `matching_action_heading` and the two can
|
|
587
|
+
# never drift on what counts as a match.
|
|
588
|
+
def self.heading_tokens(heading)
|
|
589
|
+
heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/)
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
# Rows 25-27: D19/D1r - the label must appear as a standalone token in an
|
|
593
|
+
# action file heading (any level), AND that heading must own at least one
|
|
594
|
+
# matrix data row - a heading that only names the label, with no table
|
|
595
|
+
# beneath it (or a table with a separator but no data row), is skipped and
|
|
596
|
+
# the walk keeps going. Lexicographic path order (D8), then file order.
|
|
597
|
+
#
|
|
598
|
+
# Merge note (322 into alpha, 2026-09-05): 322's table-owning rule and 331b's
|
|
599
|
+
# extracted `heading_tokens` are both kept. The token split now comes from the
|
|
600
|
+
# shared helper so `action_file_for` cannot drift from this walk, while the
|
|
601
|
+
# `table_rows(body).any?` guard stays the thing that decides the match.
|
|
602
|
+
# Intent 334 (G1, D10r/D15r): the ordered list of files a "how was this
|
|
603
|
+
# proven" reader walks - actions/*.md first (the common path today), then
|
|
604
|
+
# nodes/*.md, lexicographic WITHIN each directory rather than across both, so
|
|
605
|
+
# an intent carrying both (a G9 backfill in progress) resolves the same label
|
|
606
|
+
# to whichever actions/ file already proves it, never to glob order.
|
|
607
|
+
def self.action_and_node_paths(intent_dir)
|
|
608
|
+
Dir.glob(File.join(intent_dir, "actions", "*.md")).sort +
|
|
609
|
+
Dir.glob(File.join(intent_dir, "nodes", "*.md")).sort
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def self.matching_action_heading(intent_dir, label)
|
|
613
|
+
action_and_node_paths(intent_dir).each do |path|
|
|
614
|
+
split_by_headings(File.read(path)).each do |heading, body|
|
|
615
|
+
next unless heading_tokens(heading).include?(label)
|
|
616
|
+
return [heading, body] if table_rows(body).any?
|
|
617
|
+
end
|
|
618
|
+
end
|
|
619
|
+
[nil, nil]
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
# D3r: the row-cell fallback, for the shape where the label never appears in
|
|
623
|
+
# a heading at all, only as the first cell of a matrix data row. Restricted
|
|
624
|
+
# to tables under a heading that names itself a matrix (/matrix/i) - never a
|
|
625
|
+
# step list or any other table - so it cannot answer for a record that has
|
|
626
|
+
# no matrix anywhere (the close-gate defeat the plan review measured).
|
|
627
|
+
# Emphasis (bold/italic/code) is stripped from the compared cell; the count
|
|
628
|
+
# sums matching rows within one directory, then stops at the first
|
|
629
|
+
# directory that yields a non-zero count (post-execution review,
|
|
630
|
+
# non-blocking 6) - actions/ before nodes/, mirroring the heading walk's
|
|
631
|
+
# first-hit rule, so an intent whose nodes/ files restate ACTION_1's own
|
|
632
|
+
# matrix under the same label is never double-counted.
|
|
633
|
+
def self.matching_matrix_rows(intent_dir, label)
|
|
634
|
+
[Dir.glob(File.join(intent_dir, "actions", "*.md")).sort,
|
|
635
|
+
Dir.glob(File.join(intent_dir, "nodes", "*.md")).sort].each do |paths|
|
|
636
|
+
count = 0
|
|
637
|
+
paths.each do |path|
|
|
638
|
+
split_by_headings(File.read(path)).each do |heading, body|
|
|
639
|
+
next unless heading.to_s.match?(/matrix/i)
|
|
640
|
+
table_rows(body).each do |cells|
|
|
641
|
+
cell = cells[0].to_s.gsub(/[*_`]/, "").strip
|
|
642
|
+
count += 1 if cell == label
|
|
643
|
+
end
|
|
644
|
+
end
|
|
645
|
+
end
|
|
646
|
+
return count if count.positive?
|
|
647
|
+
end
|
|
648
|
+
0
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
# 339 S9 (D17): a verify node owns `## Criteria`, never a matrix, so its
|
|
652
|
+
# Proven-by is how many criteria its node file names - not the
|
|
653
|
+
# absent-source phrase `matching_action_heading`/`matching_matrix_rows`
|
|
654
|
+
# falls through to for a label with no matrix anywhere. The kind comes from
|
|
655
|
+
# the node file's own envelope, `NodeFile.parse`, never guessed from the
|
|
656
|
+
# label's prefix and never by sniffing a body for a criteria-shaped list
|
|
657
|
+
# (row 9.4): a work node whose body happens to carry a bulleted "##
|
|
658
|
+
# Criteria" section must never borrow this path. Returns nil (not 0) when
|
|
659
|
+
# `label` is not a verify node at all, so `proven_by` can tell "not a
|
|
660
|
+
# verify node" apart from "a verify node with zero criteria".
|
|
661
|
+
def self.verify_node_criteria_count(intent_dir, label)
|
|
662
|
+
path = Dir.glob(File.join(intent_dir, "nodes", "#{label}.md")).first ||
|
|
663
|
+
Dir.glob(File.join(intent_dir, "nodes", "#{label}--*.md")).sort.first
|
|
664
|
+
return nil unless path
|
|
665
|
+
|
|
666
|
+
parsed = NodeFile.parse(path)
|
|
667
|
+
return nil unless parsed[:ok] && parsed[:kind] == "verify"
|
|
668
|
+
|
|
669
|
+
NodeFile.split_by_headings(parsed[:body].to_s).each do |heading, body|
|
|
670
|
+
next unless heading.to_s.sub(/\A#+\s*/, "").strip == "Criteria"
|
|
671
|
+
|
|
672
|
+
return body.each_line.count { |line| line.strip.start_with?("-") }
|
|
673
|
+
end
|
|
674
|
+
nil
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
# D7: a label with no letter never resolves, on either path - it is a
|
|
678
|
+
# bullet-derived Delivered number (delivered_rows), never a label anyone
|
|
679
|
+
# wrote, and would otherwise fabricate proof from a numbered heading like
|
|
680
|
+
# "## 1. What this intent is" or from a numbered matrix row-cell column.
|
|
681
|
+
def self.proven_by(intent_dir, label)
|
|
682
|
+
return NOT_RECORDED unless label.to_s.match?(/[A-Za-z]/)
|
|
683
|
+
|
|
684
|
+
criteria_count = verify_node_criteria_count(intent_dir, label)
|
|
685
|
+
unless criteria_count.nil?
|
|
686
|
+
return criteria_count.positive? ? "#{criteria_count} criteri#{criteria_count == 1 ? 'on' : 'a'}" : NOT_RECORDED
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
_heading, body = matching_action_heading(intent_dir, label)
|
|
690
|
+
if body
|
|
691
|
+
n = table_rows(body).length
|
|
692
|
+
return n.positive? ? "#{n} test#{n == 1 ? '' : 's'}" : NOT_RECORDED
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
n = matching_matrix_rows(intent_dir, label)
|
|
696
|
+
n.positive? ? "#{n} test#{n == 1 ? '' : 's'}" : NOT_RECORDED
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
# Row 34: outcome.md's ## Needs you, our own N1..NN numbering (never the
|
|
700
|
+
# table's own N column, which could be malformed).
|
|
701
|
+
def self.needs_you_rows(intent_dir)
|
|
702
|
+
text = outcome_text(intent_dir)
|
|
703
|
+
return [] unless text
|
|
704
|
+
return [] unless text.include?("## Needs you")
|
|
705
|
+
section = section_of(text, "## Needs you")
|
|
706
|
+
rows = table_rows(section)
|
|
707
|
+
if rows.any?
|
|
708
|
+
return rows.each_with_index.map do |cells, i|
|
|
709
|
+
{ n: "N#{i + 1}", what: cells[1].to_s, why: cells[2].to_s }
|
|
710
|
+
end
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
# 317a S2 (matrix S2a): prose that exists must never render as None - the
|
|
714
|
+
# 317 record hid three owner picks behind exactly that. One joined row,
|
|
715
|
+
# why "not recorded"; a literal None (or an empty section) stays [].
|
|
716
|
+
content = section.gsub(/<!--.*?-->/m, "").strip
|
|
717
|
+
return [] if content.empty? || content == "None"
|
|
718
|
+
|
|
719
|
+
what = content.lines.map(&:strip).reject(&:empty?)
|
|
720
|
+
.join(" ").sub(/\A-\s*/, "").squeeze(" ")
|
|
721
|
+
[{ n: "N1", what: what, why: NOT_RECORDED }]
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
# Row 35: first-to-last savepoint timestamp, "1 h 51 min" / "n min".
|
|
725
|
+
def self.duration(intent_dir)
|
|
726
|
+
lines = savepoint_lines(intent_dir)
|
|
727
|
+
return NOT_RECORDED if lines.length < 2
|
|
728
|
+
secs = (Time.parse(lines.last[0]) - Time.parse(lines.first[0])).to_i
|
|
729
|
+
format_duration(secs)
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
def self.format_duration(secs)
|
|
733
|
+
mins = [secs, 0].max / 60
|
|
734
|
+
return "#{mins} min" if mins < 60
|
|
735
|
+
"#{mins / 60} h #{mins % 60} min"
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
# Row 36 (D20): mode from the LIVE delivery lock's run_mode; absent -> not recorded.
|
|
739
|
+
def self.mode(intent_dir)
|
|
740
|
+
data = Lock.read(intent_dir)
|
|
741
|
+
value = data && data["run_mode"]
|
|
742
|
+
return value.to_s if value && !value.to_s.empty?
|
|
743
|
+
|
|
744
|
+
# 317a S7 (D5): after the close the lock is gone; end-intent stamps the
|
|
745
|
+
# run_mode into outcome.md frontmatter, so mode stops being unknowable
|
|
746
|
+
# retrospectively. Live lock first - it is the source of truth mid-flight.
|
|
747
|
+
value = outcome_frontmatter(intent_dir)["mode"]
|
|
748
|
+
value && !value.to_s.empty? ? value.to_s : NOT_RECORDED
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
def self.outcome_frontmatter(intent_dir)
|
|
752
|
+
text = outcome_text(intent_dir)
|
|
753
|
+
return {} unless text && text.start_with?("---")
|
|
754
|
+
parts = text.split("---", 3)
|
|
755
|
+
return {} if parts.length < 3
|
|
756
|
+
require "yaml"
|
|
757
|
+
require "date"
|
|
758
|
+
YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
|
|
759
|
+
rescue StandardError
|
|
760
|
+
{}
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
# --- evidence rows (rows 28-33, 37) --------------------------------------------
|
|
764
|
+
|
|
765
|
+
def self.suite_row(section)
|
|
766
|
+
m = section.match(/([\d,]+)\s*runs,\s*([\d,]+)\s*assertions,\s*([\d,]+)\s*failures/)
|
|
767
|
+
return nil unless m
|
|
768
|
+
{ kind: "suite", what: "#{m[1]} runs · #{m[2]} assertions · #{m[3]} failures", source: "outcome.md ## Verification" }
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
def self.red_row(section)
|
|
772
|
+
line = section.lines.find { |l| l =~ /\bred\b/i && l =~ /`([0-9a-f]{7,40})`/ }
|
|
773
|
+
return nil unless line
|
|
774
|
+
sha = line.match(/`([0-9a-f]{7,40})`/)[1]
|
|
775
|
+
{ kind: "red", what: "#{sha} proven test-only and red", source: "outcome.md ## Verification" }
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
# Fix 2026-09-01: the record is the truth of delivery (D14: never a guess).
|
|
779
|
+
# The shipped version comes from outcome.md's own ship line first ("Shipped
|
|
780
|
+
# as `v2.0.0-alpha.10`", "released as **v2.0.0-alpha.5**", "released
|
|
781
|
+
# v2.0.0-alpha.9", "Tagged v1.14.1", "Delivered in", "Release v"); the injected tag reader (git) is the
|
|
782
|
+
# fallback when the record is silent. A bare version with no ship verb
|
|
783
|
+
# ("from 1.14.1") is not a shipped version.
|
|
784
|
+
SHIP_VERSION_RE = /\b(?:shipped|released?|delivered|tagged)\b(?:\s+(?:as|in))?[\s`*]*v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?)/i.freeze
|
|
785
|
+
|
|
786
|
+
def self.shipped_version(intent_dir)
|
|
787
|
+
text = outcome_text(intent_dir)
|
|
788
|
+
return nil unless text
|
|
789
|
+
m = text.match(SHIP_VERSION_RE)
|
|
790
|
+
m && m[1]
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
# The merge commit named on outcome.md's merge line, or nil. The CLI's tag
|
|
794
|
+
# reader asks git which tag contains it; the ship row prints it.
|
|
795
|
+
def self.merge_sha(intent_dir)
|
|
796
|
+
text = outcome_text(intent_dir)
|
|
797
|
+
return nil unless text
|
|
798
|
+
line = text.lines.find { |l| l =~ /\bmerge(d)?\b/i && l =~ /\b[0-9a-f]{7,40}\b/ }
|
|
799
|
+
line && line.match(/\b([0-9a-f]{7,40})\b/)[1]
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
# Intent 330 (D9): reads `flow: base:` from a project's project.yml when
|
|
803
|
+
# `intent_dir` sits in the installed project layout
|
|
804
|
+
# (<home>/projects/<slug>/store/<id--slug>); nil otherwise (a global-store
|
|
805
|
+
# intent, a project with no `flow:` key, or malformed YAML). Pure: no git,
|
|
806
|
+
# no shell-out, just the one file this intent's own layout already reads.
|
|
807
|
+
PROJECT_LAYOUT_RE = %r{\A(.*)/projects/([^/]+)/store/[^/]+\z}.freeze
|
|
808
|
+
|
|
809
|
+
def self.flow_base(intent_dir)
|
|
810
|
+
m = intent_dir.to_s.match(PROJECT_LAYOUT_RE)
|
|
811
|
+
return nil unless m
|
|
812
|
+
|
|
813
|
+
home, slug = m[1], m[2]
|
|
814
|
+
path = File.join(home, "projects", slug, "project.yml")
|
|
815
|
+
return nil unless File.exist?(path)
|
|
816
|
+
|
|
817
|
+
require "yaml"
|
|
818
|
+
data = YAML.safe_load(File.read(path))
|
|
819
|
+
return nil unless data.is_a?(Hash)
|
|
820
|
+
|
|
821
|
+
flow = data["flow"]
|
|
822
|
+
return nil unless flow.is_a?(Hash)
|
|
823
|
+
|
|
824
|
+
base = flow["base"]
|
|
825
|
+
base.is_a?(String) && !base.empty? ? base : nil
|
|
826
|
+
rescue StandardError
|
|
827
|
+
nil
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
# Intent 330 (D9/D10/D23): the ship row's WHAT cell is the merge sha, then
|
|
831
|
+
# " → <branch>" only when `branch_reader` answers one (never the "alpha"
|
|
832
|
+
# literal), then " · v<version>" or the existing not-recorded fallback. The
|
|
833
|
+
# Source cell names WHERE the branch came from (D23): project.yml when
|
|
834
|
+
# flow_base itself supplied that exact branch, else git refs, so the row
|
|
835
|
+
# never keeps the stale "git tags" literal for a branch git never answered.
|
|
836
|
+
def self.ship_row(_text, intent_dir, tag_reader, branch_reader: ->(_dir) { nil })
|
|
837
|
+
sha = merge_sha(intent_dir)
|
|
838
|
+
version = shipped_version(intent_dir) || tag_reader.call(intent_dir)
|
|
839
|
+
return nil if sha.nil? && (version.nil? || version.to_s.empty?)
|
|
840
|
+
branch = branch_reader.call(intent_dir)
|
|
841
|
+
sha_text = sha || NOT_RECORDED
|
|
842
|
+
what = +sha_text
|
|
843
|
+
what << " → #{branch}" if branch && !branch.to_s.empty?
|
|
844
|
+
# D10: the version segment is omitted, not filled with NOT_RECORDED. A
|
|
845
|
+
# repository with no release line has no version, the header already
|
|
846
|
+
# carries the shipped identity, and naming the absence twice on one screen
|
|
847
|
+
# is the defect this intent was opened to remove, not a floor worth keeping.
|
|
848
|
+
what << " · v#{version.to_s.sub(/\Av/, '')}" if version && !version.to_s.empty?
|
|
849
|
+
# D14: the cell names every file the row actually came from. The branch and
|
|
850
|
+
# the version have different origins, so when both contributed, both are
|
|
851
|
+
# named rather than only the branch's.
|
|
852
|
+
sources = ["outcome.md"]
|
|
853
|
+
if branch && !branch.to_s.empty?
|
|
854
|
+
sources << (flow_base(intent_dir) == branch ? "project.yml" : "git refs")
|
|
855
|
+
end
|
|
856
|
+
sources << "git tags" if version && !version.to_s.empty? && shipped_version(intent_dir).nil?
|
|
857
|
+
sources << "git tags" if sources.length == 1
|
|
858
|
+
source = sources.join("; ")
|
|
859
|
+
{ kind: "ship", what: what, source: source }
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
def self.doctor_row(text)
|
|
863
|
+
m = text.to_s.match(/(\d+)\s*pass,?\s*(\d+)\s*warn,?\s*(\d+)\s*fail/i)
|
|
864
|
+
return nil unless m
|
|
865
|
+
{ kind: "doctor", what: "#{m[1]} pass · #{m[2]} warn · #{m[3]} fail", source: "outcome.md" }
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
def self.deviates_row(section)
|
|
869
|
+
line = section.lines.find { |l| l.lstrip.sub(/\A-\s*/, "").start_with?("Deviation:") }
|
|
870
|
+
return nil unless line
|
|
871
|
+
text = line.lstrip.sub(/\A-\s*/, "").strip
|
|
872
|
+
{ kind: "deviates", what: text, source: "outcome.md ## Verification — Deviation:" }
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
def self.deposits_row(text)
|
|
876
|
+
line = text.to_s.lines.find { |l| l =~ %r{`resources/[^`]+`} }
|
|
877
|
+
return nil unless line
|
|
878
|
+
path = line.match(%r{`(resources/[^`]+)`})[1]
|
|
879
|
+
{ kind: "deposits", what: path, source: "outcome.md" }
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
def self.verdict_row(text)
|
|
883
|
+
m = text.to_s.match(/verdict[:\s]+([A-Za-z][A-Za-z ]*)/i)
|
|
884
|
+
return nil unless m
|
|
885
|
+
{ kind: "verdict", what: m[1].strip, source: "outcome.md" }
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
def self.evidence_rows(intent_dir, tag_reader: ->(_dir) { nil }, branch_reader: ->(_dir) { nil })
|
|
889
|
+
text = outcome_text(intent_dir)
|
|
890
|
+
return [] unless text
|
|
891
|
+
verification = section_of(text, "## Verification")
|
|
892
|
+
|
|
893
|
+
rows = []
|
|
894
|
+
rows << suite_row(verification)
|
|
895
|
+
rows << red_row(verification)
|
|
896
|
+
rows << (research_intent?(intent_dir) ? nil : ship_row(text, intent_dir, tag_reader, branch_reader: branch_reader))
|
|
897
|
+
if research_intent?(intent_dir)
|
|
898
|
+
rows << deposits_row(text)
|
|
899
|
+
rows << verdict_row(text)
|
|
900
|
+
end
|
|
901
|
+
rows << doctor_row(text)
|
|
902
|
+
rows << deviates_row(verification)
|
|
903
|
+
rows.compact
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
# --- S4/S5: the state verb and the --all roster --------------------------------
|
|
907
|
+
|
|
908
|
+
CHANGED_NOTE = "the reason this screen printed"
|
|
909
|
+
|
|
910
|
+
def self.state_fields(intent_dir:, store_root:, changed:)
|
|
911
|
+
base = intent_basename(intent_dir)
|
|
912
|
+
id = base.split("--", 2).first
|
|
913
|
+
text = intent_text(intent_dir)
|
|
914
|
+
status, title = IntentScreen.index_fields(store_root, id)
|
|
915
|
+
name = title || IntentScreen.fallback_name(text.to_s)
|
|
916
|
+
|
|
917
|
+
f = {}
|
|
918
|
+
f.merge!(IntentScreen.store_fields(store_root))
|
|
919
|
+
f["status"] = status
|
|
920
|
+
f["status.note"] = status == "unlisted" ? "no INDEX.md line names this id" : "listed under ## #{status} in INDEX.md"
|
|
921
|
+
f.merge!(IntentScreen.savepoint_fields(intent_dir, text.to_s))
|
|
922
|
+
items = IntentScreen.checklist_items(intent_dir)
|
|
923
|
+
f.merge!(IntentScreen.progress_fields(items))
|
|
924
|
+
f.merge!(IntentScreen.next_fields(items, status, checklist_present: IntentScreen.items_present?(intent_dir)))
|
|
925
|
+
f.merge!(IntentScreen.insight_fields(text.to_s))
|
|
926
|
+
|
|
927
|
+
changed_value = changed && !changed.to_s.empty? ? changed.to_s : "on request"
|
|
928
|
+
|
|
929
|
+
rows = [
|
|
930
|
+
["Store", f["store"], f["store.note"]],
|
|
931
|
+
["Status", f["status"], f["status.note"]],
|
|
932
|
+
["Stage", f["stage"], f["stage.note"]],
|
|
933
|
+
["Savepoint", f["savepoint"], f["savepoint.note"]],
|
|
934
|
+
["Progress", "#{f['progress.bar']} #{f['progress.done']} / #{f['progress.total']}", f["progress.note"]],
|
|
935
|
+
["Next", f["next"], f["next.note"]],
|
|
936
|
+
["Insight", f["insight"], f["insight.note"]],
|
|
937
|
+
["Changed", changed_value, CHANGED_NOTE],
|
|
938
|
+
]
|
|
939
|
+
{ id: id, name: name, rows: rows, items: items }
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
# Rows 42-46: pad BOTH columns to the widest NOTED label/value, computed on
|
|
943
|
+
# the raw emitted (already-escaped) cell text; unnoted rows carry no padding.
|
|
944
|
+
def self.state_rows(rows)
|
|
945
|
+
escaped = rows.map { |label, value, note| ["**#{label}**", escape(value), escape(note)] }
|
|
946
|
+
noted = escaped.select { |_, _, note| !note.to_s.empty? }
|
|
947
|
+
label_w = noted.map { |l, _, _| l.length }.max || 0
|
|
948
|
+
value_w = noted.map { |_, v, _| v.length }.max || 0
|
|
949
|
+
escaped.map do |label, value, note|
|
|
950
|
+
if note.to_s.empty?
|
|
951
|
+
"| #{label} | #{value} | |"
|
|
952
|
+
else
|
|
953
|
+
"| #{label.ljust(label_w)} | #{value.ljust(value_w)} | #{note} |"
|
|
954
|
+
end
|
|
955
|
+
end
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
def self.render_state(intent_dir:, store_root:, changed:, template:)
|
|
959
|
+
data = state_fields(intent_dir: intent_dir, store_root: store_root, changed: changed)
|
|
960
|
+
out = template.dup
|
|
961
|
+
out = out.gsub("{{id}}", data[:id])
|
|
962
|
+
out = out.gsub("{{name}}", data[:name])
|
|
963
|
+
out = out.gsub("{{fields.rows}}", state_rows(data[:rows]).join("\n"))
|
|
964
|
+
out = out.gsub("{{steps.rows}}", IntentScreen.steps_rows(data[:items]))
|
|
965
|
+
fit_screen(out.gsub(/\n{3,}/, "\n\n"))
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
# --- roster (D7/D8) -------------------------------------------------------------
|
|
969
|
+
|
|
970
|
+
# The dirnames named under one "## <section_name>" heading of an INDEX.md.
|
|
971
|
+
# active_dirnames used to hardcode "Active"; intent 330's session verb (D22)
|
|
972
|
+
# reuses this to find Completed/Abandoned dirnames for the no-bookend
|
|
973
|
+
# footer, so the section is now a parameter.
|
|
974
|
+
def self.dirnames_in_section(index_path, section_name)
|
|
975
|
+
return [] unless File.exist?(index_path)
|
|
976
|
+
dirnames = []
|
|
977
|
+
section = nil
|
|
978
|
+
File.foreach(index_path) do |line|
|
|
979
|
+
if line.start_with?("## ")
|
|
980
|
+
section = line[3..].strip
|
|
981
|
+
next
|
|
982
|
+
end
|
|
983
|
+
next unless section == section_name
|
|
984
|
+
m = line.match(%r{\(store/([^/]+)/})
|
|
985
|
+
dirnames << m[1] if m
|
|
986
|
+
end
|
|
987
|
+
dirnames
|
|
988
|
+
end
|
|
989
|
+
|
|
990
|
+
def self.active_dirnames(index_path)
|
|
991
|
+
dirnames_in_section(index_path, "Active")
|
|
992
|
+
end
|
|
993
|
+
|
|
994
|
+
# Intent 330 (D22): both terminal sections count as "completed" for the
|
|
995
|
+
# no-bookend footer - a closed intent the reader cannot expect a Done
|
|
996
|
+
# savepoint line from, since the convention predates end-intent writing it.
|
|
997
|
+
def self.completed_dirnames(index_path)
|
|
998
|
+
dirnames_in_section(index_path, "Completed") + dirnames_in_section(index_path, "Abandoned")
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
def self.newest_savepoint_ts(intent_dir)
|
|
1002
|
+
lines = savepoint_lines(intent_dir)
|
|
1003
|
+
lines.last&.first
|
|
1004
|
+
end
|
|
1005
|
+
|
|
1006
|
+
# report-screen archive <store_root> (intent 339, G6, n6, spec D9): a
|
|
1007
|
+
# read-only VIEW of a store's terminal intents - the reading half of intent
|
|
1008
|
+
# 132, declining the other three halves (moving directories, path
|
|
1009
|
+
# resolution, doctor checks). Lists `## Completed` and `## Abandoned` only
|
|
1010
|
+
# (row 6.1, the same set `completed_dirnames` above already gathers - row
|
|
1011
|
+
# v1f.10 deleted the byte-for-byte duplicate that used to live here); each
|
|
1012
|
+
# row's disposition comes from outcome.md's own frontmatter (row 6.2),
|
|
1013
|
+
# never guessed from which INDEX section the dirname was found in, so a
|
|
1014
|
+
# terminal intent with no outcome.md renders the absent-source phrase
|
|
1015
|
+
# instead of a fabricated disposition (row 6.3). Reads only - moves
|
|
1016
|
+
# nothing (row 6.4).
|
|
1017
|
+
def self.render_archive(store_root)
|
|
1018
|
+
index_path = File.join(store_root, "INDEX.md")
|
|
1019
|
+
lines = ["# Archive: #{File.basename(store_root)}", "", "| Intent | Disposition |", "| --- | --- |"]
|
|
1020
|
+
completed_dirnames(index_path).each do |dirname|
|
|
1021
|
+
dir = File.join(store_root, "store", dirname)
|
|
1022
|
+
disposition = outcome_frontmatter(dir)["disposition"]
|
|
1023
|
+
disposition = NOT_RECORDED if disposition.nil? || disposition.to_s.empty?
|
|
1024
|
+
lines << "| #{escape(dirname)} | #{escape(disposition.to_s)} |"
|
|
1025
|
+
end
|
|
1026
|
+
fit_screen("#{lines.join("\n")}\n")
|
|
1027
|
+
end
|
|
1028
|
+
|
|
1029
|
+
def self.roster(store_root)
|
|
1030
|
+
index_path = File.join(store_root, "INDEX.md")
|
|
1031
|
+
entries = active_dirnames(index_path).filter_map do |dirname|
|
|
1032
|
+
dir = File.join(store_root, "store", dirname)
|
|
1033
|
+
next unless File.directory?(dir)
|
|
1034
|
+
text = intent_text(dir).to_s
|
|
1035
|
+
fields = IntentScreen.savepoint_fields(dir, text)
|
|
1036
|
+
next if fields["stage"] == "Done"
|
|
1037
|
+
{ dir: dir, id: dirname.split("--", 2).first, ts: newest_savepoint_ts(dir) }
|
|
1038
|
+
end
|
|
1039
|
+
entries.sort_by { |e| [-(e[:ts] ? Time.parse(e[:ts]).to_i : 0), e[:id]] }
|
|
1040
|
+
end
|
|
1041
|
+
|
|
1042
|
+
# D6/R5, intent 331f: one freshness rule for every Lead cell, on the SAME primitive
|
|
1043
|
+
# (Lock.who) every call site now shares - a fresh lock prints "agent · key" (this file's
|
|
1044
|
+
# own long-standing format), an older lock prints "stale · N min", never idle; no lock, or
|
|
1045
|
+
# one that will not read, prints "idle". Lock.who is called ONCE: it already returns the
|
|
1046
|
+
# heartbeat timestamp alongside the state, so nothing stats the lock file a second time.
|
|
1047
|
+
def self.lead_cell(intent_dir, now: Time.now)
|
|
1048
|
+
data = Lock.who(intent_dir, now: now)
|
|
1049
|
+
case data["state"]
|
|
1050
|
+
when "fresh"
|
|
1051
|
+
owner = data["owner"] || {}
|
|
1052
|
+
agent = owner["agent"].to_s
|
|
1053
|
+
agent = "unknown" if agent.empty? || agent == "unknown"
|
|
1054
|
+
session = data["owner_session"].to_s
|
|
1055
|
+
"#{agent} · #{session[0, 8]}"
|
|
1056
|
+
when "stale"
|
|
1057
|
+
mins = [((now - Time.parse(data["heartbeat_at"])) / 60).to_i, 0].max
|
|
1058
|
+
"stale · #{mins} min"
|
|
1059
|
+
else
|
|
1060
|
+
"idle"
|
|
1061
|
+
end
|
|
1062
|
+
rescue StandardError
|
|
1063
|
+
"idle"
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
# The roster's own call site (unchanged name/signature at the call sites below); `now:`
|
|
1067
|
+
# defaults so a caller that never passed a clock keeps working exactly as before.
|
|
1068
|
+
def self.lead(intent_dir, now: Time.now)
|
|
1069
|
+
lead_cell(intent_dir, now: now)
|
|
1070
|
+
end
|
|
1071
|
+
|
|
1072
|
+
def self.collapsed_open_steps_note(count)
|
|
1073
|
+
count <= 3 ? "#{count} open" : "#{count} open · showing the first three"
|
|
1074
|
+
end
|
|
1075
|
+
|
|
1076
|
+
def self.render_collapsed_block(intent_dir, store_root, changed:)
|
|
1077
|
+
data = state_fields(intent_dir: intent_dir, store_root: store_root, changed: changed)
|
|
1078
|
+
stage = data[:rows].find { |l, _, _| l == "Stage" }[1]
|
|
1079
|
+
nxt = data[:rows].find { |l, _, _| l == "Next" }[1]
|
|
1080
|
+
ch = data[:rows].find { |l, _, _| l == "Changed" }[1]
|
|
1081
|
+
|
|
1082
|
+
open_items = data[:items].each_with_index.reject { |item, _| item[:done] }
|
|
1083
|
+
lines = []
|
|
1084
|
+
lines << "▶ #{data[:id]} · #{data[:name]}"
|
|
1085
|
+
lines << "Stage #{stage}"
|
|
1086
|
+
lines << "Next #{nxt}"
|
|
1087
|
+
lines << "Changed #{ch}"
|
|
1088
|
+
lines << collapsed_open_steps_note(open_items.length)
|
|
1089
|
+
open_items.first(3).each { |item, i| lines << "S#{i + 1} [ open ] #{escape(item[:text])}" }
|
|
1090
|
+
lines.join("\n")
|
|
1091
|
+
end
|
|
1092
|
+
|
|
1093
|
+
def self.render_roster(store_root, changed: nil, now: Time.now)
|
|
1094
|
+
entries = roster(store_root)
|
|
1095
|
+
return "No intents in delivery.\n" if entries.empty?
|
|
1096
|
+
|
|
1097
|
+
header = "▶ In delivery · #{entries.length} #{entries.length == 1 ? 'intent' : 'intents'} · " \
|
|
1098
|
+
"#{now.utc.strftime('%Y-%m-%d %H:%M UTC')}"
|
|
1099
|
+
table = ["| Graph ID | Stage | Progress | Changed | Lead |", "| --- | --- | --- | --- | --- |"]
|
|
1100
|
+
entries.each do |e|
|
|
1101
|
+
text = intent_text(e[:dir]).to_s
|
|
1102
|
+
savepoint = IntentScreen.savepoint_fields(e[:dir], text)
|
|
1103
|
+
items = IntentScreen.checklist_items(e[:dir])
|
|
1104
|
+
progress = IntentScreen.progress_fields(items)
|
|
1105
|
+
ch = state_fields(intent_dir: e[:dir], store_root: store_root, changed: changed)[:rows].find { |l, _, _| l == "Changed" }[1]
|
|
1106
|
+
table << "| #{e[:id]} | #{savepoint['stage']} | #{progress['progress.bar']} #{progress['progress.done']} / #{progress['progress.total']} | #{escape(ch)} | #{lead(e[:dir], now: now)} |"
|
|
1107
|
+
end
|
|
1108
|
+
blocks = entries.map { |e| render_collapsed_block(e[:dir], store_root, changed: changed) }
|
|
1109
|
+
head_and_table = ([header, ""] + table).join("\n")
|
|
1110
|
+
# Each collapsed block already has its own internal "\n"; a blank line
|
|
1111
|
+
# separates block from block (design--delivery-reports.html:137-152),
|
|
1112
|
+
# so they read as distinct entries instead of running together.
|
|
1113
|
+
fit_screen("#{head_and_table}\n\n#{blocks.join("\n\n")}\n")
|
|
1114
|
+
end
|
|
1115
|
+
|
|
1116
|
+
# --- S6: the delivered verb ------------------------------------------------------
|
|
1117
|
+
|
|
1118
|
+
def self.delivered_timestamp(intent_dir)
|
|
1119
|
+
lines = savepoint_lines(intent_dir)
|
|
1120
|
+
done = lines.reverse.find { |_ts, kind, _text| kind == "Done" }
|
|
1121
|
+
done ? human_time(done[0]) : NOT_RECORDED
|
|
1122
|
+
end
|
|
1123
|
+
|
|
1124
|
+
# Intent 330 (D11): the header's last segment is the shipped identity, and
|
|
1125
|
+
# says which kind it is - v<version> when a version is known, else
|
|
1126
|
+
# "merge <sha>" (never a bare, ambiguous hash), else the exact NOT_RECORDED
|
|
1127
|
+
# string when neither exists.
|
|
1128
|
+
def self.header_ship_segment(intent_dir, tag_reader)
|
|
1129
|
+
version = shipped_version(intent_dir) || tag_reader.call(intent_dir)
|
|
1130
|
+
return "v#{version.to_s.sub(/\Av/, '')}" if version && !version.to_s.empty?
|
|
1131
|
+
|
|
1132
|
+
sha = merge_sha(intent_dir)
|
|
1133
|
+
return "merge #{sha}" if sha && !sha.to_s.empty?
|
|
1134
|
+
|
|
1135
|
+
NOT_RECORDED
|
|
1136
|
+
end
|
|
1137
|
+
|
|
1138
|
+
def self.render_delivered(intent_dir:, tag_reader: ->(_dir) { nil }, branch_reader: ->(_dir) { nil })
|
|
1139
|
+
id = intent_id(intent_dir)
|
|
1140
|
+
name = title_for(intent_dir, default_store_root(intent_dir))
|
|
1141
|
+
ts = delivered_timestamp(intent_dir)
|
|
1142
|
+
m = mode(intent_dir)
|
|
1143
|
+
dur = duration(intent_dir)
|
|
1144
|
+
ship_segment = header_ship_segment(intent_dir, tag_reader)
|
|
1145
|
+
|
|
1146
|
+
lines = []
|
|
1147
|
+
lines << "## ✔ #{id} · #{name} · delivered"
|
|
1148
|
+
lines << "#{ts} · #{m} · #{dur} · #{ship_segment}"
|
|
1149
|
+
lines << ""
|
|
1150
|
+
lines << "**Asked**"
|
|
1151
|
+
lines << " #{asked(intent_dir)}"
|
|
1152
|
+
lines << " #{decision_note(intent_dir)}"
|
|
1153
|
+
lines << ""
|
|
1154
|
+
lines << "**Delivered**"
|
|
1155
|
+
lines << "| Row | Detail | Proven by |"
|
|
1156
|
+
lines << "| --- | --- | --- |"
|
|
1157
|
+
delivered_rows(intent_dir).each do |r|
|
|
1158
|
+
lines << "| #{r[:label]} | #{escape(r[:text])} | #{escape(proven_by(intent_dir, r[:label]))} |"
|
|
1159
|
+
end
|
|
1160
|
+
lines << ""
|
|
1161
|
+
lines << "**Evidence**"
|
|
1162
|
+
ev = evidence_rows(intent_dir, tag_reader: tag_reader, branch_reader: branch_reader)
|
|
1163
|
+
if ev.empty?
|
|
1164
|
+
# 317a S4 (matrix S4a): a header-only table (319's live rendering) says
|
|
1165
|
+
# nothing; the honest floor is the same phrase every other absent source
|
|
1166
|
+
# prints.
|
|
1167
|
+
lines << NOT_RECORDED
|
|
1168
|
+
else
|
|
1169
|
+
lines << "| Kind | Detail | Source |"
|
|
1170
|
+
lines << "| --- | --- | --- |"
|
|
1171
|
+
ev.each do |r|
|
|
1172
|
+
lines << "| #{r[:kind]} | #{escape(r[:what])} | #{escape(r[:source])} |"
|
|
1173
|
+
end
|
|
1174
|
+
end
|
|
1175
|
+
lines << ""
|
|
1176
|
+
needsyou = needs_you_rows(intent_dir)
|
|
1177
|
+
lines << "**Needs you**"
|
|
1178
|
+
if needsyou.empty?
|
|
1179
|
+
lines << "None"
|
|
1180
|
+
else
|
|
1181
|
+
lines << "| N | Need | Reason |"
|
|
1182
|
+
lines << "| --- | --- | --- |"
|
|
1183
|
+
needsyou.each { |r| lines << "| #{r[:n]} | #{escape(r[:what])} | #{escape(r[:why])} |" }
|
|
1184
|
+
end
|
|
1185
|
+
out = fit_screen("#{lines.join("\n")}\n")
|
|
1186
|
+
|
|
1187
|
+
# Intent 339 (G6, n5, spec D8): additive, and only for an intent that has
|
|
1188
|
+
# a graph.md - an intent with none renders exactly the bytes it renders
|
|
1189
|
+
# today (row 5.2's frozen golden). Node state and titles come from the
|
|
1190
|
+
# LEDGER via OutcomeReport.model, never from outcome.md (row 5.6): a
|
|
1191
|
+
# stale hand-edited outcome must never be read as truth here.
|
|
1192
|
+
return out unless File.exist?(File.join(intent_dir, "graph.md"))
|
|
1193
|
+
|
|
1194
|
+
out + fit_screen(render_nodes_block(intent_dir))
|
|
1195
|
+
end
|
|
1196
|
+
|
|
1197
|
+
# Row 5.1/5.4/5.5: a "### Nodes" table (id, kind, ledger state, "(stale)"
|
|
1198
|
+
# when C13 applies) plus a "### Findings" bullet block when the record
|
|
1199
|
+
# carries any (D7). Neither heading is the `**Bold**` shape the delivered
|
|
1200
|
+
# screen's own block scan (`/^\*\*(.+?)\*\*/`) reads, so this block can
|
|
1201
|
+
# never widen that pinned header list (row 5.3).
|
|
1202
|
+
def self.render_nodes_block(intent_dir)
|
|
1203
|
+
model = OutcomeReport.model(intent_dir)
|
|
1204
|
+
stale = OutcomeReport.stale_nodes(entries: model[:entries] || [], edges: model[:edges] || {})
|
|
1205
|
+
|
|
1206
|
+
lines = ["", "### Nodes", "| Node | Kind | State |", "| --- | --- | --- |"]
|
|
1207
|
+
OutcomeReport.sort_ids(model[:nodes].keys).each do |id|
|
|
1208
|
+
n = model[:nodes][id]
|
|
1209
|
+
state = n[:state].to_s
|
|
1210
|
+
state = "#{state} (stale)" if stale.include?(id)
|
|
1211
|
+
lines << "| #{id} | #{escape(n[:kind].to_s)} | #{escape(state)} |"
|
|
1212
|
+
end
|
|
1213
|
+
|
|
1214
|
+
findings = OutcomeReport.findings(intent_dir)
|
|
1215
|
+
unless findings.empty?
|
|
1216
|
+
lines << ""
|
|
1217
|
+
lines << "### Findings"
|
|
1218
|
+
findings.each { |f| lines << "- #{escape(f)}" }
|
|
1219
|
+
end
|
|
1220
|
+
|
|
1221
|
+
"#{lines.join("\n")}\n"
|
|
1222
|
+
end
|
|
1223
|
+
|
|
1224
|
+
# --- S7: the delay verb -----------------------------------------------------------
|
|
1225
|
+
|
|
1226
|
+
def self.delay_timeline(intent_dir)
|
|
1227
|
+
savepoint_lines(intent_dir).map { |ts, kind, text| { ts: ts, kind: kind, text: text } }
|
|
1228
|
+
end
|
|
1229
|
+
|
|
1230
|
+
def self.longest_gap(timeline)
|
|
1231
|
+
return nil if timeline.length < 2
|
|
1232
|
+
best = nil
|
|
1233
|
+
timeline.each_cons(2) do |a, b|
|
|
1234
|
+
secs = (Time.parse(b[:ts]) - Time.parse(a[:ts])).to_i
|
|
1235
|
+
best = { secs: secs, a: a[:kind], b: b[:kind] } if best.nil? || secs > best[:secs]
|
|
1236
|
+
end
|
|
1237
|
+
"longest gap #{best[:secs] / 60} min, #{best[:a]} to #{best[:b]}"
|
|
1238
|
+
end
|
|
1239
|
+
|
|
1240
|
+
def self.where_time_went(timeline)
|
|
1241
|
+
gap = longest_gap(timeline)
|
|
1242
|
+
|
|
1243
|
+
unless timeline.any? { |r| %w[Review Commit].include?(r[:kind]) }
|
|
1244
|
+
parts = ["the review and commit ledger was not kept for this intent"]
|
|
1245
|
+
parts << gap if gap
|
|
1246
|
+
return parts.join(" · ")
|
|
1247
|
+
end
|
|
1248
|
+
|
|
1249
|
+
rounds = timeline.count { |r| r[:kind] == "Review" }
|
|
1250
|
+
commits = timeline.count { |r| r[:kind] == "Commit" }
|
|
1251
|
+
parts = []
|
|
1252
|
+
parts << "reviews #{rounds} round#{rounds == 1 ? '' : 's'}" if rounds.positive?
|
|
1253
|
+
parts << "#{commits} commit#{commits == 1 ? '' : 's'}" if commits.positive?
|
|
1254
|
+
parts << gap if gap
|
|
1255
|
+
parts.join(" · ")
|
|
1256
|
+
end
|
|
1257
|
+
|
|
1258
|
+
def self.hhmm(ts)
|
|
1259
|
+
m = ts.match(/T(\d\d:\d\d)/)
|
|
1260
|
+
m ? m[1] : ts
|
|
1261
|
+
end
|
|
1262
|
+
|
|
1263
|
+
def self.delay_outcome_line(intent_dir)
|
|
1264
|
+
text = outcome_text(intent_dir)
|
|
1265
|
+
return NOT_RECORDED unless text
|
|
1266
|
+
section = section_of(text, "## Summary")
|
|
1267
|
+
# The first PARAGRAPH, not just its first physical line - outcome.md's
|
|
1268
|
+
# prose is hand-wrapped at ~100 columns, so a single logical sentence
|
|
1269
|
+
# spans several source lines.
|
|
1270
|
+
paragraph = section.lstrip.split(/\n\s*\n/, 2).first.to_s.lines.map(&:strip).join(" ").strip
|
|
1271
|
+
return NOT_RECORDED if paragraph.empty?
|
|
1272
|
+
doc = doctor_row(text)
|
|
1273
|
+
doc ? "#{paragraph} · #{doc[:what]}" : paragraph
|
|
1274
|
+
end
|
|
1275
|
+
|
|
1276
|
+
def self.render_delay(intent_dir:)
|
|
1277
|
+
id = intent_id(intent_dir)
|
|
1278
|
+
name = title_for(intent_dir, default_store_root(intent_dir))
|
|
1279
|
+
dur = duration(intent_dir)
|
|
1280
|
+
timeline = delay_timeline(intent_dir)
|
|
1281
|
+
|
|
1282
|
+
lines = []
|
|
1283
|
+
lines << "✔ #{id} · #{name} · delivered in #{dur}"
|
|
1284
|
+
lines << ""
|
|
1285
|
+
timeline.each { |r| lines << "#{hhmm(r[:ts])} #{r[:kind]} #{escape(r[:text])}" }
|
|
1286
|
+
lines << ""
|
|
1287
|
+
lines << "**Where the time went** #{where_time_went(timeline)}"
|
|
1288
|
+
lines << ""
|
|
1289
|
+
lines << "**Outcome** #{delay_outcome_line(intent_dir)}"
|
|
1290
|
+
fit_screen("#{lines.join("\n")}\n")
|
|
1291
|
+
end
|
|
1292
|
+
|
|
1293
|
+
# --- the plan verb (intent 331b): the PRE-delivery report -----------------------
|
|
1294
|
+
#
|
|
1295
|
+
# `report-screen plan <intent_dir>` prints the plan the record already
|
|
1296
|
+
# carries, before Exec starts: Asked, the decisions count, the planned
|
|
1297
|
+
# steps with their action file, and risks. Every cell traces to a file
|
|
1298
|
+
# (D3/D14); a missing source prints "not recorded", the same floor every
|
|
1299
|
+
# other screen in the family uses, except Mode (D2): a missing lock prints
|
|
1300
|
+
# "not armed", never "not recorded" - there is nothing to fall back to
|
|
1301
|
+
# before Exec starts.
|
|
1302
|
+
|
|
1303
|
+
VERDICT_TOKENS = %w[PROCEED APPROVE PASS REVISE REWORK FAIL BLOCK].freeze
|
|
1304
|
+
|
|
1305
|
+
# spec.md F4: a checklist line's OWN declared label ("S6 Docs and...")
|
|
1306
|
+
# survives here; STEP_PREFIX_RE (IntentScreen's own stripping regex) is
|
|
1307
|
+
# reused for the strip, so the label this recognizes is exactly the prefix
|
|
1308
|
+
# IntentScreen.checklist_items strips - the two readers can never disagree
|
|
1309
|
+
# on where a label ends and the step text begins.
|
|
1310
|
+
# The separator class mirrors STEP_PREFIX_RE's own (hyphen, colon, middle
|
|
1311
|
+
# dot, em dash, en dash); the latter two are written as \u escapes rather
|
|
1312
|
+
# than the literal glyph so this line never trips the project's added-line
|
|
1313
|
+
# dash guard, which scans literal characters only - the compiled regex
|
|
1314
|
+
# matches identically either way.
|
|
1315
|
+
STEP_LABEL_RE = /\A(?:Step\s*|S)\s*(\d+)\s*(?:[-:·\u2014\u2013]\s*|\s+)(?=\S)/i.freeze
|
|
1316
|
+
|
|
1317
|
+
def self.asked_first_sentence(intent_dir)
|
|
1318
|
+
body = asked(intent_dir)
|
|
1319
|
+
return NOT_RECORDED if body == NOT_RECORDED
|
|
1320
|
+
collapsed = body.gsub(/\s+/, " ").strip
|
|
1321
|
+
head, rest = IntentScreen.clause_and_rest(collapsed)
|
|
1322
|
+
rest ? "#{head}…" : head
|
|
1323
|
+
end
|
|
1324
|
+
|
|
1325
|
+
# D5, intent 331f: the plan screen's own Asked row - the intent title before its first
|
|
1326
|
+
# colon (F21), never the whole `## Intent` body asked_first_sentence above reads. Most real
|
|
1327
|
+
# intent lines read "Short title: the elaborated ask...", so this is the short title; a body
|
|
1328
|
+
# with no colon at all (a short intent with no title/elaboration split) renders unchanged,
|
|
1329
|
+
# word-boundary truncated the same way every other title cell in the family is.
|
|
1330
|
+
def self.plan_asked_title(intent_dir)
|
|
1331
|
+
body = asked(intent_dir)
|
|
1332
|
+
return NOT_RECORDED if body == NOT_RECORDED
|
|
1333
|
+
title_before_colon(body.gsub(/\s+/, " ").strip)
|
|
1334
|
+
end
|
|
1335
|
+
|
|
1336
|
+
# spec.md F4: keeps checklist.md's own file order and each line's DECLARED
|
|
1337
|
+
# label, falling back to the positional S<n> only when a line declares
|
|
1338
|
+
# none - IntentScreen.checklist_items strips the label and renumbers
|
|
1339
|
+
# positionally, which is right for the state screen and wrong for the
|
|
1340
|
+
# Action lookup below.
|
|
1341
|
+
def self.plan_steps(intent_dir)
|
|
1342
|
+
return [] unless IntentScreen.items_present?(intent_dir)
|
|
1343
|
+
|
|
1344
|
+
raw = File.readlines(File.join(intent_dir, "checklist.md")).filter_map do |line|
|
|
1345
|
+
m = line.match(IntentScreen::ITEM_RE)
|
|
1346
|
+
next unless m
|
|
1347
|
+
text = m[2].strip
|
|
1348
|
+
next if text == "..."
|
|
1349
|
+
text
|
|
1350
|
+
end
|
|
1351
|
+
|
|
1352
|
+
raw.each_with_index.map do |text, i|
|
|
1353
|
+
m = text.match(STEP_LABEL_RE)
|
|
1354
|
+
label = m ? "S#{m[1]}" : "S#{i + 1}"
|
|
1355
|
+
{ label: label, text: text.sub(IntentScreen::STEP_PREFIX_RE, "") }
|
|
1356
|
+
end
|
|
1357
|
+
end
|
|
1358
|
+
|
|
1359
|
+
# spec.md F3/F6a: the Action column names the file whose heading carries
|
|
1360
|
+
# the step's label AND whose section has a matrix table of its own - a
|
|
1361
|
+
# heading that resolves but proves nothing is the same hollow-close defect
|
|
1362
|
+
# `proven_by` already guards against, so it renders "not recorded" too.
|
|
1363
|
+
def self.action_file_for(intent_dir, label)
|
|
1364
|
+
action_and_node_paths(intent_dir).each do |path|
|
|
1365
|
+
split_by_headings(File.read(path)).each do |heading, body|
|
|
1366
|
+
next unless heading_tokens(heading).include?(label)
|
|
1367
|
+
return File.basename(path, ".md") if table_rows(body).any?
|
|
1368
|
+
end
|
|
1369
|
+
end
|
|
1370
|
+
NOT_RECORDED
|
|
1371
|
+
end
|
|
1372
|
+
|
|
1373
|
+
# D2: mode from the LIVE delivery lock only - unlike `mode` (row 36), a
|
|
1374
|
+
# missing lock never falls back to outcome.md's frontmatter (there is
|
|
1375
|
+
# nothing to fall back to before Exec starts) and never says the
|
|
1376
|
+
# delivered screen's "not recorded"; it says "not armed".
|
|
1377
|
+
def self.plan_mode(intent_dir)
|
|
1378
|
+
data = Lock.read(intent_dir)
|
|
1379
|
+
value = data && data["run_mode"]
|
|
1380
|
+
value && !value.to_s.empty? ? value.to_s : "not armed"
|
|
1381
|
+
end
|
|
1382
|
+
|
|
1383
|
+
# The last `Review` savepoint line whose text names a PLAN review - a
|
|
1384
|
+
# post-execution review line never matches, since its text never contains
|
|
1385
|
+
# "plan review".
|
|
1386
|
+
def self.plan_review_line(intent_dir)
|
|
1387
|
+
savepoint_lines(intent_dir).reverse.find { |_ts, kind, text| kind == "Review" && text =~ /plan review/i }
|
|
1388
|
+
end
|
|
1389
|
+
|
|
1390
|
+
def self.plan_reviewer(intent_dir)
|
|
1391
|
+
line = plan_review_line(intent_dir)
|
|
1392
|
+
return "not reviewed" unless line
|
|
1393
|
+
_ts, _kind, text = line
|
|
1394
|
+
VERDICT_TOKENS.find { |t| text =~ /\b#{t}\b/ } || NOT_RECORDED
|
|
1395
|
+
end
|
|
1396
|
+
|
|
1397
|
+
def self.plan_reviewer_note(intent_dir)
|
|
1398
|
+
line = plan_review_line(intent_dir)
|
|
1399
|
+
return "-" unless line
|
|
1400
|
+
ts, _kind, text = line
|
|
1401
|
+
"#{human_time(ts)} · #{text}"
|
|
1402
|
+
end
|
|
1403
|
+
|
|
1404
|
+
def self.plan_fields(intent_dir)
|
|
1405
|
+
[
|
|
1406
|
+
["Asked", plan_asked_title(intent_dir), "## Intent"],
|
|
1407
|
+
["Decisions", decision_note(intent_dir), "-"],
|
|
1408
|
+
["Steps", "#{plan_steps(intent_dir).length} planned", "checklist.md"],
|
|
1409
|
+
["Mode", plan_mode(intent_dir), "the delivery lock"],
|
|
1410
|
+
["Reviewer", plan_reviewer(intent_dir), plan_reviewer_note(intent_dir)],
|
|
1411
|
+
]
|
|
1412
|
+
end
|
|
1413
|
+
|
|
1414
|
+
# plan.md's own ## Risks bullets, wrapped continuations joined (317a's
|
|
1415
|
+
# bullet_rows); [] when plan.md is absent or carries no such section - the
|
|
1416
|
+
# renderer prints the literal "None" rather than an empty table, the
|
|
1417
|
+
# lesson 317a S4 already learned on the Evidence table.
|
|
1418
|
+
def self.risk_rows(intent_dir)
|
|
1419
|
+
path = File.join(intent_dir, "plan.md")
|
|
1420
|
+
return [] unless File.exist?(path)
|
|
1421
|
+
bullet_rows(section_of(File.read(path), "## Risks"))
|
|
1422
|
+
end
|
|
1423
|
+
|
|
1424
|
+
def self.render_plan(intent_dir:, store_root:, template:)
|
|
1425
|
+
id = intent_id(intent_dir)
|
|
1426
|
+
name = title_for(intent_dir, store_root)
|
|
1427
|
+
steps = plan_steps(intent_dir)
|
|
1428
|
+
|
|
1429
|
+
steps_rows =
|
|
1430
|
+
if steps.empty?
|
|
1431
|
+
"| | | no steps yet |"
|
|
1432
|
+
else
|
|
1433
|
+
steps.map do |s|
|
|
1434
|
+
"| #{escape(s[:label])} | #{escape(action_file_for(intent_dir, s[:label]))} | #{escape(s[:text])} |"
|
|
1435
|
+
end.join("\n")
|
|
1436
|
+
end
|
|
1437
|
+
|
|
1438
|
+
risks = risk_rows(intent_dir)
|
|
1439
|
+
risks_block =
|
|
1440
|
+
if risks.empty?
|
|
1441
|
+
"None"
|
|
1442
|
+
else
|
|
1443
|
+
rows = risks.each_with_index.map { |r, i| "| #{i + 1} | #{escape(r)} |" }
|
|
1444
|
+
(["| N | Risk |", "| --- | --- |"] + rows).join("\n")
|
|
1445
|
+
end
|
|
1446
|
+
|
|
1447
|
+
out = template.dup
|
|
1448
|
+
out = out.gsub("{{id}}", id)
|
|
1449
|
+
out = out.gsub("{{name}}", name)
|
|
1450
|
+
out = out.gsub("{{fields.rows}}", state_rows(plan_fields(intent_dir)).join("\n"))
|
|
1451
|
+
out = out.gsub("{{steps.rows}}", steps_rows)
|
|
1452
|
+
out = out.gsub("{{risks.block}}", risks_block)
|
|
1453
|
+
fit_screen(out.gsub(/\n{3,}/, "\n\n"))
|
|
1454
|
+
end
|
|
1455
|
+
|
|
1456
|
+
# --- S9: the session verb (intent 330) -------------------------------------------
|
|
1457
|
+
#
|
|
1458
|
+
# `report-screen session <tier_root>` - the delivered screens for every intent
|
|
1459
|
+
# this session completed, oldest first, then the state --all roster (D1).
|
|
1460
|
+
# Membership is the savepoint Done bookend inside [window_start, now] (D2),
|
|
1461
|
+
# never the delivery lock (a dispatched lead's derived auto- key is not the
|
|
1462
|
+
# owner's session id). The pure functions below take the clock and the
|
|
1463
|
+
# ledger root as arguments (D8): no Time.now, no git, no ENV read here.
|
|
1464
|
+
|
|
1465
|
+
# <home> for a tier root, by the same layout discriminator IntentScreen
|
|
1466
|
+
# uses elsewhere: a project tier root's parent directory is "projects".
|
|
1467
|
+
def self.home_for_tier_root(tier_root)
|
|
1468
|
+
File.basename(File.dirname(tier_root)) == "projects" ? File.expand_path("../..", tier_root) : tier_root
|
|
1469
|
+
end
|
|
1470
|
+
|
|
1471
|
+
# D18: <home>/store/.sessions, derived from the tier root through the SAME
|
|
1472
|
+
# discriminator - deriving it unconditionally from tier_root would answer
|
|
1473
|
+
# "/Users" for the global tier (~/.plastic itself has no "store" segment
|
|
1474
|
+
# to strip).
|
|
1475
|
+
def self.default_ledger_root(tier_root)
|
|
1476
|
+
File.join(home_for_tier_root(tier_root), "store", ".sessions")
|
|
1477
|
+
end
|
|
1478
|
+
|
|
1479
|
+
# D5: "global" is <home> itself; any other slug is <home>/projects/<slug>.
|
|
1480
|
+
def self.store_for_slug(home, slug)
|
|
1481
|
+
slug == "global" ? home : File.join(home, "projects", slug)
|
|
1482
|
+
end
|
|
1483
|
+
|
|
1484
|
+
# D4: the newest valid day directory that is not in the future, when
|
|
1485
|
+
# `today`'s own day directory does not exist. No ledger at all (D3.13)
|
|
1486
|
+
# answers `today` unchanged rather than raising - there is simply nothing
|
|
1487
|
+
# to scan, not an error.
|
|
1488
|
+
def self.fallback_day(ledger_root, today)
|
|
1489
|
+
return today if Dir.exist?(File.join(ledger_root, today))
|
|
1490
|
+
return today unless Dir.exist?(ledger_root)
|
|
1491
|
+
|
|
1492
|
+
candidates = Dir.children(ledger_root).select { |d| SessionLedger.valid_day_id?(d) && d <= today }
|
|
1493
|
+
candidates.max || today
|
|
1494
|
+
end
|
|
1495
|
+
|
|
1496
|
+
# D17: the visible note printed above the screens when no session id was
|
|
1497
|
+
# given at all, so the whole-day, tier-only fallback never looks like a
|
|
1498
|
+
# real, narrower answer.
|
|
1499
|
+
# D17: shaped as a screen opener ("▶ ... · ...") on purpose. The note is the
|
|
1500
|
+
# first line of the reply, and both ScreenPaint's OPENER_RE and the
|
|
1501
|
+
# MessageDisplay hook's first-character gate require that shape; a plain
|
|
1502
|
+
# sentence here would leave the whole session report unpainted.
|
|
1503
|
+
def self.window_note(day, reason)
|
|
1504
|
+
"▶ Window · the whole of #{Date.strptime(day, '%Y%m%d').iso8601} · #{reason}"
|
|
1505
|
+
end
|
|
1506
|
+
|
|
1507
|
+
# True when the day ledger actually carries a line for this session, across
|
|
1508
|
+
# the same two day directories the window search reads. The CLI asks so it
|
|
1509
|
+
# can tell "no session id given" apart from "this session id matches no
|
|
1510
|
+
# ledger line": D17 exists to stop the second one answering silently, and a
|
|
1511
|
+
# resumed background job carries exactly that kind of unmatched id.
|
|
1512
|
+
def self.session_tagged?(ledger_root:, session:, now:)
|
|
1513
|
+
return false if session.nil? || session.to_s.strip.empty?
|
|
1514
|
+
|
|
1515
|
+
short = SessionLedger.short_session_id(session)
|
|
1516
|
+
today = SessionLedger.day_id(now)
|
|
1517
|
+
yesterday = SessionLedger.day_id(now - 86_400)
|
|
1518
|
+
[yesterday, today].any? do |d|
|
|
1519
|
+
session_ledger_lines(ledger_root, d).any? { |l| l[:session] == short }
|
|
1520
|
+
end
|
|
1521
|
+
end
|
|
1522
|
+
|
|
1523
|
+
# D4: local midnight of `day`, converted to UTC, using `sample_now`'s OWN
|
|
1524
|
+
# utc_offset - never a literal UTC midnight, and never the machine's
|
|
1525
|
+
# ambient zone outside what the injected clock itself carries.
|
|
1526
|
+
def self.local_midnight_utc(day, sample_now)
|
|
1527
|
+
date = Date.strptime(day, "%Y%m%d")
|
|
1528
|
+
Time.new(date.year, date.month, date.day, 0, 0, 0, sample_now.utc_offset)
|
|
1529
|
+
end
|
|
1530
|
+
|
|
1531
|
+
# One day's session-tagged savepoint lines: "{ts} {Event} [{session}]
|
|
1532
|
+
# [{slug}] {summary}" (SessionLedger.savepoint_line's own shape). Missing
|
|
1533
|
+
# file, or a line that does not match, is silently skipped.
|
|
1534
|
+
SESSION_LEDGER_LINE_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s{2,}\S+\s{2,}\[([^\]]*)\]\s\[([^\]]*)\]/.freeze
|
|
1535
|
+
|
|
1536
|
+
def self.session_ledger_lines(ledger_root, day)
|
|
1537
|
+
path = File.join(ledger_root, day, "savepoint.md")
|
|
1538
|
+
return [] unless File.exist?(path)
|
|
1539
|
+
|
|
1540
|
+
File.readlines(path).filter_map do |line|
|
|
1541
|
+
m = line.match(SESSION_LEDGER_LINE_RE)
|
|
1542
|
+
m ? { ts: m[1], session: m[2], slug: m[3] } : nil
|
|
1543
|
+
end
|
|
1544
|
+
end
|
|
1545
|
+
|
|
1546
|
+
def self.store_intent_dirs(store)
|
|
1547
|
+
Dir.glob(File.join(store, "store", "*")).select { |d| IntentScreen.intent_dir?(d) }
|
|
1548
|
+
end
|
|
1549
|
+
|
|
1550
|
+
def self.last_done_ts(intent_dir)
|
|
1551
|
+
lines = savepoint_lines(intent_dir)
|
|
1552
|
+
done = lines.reverse.find { |_ts, kind, _text| kind == "Done" }
|
|
1553
|
+
done ? Time.parse(done[0]) : nil
|
|
1554
|
+
end
|
|
1555
|
+
|
|
1556
|
+
# D2/D3/D4/D5/D22: the intent directories completed inside the session's
|
|
1557
|
+
# window, oldest Done bookend first, plus the count of completed intents
|
|
1558
|
+
# (D22: Completed or Abandoned in INDEX.md) that carry no Done bookend at
|
|
1559
|
+
# all and so cannot be placed in any window.
|
|
1560
|
+
def self.session_delivered_dirs(ledger_root:, tier_root:, session:, since:, now:)
|
|
1561
|
+
today = SessionLedger.day_id(now)
|
|
1562
|
+
yesterday = SessionLedger.day_id(now - 86_400)
|
|
1563
|
+
short = session && !session.to_s.strip.empty? ? SessionLedger.short_session_id(session) : nil
|
|
1564
|
+
|
|
1565
|
+
tagged = short ? [yesterday, today].flat_map { |d| session_ledger_lines(ledger_root, d) }
|
|
1566
|
+
.select { |l| l[:session] == short } : []
|
|
1567
|
+
slugs = tagged.map { |l| l[:slug] }.uniq
|
|
1568
|
+
|
|
1569
|
+
window_start =
|
|
1570
|
+
if since
|
|
1571
|
+
Time.parse(since.to_s)
|
|
1572
|
+
elsif tagged.any?
|
|
1573
|
+
tagged.map { |l| Time.parse(l[:ts]) }.min
|
|
1574
|
+
else
|
|
1575
|
+
local_midnight_utc(fallback_day(ledger_root, today), now)
|
|
1576
|
+
end
|
|
1577
|
+
|
|
1578
|
+
home = home_for_tier_root(tier_root)
|
|
1579
|
+
stores = ([tier_root] + slugs.map { |s| store_for_slug(home, s) }).uniq
|
|
1580
|
+
stores = stores.select { |s| File.exist?(File.join(s, "INDEX.md")) }
|
|
1581
|
+
|
|
1582
|
+
entries = []
|
|
1583
|
+
skipped = 0
|
|
1584
|
+
stores.each do |store|
|
|
1585
|
+
completed = completed_dirnames(File.join(store, "INDEX.md"))
|
|
1586
|
+
store_intent_dirs(store).each do |dir|
|
|
1587
|
+
done_ts = last_done_ts(dir)
|
|
1588
|
+
if done_ts
|
|
1589
|
+
entries << [dir, done_ts] if done_ts >= window_start && done_ts <= now
|
|
1590
|
+
elsif completed.include?(File.basename(dir))
|
|
1591
|
+
skipped += 1
|
|
1592
|
+
end
|
|
1593
|
+
end
|
|
1594
|
+
end
|
|
1595
|
+
|
|
1596
|
+
[entries.sort_by { |_dir, ts| ts }.map(&:first), skipped]
|
|
1597
|
+
end
|
|
1598
|
+
|
|
1599
|
+
# D1/D7/D21/D22: one delivered screen per directory (oldest first, one
|
|
1600
|
+
# blank line apart), the roster last, and the skipped-count footer between
|
|
1601
|
+
# them when non-zero. `painter` is applied to each block SEPARATELY (D21):
|
|
1602
|
+
# a screen ScreenPaint cannot parse falls back to its own plain text
|
|
1603
|
+
# without touching its neighbours; the default is the identity function,
|
|
1604
|
+
# so a caller that never paints gets the plain screens verbatim. A
|
|
1605
|
+
# directory whose delivered screen cannot be rendered (O3.28) never sinks
|
|
1606
|
+
# the rest of the report.
|
|
1607
|
+
def self.render_session(dirs:, skipped:, store_root:, tag_reader: ->(_dir) { nil },
|
|
1608
|
+
branch_reader: ->(_dir) { nil }, note: nil, changed: nil,
|
|
1609
|
+
now: Time.now, painter: ->(text) { text })
|
|
1610
|
+
blocks = []
|
|
1611
|
+
blocks << note if note && !note.to_s.empty?
|
|
1612
|
+
|
|
1613
|
+
if dirs.empty?
|
|
1614
|
+
blocks << "No intents delivered in this session."
|
|
1615
|
+
else
|
|
1616
|
+
dirs.each do |dir|
|
|
1617
|
+
blocks << begin
|
|
1618
|
+
render_delivered(intent_dir: dir, tag_reader: tag_reader, branch_reader: branch_reader).chomp
|
|
1619
|
+
rescue StandardError => e
|
|
1620
|
+
"## #{intent_id(dir)} · could not render (#{e.message})"
|
|
1621
|
+
end
|
|
1622
|
+
end
|
|
1623
|
+
end
|
|
1624
|
+
|
|
1625
|
+
if skipped.positive?
|
|
1626
|
+
blocks << "#{skipped} completed intent#{skipped == 1 ? '' : 's'} skipped: no Done bookend in savepoint.md."
|
|
1627
|
+
end
|
|
1628
|
+
|
|
1629
|
+
blocks << render_roster(store_root, changed: changed, now: now).chomp
|
|
1630
|
+
|
|
1631
|
+
"#{blocks.map { |b| painter.call(b) }.join("\n\n")}\n"
|
|
1632
|
+
end
|
|
1633
|
+
|
|
1634
|
+
# --- S10: the roadmap verb (intent 331c) -----------------------------------------
|
|
1635
|
+
#
|
|
1636
|
+
# `report-screen roadmap <roadmap.md> plan|state|delivered` - a roadmap's own three reports,
|
|
1637
|
+
# the counterpart to an intent's state/delivered. Every entry comes from RoadmapQueue's public
|
|
1638
|
+
# `roadmap` reader (D6/R1): no second parser here ever re-derives its grammar, its INDEX
|
|
1639
|
+
# reconciliation, or its frontier selection.
|
|
1640
|
+
|
|
1641
|
+
ROADMAP_VERBS = %w[plan state delivered].freeze
|
|
1642
|
+
|
|
1643
|
+
# D6/R17: the tier root for a roadmap path is the parent of `roadmaps/`, one extra parent when
|
|
1644
|
+
# the file sits under `roadmaps/archived/` - the SAME rule RoadmapSavepoint.index_path_for
|
|
1645
|
+
# uses (that method is private, so this is the rule's second, agreeing owner; a test pins them
|
|
1646
|
+
# together).
|
|
1647
|
+
def self.roadmap_tier_root(path)
|
|
1648
|
+
dir = File.dirname(path)
|
|
1649
|
+
dir = File.dirname(dir) if File.basename(dir) == "archived"
|
|
1650
|
+
File.dirname(dir)
|
|
1651
|
+
end
|
|
1652
|
+
|
|
1653
|
+
def self.roadmap_default_template_path(verb)
|
|
1654
|
+
File.expand_path("../../templates/report-roadmap-#{verb}.md", __dir__)
|
|
1655
|
+
end
|
|
1656
|
+
|
|
1657
|
+
# D6/R1: the parsed, INDEX-reconciled entries for one roadmap file, obtained from
|
|
1658
|
+
# RoadmapQueue's own public reader - never a second parser.
|
|
1659
|
+
def self.roadmap_entries(path:, store_root:)
|
|
1660
|
+
index_path = File.join(store_root, "INDEX.md")
|
|
1661
|
+
RoadmapQueue.new(roadmaps_dir: File.dirname(path), index_path: index_path).roadmap(path)
|
|
1662
|
+
end
|
|
1663
|
+
|
|
1664
|
+
# R4/R15: the first sentence of `## Goal`, joined across wrapped source lines. Splits on a
|
|
1665
|
+
# period only (never IntentScreen.clause_and_rest's `[.;]` - a semicolon inside a real goal is
|
|
1666
|
+
# common and must survive, R15); a period with no following whitespace or end-of-string (a
|
|
1667
|
+
# version number like "2.0.0", never followed by a space mid-number) is never mistaken for a
|
|
1668
|
+
# sentence boundary (R4).
|
|
1669
|
+
def self.roadmap_goal(text)
|
|
1670
|
+
section = section_of(text, "## Goal").strip
|
|
1671
|
+
return NOT_RECORDED if section.empty?
|
|
1672
|
+
|
|
1673
|
+
joined = section.lines.map(&:strip).join(" ").squeeze(" ")
|
|
1674
|
+
m = joined.match(/\A(.*?\.)(?=\s|\z)/)
|
|
1675
|
+
(m ? m[1] : joined).strip
|
|
1676
|
+
end
|
|
1677
|
+
|
|
1678
|
+
# R16: the ledger's own entries when the paired `.savepoint.md` carries any; otherwise the
|
|
1679
|
+
# `## Log` lines classified through RoadmapSavepoint.classify_event (the same KEYWORD_TABLE,
|
|
1680
|
+
# no second vocabulary), each timestamped from its own Log line's date and time. A roadmap with
|
|
1681
|
+
# neither source (no ledger file, no classifiable Log line) answers `[]`, never an invented
|
|
1682
|
+
# event - callers reading it print `not recorded`.
|
|
1683
|
+
def self.roadmap_events(path)
|
|
1684
|
+
ledger = RoadmapSavepoint.ledger_entries(path)
|
|
1685
|
+
return ledger.sort_by { |t, _, _| t } if ledger.any?
|
|
1686
|
+
|
|
1687
|
+
text = File.exist?(path) ? File.read(path) : nil
|
|
1688
|
+
return [] unless text
|
|
1689
|
+
|
|
1690
|
+
section_of(text, "## Log").each_line.filter_map do |line|
|
|
1691
|
+
m = line.strip.match(RoadmapSavepoint::LOG_LINE)
|
|
1692
|
+
next nil unless m
|
|
1693
|
+
event = RoadmapSavepoint.classify_event(m[3])
|
|
1694
|
+
next nil unless event
|
|
1695
|
+
[Time.parse("#{m[1]}T#{m[2]}:00Z"), event, m[3].strip]
|
|
1696
|
+
end
|
|
1697
|
+
end
|
|
1698
|
+
|
|
1699
|
+
# Every `## Log` line, classified for the delivered screen's Log table: an unclassifiable line
|
|
1700
|
+
# still renders, with `not recorded` in its Event cell (never dropped, unlike roadmap_events'
|
|
1701
|
+
# fallback, which only wants events it can act on).
|
|
1702
|
+
def self.roadmap_log_rows(text)
|
|
1703
|
+
section_of(text, "## Log").each_line.filter_map do |line|
|
|
1704
|
+
m = line.strip.match(RoadmapSavepoint::LOG_LINE)
|
|
1705
|
+
next nil unless m
|
|
1706
|
+
{ when: human_time("#{m[1]}T#{m[2]}:00Z"), event: RoadmapSavepoint.classify_event(m[3]) || NOT_RECORDED, what: m[3].strip }
|
|
1707
|
+
end
|
|
1708
|
+
end
|
|
1709
|
+
|
|
1710
|
+
# R7: idle unless the entry's own delivery lock is fresh as of `now:` - a stale lock (the
|
|
1711
|
+
# heartbeat older than the TTL) never masquerades as a live lead.
|
|
1712
|
+
def self.roadmap_lead(intent_dir, now:)
|
|
1713
|
+
return "idle" unless intent_dir
|
|
1714
|
+
lead_cell(intent_dir, now: now)
|
|
1715
|
+
end
|
|
1716
|
+
|
|
1717
|
+
def self.roadmap_intent_dir(store_root, id)
|
|
1718
|
+
Dir.glob(File.join(store_root, "store", "#{id}--*")).sort.find { |d| IntentScreen.intent_dir?(d) }
|
|
1719
|
+
end
|
|
1720
|
+
|
|
1721
|
+
def self.roadmap_entry_progress(dir)
|
|
1722
|
+
return NOT_RECORDED unless dir
|
|
1723
|
+
items = IntentScreen.checklist_items(dir)
|
|
1724
|
+
fields = IntentScreen.progress_fields(items)
|
|
1725
|
+
"#{fields['progress.bar']} #{fields['progress.done']} / #{fields['progress.total']}"
|
|
1726
|
+
end
|
|
1727
|
+
|
|
1728
|
+
def self.roadmap_progress_bar(done, total)
|
|
1729
|
+
on = total.zero? ? 0 : (done * IntentScreen::BAR_WIDTH) / total
|
|
1730
|
+
(IntentScreen::ON * on) + (IntentScreen::OFF * (IntentScreen::BAR_WIDTH - on))
|
|
1731
|
+
end
|
|
1732
|
+
|
|
1733
|
+
# "Batch" or "Wave" (singular): the entries table's own first column header (R3 - a legacy
|
|
1734
|
+
# Waves roadmap reads "Wave", never "Batch").
|
|
1735
|
+
def self.roadmap_batch_label(data)
|
|
1736
|
+
data[:grouping] == "Waves" ? "Wave" : "Batch"
|
|
1737
|
+
end
|
|
1738
|
+
|
|
1739
|
+
def self.roadmap_all_entries(data)
|
|
1740
|
+
data[:batches].flat_map { |b| b[:entries] }
|
|
1741
|
+
end
|
|
1742
|
+
|
|
1743
|
+
# --- plan (D2) ---------------------------------------------------------------
|
|
1744
|
+
|
|
1745
|
+
def self.roadmap_plan_fields(text, data, events)
|
|
1746
|
+
all_entries = roadmap_all_entries(data)
|
|
1747
|
+
order = data[:batches].map { |b| b[:heading] }.join(" → ")
|
|
1748
|
+
created = events.empty? ? NOT_RECORDED : human_time(events.first[0].utc.iso8601)
|
|
1749
|
+
[
|
|
1750
|
+
["Goal", roadmap_goal(text), ""],
|
|
1751
|
+
[data[:grouping], "#{data[:batches].length} #{data[:grouping].downcase}, #{all_entries.length} intents", ""],
|
|
1752
|
+
["Order", order, ""],
|
|
1753
|
+
["Created", created, ""],
|
|
1754
|
+
]
|
|
1755
|
+
end
|
|
1756
|
+
|
|
1757
|
+
# Intent 337, n7: the graph tree block for the plan screen, additive only
|
|
1758
|
+
# - a roadmap with no real "## Graph" section (RoadmapGraph.analyze's
|
|
1759
|
+
# `has_graph: false`) or a cyclic one renders no block at all, so a
|
|
1760
|
+
# graphless roadmap's screen stays byte-identical to before this method
|
|
1761
|
+
# existed (row 7.2). Fits the same screen limit `fit_screen` enforces
|
|
1762
|
+
# everywhere else (row 7.3), fenced so a box-drawing line is never
|
|
1763
|
+
# mistaken for a markdown table row.
|
|
1764
|
+
def self.roadmap_tree_block(path, store_root)
|
|
1765
|
+
index_path = File.join(store_root, "INDEX.md")
|
|
1766
|
+
analysis = RoadmapGraph.analyze(path, index_path: index_path)
|
|
1767
|
+
return "" unless analysis[:has_graph] && analysis[:cycle].nil?
|
|
1768
|
+
|
|
1769
|
+
labels = analysis[:entries].each_with_object({}) { |(id, e), h| h[id] = e[:title] }
|
|
1770
|
+
marks = {
|
|
1771
|
+
critical_path: analysis[:critical_paths] ? (analysis[:critical_paths][:critical_path] || []) : [],
|
|
1772
|
+
ready: analysis[:ready] || [],
|
|
1773
|
+
}
|
|
1774
|
+
tree = GraphTree.render(edges: analysis[:edges], labels: labels, marks: marks, width: FIT_SCREEN_DEFAULT_LIMIT)
|
|
1775
|
+
return "" unless tree[:ok]
|
|
1776
|
+
|
|
1777
|
+
"\n\n**Tree**\n\n```\n#{tree[:text]}```\n"
|
|
1778
|
+
end
|
|
1779
|
+
|
|
1780
|
+
def self.roadmap_plan_entries_table(data)
|
|
1781
|
+
label = roadmap_batch_label(data)
|
|
1782
|
+
rows = ["| #{label} | Graph ID | Intent | Status |", "| --- | --- | --- | --- |"]
|
|
1783
|
+
data[:batches].each do |batch|
|
|
1784
|
+
batch[:entries].each do |e|
|
|
1785
|
+
rows << "| #{escape(batch[:heading])} | #{escape(e[:id])} | #{escape(e[:text])} | #{escape(e[:status])} |"
|
|
1786
|
+
end
|
|
1787
|
+
end
|
|
1788
|
+
rows.join("\n")
|
|
1789
|
+
end
|
|
1790
|
+
|
|
1791
|
+
# --- state (D3) ---------------------------------------------------------------
|
|
1792
|
+
|
|
1793
|
+
def self.roadmap_state_fields(text, data, events, store_root, now)
|
|
1794
|
+
all_entries = roadmap_all_entries(data)
|
|
1795
|
+
total = all_entries.length
|
|
1796
|
+
delivered = all_entries.count { |e| e[:status] == "delivered" }
|
|
1797
|
+
bar = roadmap_progress_bar(delivered, total)
|
|
1798
|
+
|
|
1799
|
+
frontier = data[:frontier]
|
|
1800
|
+
frontier_value = frontier ? frontier[:heading] : NOT_RECORDED
|
|
1801
|
+
frontier_note =
|
|
1802
|
+
if frontier.nil?
|
|
1803
|
+
""
|
|
1804
|
+
elsif frontier[:in_flight].any?
|
|
1805
|
+
"in flight"
|
|
1806
|
+
else
|
|
1807
|
+
"queued"
|
|
1808
|
+
end
|
|
1809
|
+
|
|
1810
|
+
delivering_value =
|
|
1811
|
+
if frontier && frontier[:in_flight].any?
|
|
1812
|
+
frontier[:in_flight].map do |e|
|
|
1813
|
+
dir = roadmap_intent_dir(store_root, e["id"])
|
|
1814
|
+
"#{e['id']} (#{roadmap_lead(dir, now: now)})"
|
|
1815
|
+
end.join(", ")
|
|
1816
|
+
else
|
|
1817
|
+
NOT_RECORDED
|
|
1818
|
+
end
|
|
1819
|
+
|
|
1820
|
+
next_entry = all_entries.find { |e| e[:status] == "queued" }
|
|
1821
|
+
next_value = next_entry ? "#{next_entry[:id]} #{next_entry[:text]}".strip : NOT_RECORDED
|
|
1822
|
+
|
|
1823
|
+
changed_value = events.empty? ? NOT_RECORDED : "#{events.last[1]} · #{human_time(events.last[0].utc.iso8601)}"
|
|
1824
|
+
|
|
1825
|
+
[
|
|
1826
|
+
["Goal", roadmap_goal(text), ""],
|
|
1827
|
+
["Progress", "#{bar} #{delivered} / #{total}", ""],
|
|
1828
|
+
["Frontier", frontier_value, frontier_note],
|
|
1829
|
+
["Delivering", delivering_value, ""],
|
|
1830
|
+
["Next", next_value, ""],
|
|
1831
|
+
["Changed", changed_value, ""],
|
|
1832
|
+
]
|
|
1833
|
+
end
|
|
1834
|
+
|
|
1835
|
+
# RC4/spec.md defect 2: the Batches table carries the same Intent title column the plan
|
|
1836
|
+
# verb's own table already does (roadmap_plan_entries_table). The Intent cell spends
|
|
1837
|
+
# whatever the row's other cells leave it (W8a/W8b) through the ONE shared budget helper
|
|
1838
|
+
# (fit_row_cell) dashboard.rb's screen_fit_intent also spends by, computed PER ROW from that
|
|
1839
|
+
# row's own batch/id/status/progress/lead - never a cross-row max - so one long row's Intent
|
|
1840
|
+
# cell can never re-truncate another row's already-correct one (A5).
|
|
1841
|
+
def self.roadmap_state_entries_table(data, store_root, now)
|
|
1842
|
+
label = roadmap_batch_label(data)
|
|
1843
|
+
rows = ["| #{label} | Graph ID | Intent | Status | Progress | Lead |",
|
|
1844
|
+
"| --- | --- | --- | --- | --- | --- |"]
|
|
1845
|
+
data[:batches].each do |batch|
|
|
1846
|
+
batch[:entries].each do |e|
|
|
1847
|
+
dir = roadmap_intent_dir(store_root, e[:id])
|
|
1848
|
+
progress = roadmap_entry_progress(dir)
|
|
1849
|
+
lead = roadmap_lead(dir, now: now)
|
|
1850
|
+
others = [batch[:heading], e[:id], e[:status], progress, lead]
|
|
1851
|
+
intent_cell = fit_row_cell(e[:text], others)
|
|
1852
|
+
rows << "| #{escape(batch[:heading])} | #{escape(e[:id])} | #{escape(intent_cell)} | " \
|
|
1853
|
+
"#{escape(e[:status])} | #{escape(progress)} | #{escape(lead)} |"
|
|
1854
|
+
end
|
|
1855
|
+
end
|
|
1856
|
+
rows.join("\n")
|
|
1857
|
+
end
|
|
1858
|
+
|
|
1859
|
+
# --- delivered (D4) ------------------------------------------------------------
|
|
1860
|
+
|
|
1861
|
+
def self.roadmap_delivered_meta(data, events)
|
|
1862
|
+
all_entries = roadmap_all_entries(data)
|
|
1863
|
+
closed = events.reverse.find { |_t, event, _d| event == "closed" }
|
|
1864
|
+
closed_part = closed ? human_time(closed[0].utc.iso8601) : "in progress"
|
|
1865
|
+
|
|
1866
|
+
merged = events.select { |_t, event, _d| event == "merged" }
|
|
1867
|
+
duration = events.empty? || merged.empty? ? NOT_RECORDED : format_duration((merged.last[0] - events.first[0]).to_i)
|
|
1868
|
+
|
|
1869
|
+
"#{closed_part} · #{all_entries.length} intents · #{duration}"
|
|
1870
|
+
end
|
|
1871
|
+
|
|
1872
|
+
# The regex RoadmapSavepoint::KEYWORD_TABLE pairs with an event word - read from the table
|
|
1873
|
+
# rather than copied, so the Merged cell's vocabulary never drifts from rebuild's own.
|
|
1874
|
+
def self.roadmap_savepoint_keyword_regex(event)
|
|
1875
|
+
RoadmapSavepoint::KEYWORD_TABLE.find { |_re, ev| ev == event }.first
|
|
1876
|
+
end
|
|
1877
|
+
|
|
1878
|
+
# R10/R21/R22: the Merged cell reads a line only when the entry id is its SUBJECT - the first
|
|
1879
|
+
# whitespace-delimited token of the detail, never a whole word anywhere in it (R21: a real
|
|
1880
|
+
# ledger line names one entry's id as its subject and a SECOND entry's id in passing, and the
|
|
1881
|
+
# second entry has no merge line of its own to fill this row with). Among subject-matching
|
|
1882
|
+
# lines, one is read when the ledger's own event is `merged` OR its detail matches
|
|
1883
|
+
# KEYWORD_TABLE's merged pattern (R22: the appender sometimes files a real per-entry merge
|
|
1884
|
+
# under a different event word, `dispatched`, because the rest of the line was other news),
|
|
1885
|
+
# and refused when the event is `handoff` or the detail matches KEYWORD_TABLE's handoff
|
|
1886
|
+
# pattern - stricter than R10's original guarantee, never weaker. The sha is the first
|
|
1887
|
+
# hex-with-at-least-one-digit token of 7-40 characters in the matched line.
|
|
1888
|
+
def self.roadmap_merged_cell(id, events)
|
|
1889
|
+
merged_re = roadmap_savepoint_keyword_regex("merged")
|
|
1890
|
+
handoff_re = roadmap_savepoint_keyword_regex("handoff")
|
|
1891
|
+
|
|
1892
|
+
line = events.find do |_t, event, detail|
|
|
1893
|
+
next false unless detail.to_s.strip.split(/\s+/).first == id
|
|
1894
|
+
next false if event == "handoff" || detail.to_s =~ handoff_re
|
|
1895
|
+
event == "merged" || detail.to_s =~ merged_re
|
|
1896
|
+
end
|
|
1897
|
+
return NOT_RECORDED unless line
|
|
1898
|
+
|
|
1899
|
+
m = line[2].match(/\b(?=[0-9a-f]*\d)[0-9a-f]{7,40}\b/i)
|
|
1900
|
+
m ? m[0] : NOT_RECORDED
|
|
1901
|
+
end
|
|
1902
|
+
|
|
1903
|
+
def self.roadmap_delivered_table(data, events)
|
|
1904
|
+
label = roadmap_batch_label(data)
|
|
1905
|
+
rows = ["| #{label} | Graph ID | Intent | Merged |", "| --- | --- | --- | --- |"]
|
|
1906
|
+
data[:batches].each do |batch|
|
|
1907
|
+
batch[:entries].each do |e|
|
|
1908
|
+
rows << "| #{escape(batch[:heading])} | #{escape(e[:id])} | #{escape(e[:text])} | " \
|
|
1909
|
+
"#{escape(roadmap_merged_cell(e[:id], events))} |"
|
|
1910
|
+
end
|
|
1911
|
+
end
|
|
1912
|
+
rows.join("\n")
|
|
1913
|
+
end
|
|
1914
|
+
|
|
1915
|
+
def self.roadmap_log_table(text)
|
|
1916
|
+
log_rows = roadmap_log_rows(text)
|
|
1917
|
+
return NOT_RECORDED if log_rows.empty?
|
|
1918
|
+
|
|
1919
|
+
rows = ["| When | Event | Detail |", "| --- | --- | --- |"]
|
|
1920
|
+
log_rows.each { |r| rows << "| #{escape(r[:when])} | #{escape(r[:event])} | #{escape(r[:what])} |" }
|
|
1921
|
+
rows.join("\n")
|
|
1922
|
+
end
|
|
1923
|
+
|
|
1924
|
+
# --- render ---------------------------------------------------------------------
|
|
1925
|
+
|
|
1926
|
+
# D6: `ReportScreen.render_roadmap(path:, verb:, store_root: nil, now: Time.now, template:
|
|
1927
|
+
# nil)`. No ENV, no git; `now:` is used only for lock freshness (R7). `store_root` defaults to
|
|
1928
|
+
# the derived tier root; `template` defaults to the installed-or-in-repo
|
|
1929
|
+
# `templates/report-roadmap-<verb>.md`.
|
|
1930
|
+
def self.render_roadmap(path:, verb:, store_root: nil, now: Time.now, template: nil)
|
|
1931
|
+
verb = verb.to_s
|
|
1932
|
+
raise ArgumentError, "verb must be one of #{ROADMAP_VERBS.join(', ')}, got #{verb.inspect}" unless ROADMAP_VERBS.include?(verb)
|
|
1933
|
+
|
|
1934
|
+
store_root ||= roadmap_tier_root(path)
|
|
1935
|
+
text = File.read(path)
|
|
1936
|
+
data = roadmap_entries(path: path, store_root: store_root)
|
|
1937
|
+
events = roadmap_events(path)
|
|
1938
|
+
template ||= File.read(roadmap_default_template_path(verb))
|
|
1939
|
+
|
|
1940
|
+
out = template.dup
|
|
1941
|
+
out = out.gsub("{{slug}}", data[:slug])
|
|
1942
|
+
|
|
1943
|
+
case verb
|
|
1944
|
+
when "plan"
|
|
1945
|
+
out = out.gsub("{{fields.rows}}", state_rows(roadmap_plan_fields(text, data, events)).join("\n"))
|
|
1946
|
+
out = out.gsub("{{entries.table}}", roadmap_plan_entries_table(data))
|
|
1947
|
+
out = out.gsub("{{tree}}", roadmap_tree_block(path, store_root))
|
|
1948
|
+
when "state"
|
|
1949
|
+
out = out.gsub("{{fields.rows}}", state_rows(roadmap_state_fields(text, data, events, store_root, now)).join("\n"))
|
|
1950
|
+
out = out.gsub("{{entries.table}}", roadmap_state_entries_table(data, store_root, now))
|
|
1951
|
+
when "delivered"
|
|
1952
|
+
out = out.gsub("{{meta}}", roadmap_delivered_meta(data, events))
|
|
1953
|
+
out = out.gsub("{{delivered.table}}", roadmap_delivered_table(data, events))
|
|
1954
|
+
out = out.gsub("{{log.table}}", roadmap_log_table(text))
|
|
1955
|
+
end
|
|
1956
|
+
|
|
1957
|
+
fit_screen(out.gsub(/\n{3,}/, "\n\n"))
|
|
1958
|
+
end
|
|
1959
|
+
|
|
1960
|
+
# --- S8: --ansi passthrough (D2) -----------------------------------------------
|
|
1961
|
+
#
|
|
1962
|
+
# 316a owns the ANSI renderer; 317 only wires a generic DI seam so this
|
|
1963
|
+
# module never blocks on 316a landing and never breaks when it does (row 77).
|
|
1964
|
+
# A renderer file, when present, is expected to define IntentScreenAnsi.paint
|
|
1965
|
+
# (one plain-text string in, one string out). Wiring the real contract 316a
|
|
1966
|
+
# ships is left to a follow-up step once that file exists (see checklist S14).
|
|
1967
|
+
end
|