@zalom/plastic 2.0.0-alpha.8 → 2.0.1

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