@zalom/plastic 2.0.0-alpha.9 → 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 +11 -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 +55 -2
  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 +153 -30
  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 +7 -0
  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 +23 -14
  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 +531 -71
  152. package/scripts/lib/intent_screen.rb +4 -4
  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 +350 -54
  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 +1371 -47
  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 +365 -28
  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 +177 -16
  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 +8 -3
  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 -296
  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 -176
  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
@@ -17,21 +17,186 @@ require_relative "intent_screen_ansi"
17
17
  # markdown_safe are caller arguments, exactly like IntentScreenAnsi before it
18
18
  # (316a1); the 318 ceiling holds - the palette is IntentScreenAnsi's, no new
19
19
  # colors, no box borders.
20
+ #
21
+ # Intent 331a (D6): a registry, so a new screen KIND is a new file
22
+ # (scripts/lib/screens/<kind>.rb, calling `register` on load), never a diff
23
+ # to this one. `paint:` is optional and defaults to the shared pipeline
24
+ # below - no shipped kind has its own palette; `classify`/`paint` branch on
25
+ # LINE SHAPE, never on kind. The registry's job is only that a new kind's
26
+ # opener is recognized without editing this file, and that a kind CAN
27
+ # supply its own paint lambda on the rare day one needs one.
20
28
  module ScreenPaint
21
29
  A = IntentScreenAnsi
22
30
 
23
31
  # A screen's first line: "## ▶ id · name", "## ✔ id · name · delivered",
24
- # "▶ In delivery · ...", "✔ id · name · delivered in ...".
32
+ # "▶ In delivery · ...", "✔ id · name · delivered in ...". Retained for
33
+ # reference (and as the union every shipped kind below decomposes into);
34
+ # `classify`/`paint` consult the registry, not this constant, directly.
25
35
  OPENER_RE = /\A(?:## )?[▶✔] .+ · /.freeze
26
36
 
27
37
  FIELD_LINE_RE = /\A(Stage|Next|Changed|Lead|Progress)(\s{2,})(.*)\z/.freeze
28
38
  STEP_LINE_RE = /\A(S\d+)\s+\[ (open|done) \]\s+(.*)\z/.freeze
29
39
  TIMELINE_RE = /\A(\d\d:\d\d)\s{2}(\S+)\s{2}(.*)\z/.freeze
30
40
  COUNT_LINE_RE = /\A\d+ open( · .*)?\z/.freeze
41
+ # 331a1: the session verb prints this note between the delivered screens and
42
+ # the roster (report_screen.rb, render_session). It is our own output, so the
43
+ # painter must not reject it - the live hook trace caught the region stopping
44
+ # dead on it, with the whole roster below reaching the terminal as plain
45
+ # Markdown. Pinned to the exact shape rather than "any sentence": region_end
46
+ # exists to stop at the model's own prose, and a loose rule would swallow it.
47
+ SKIP_NOTE_RE = /\A\d+ completed intents? skipped: .+\z/.freeze
48
+ # 331a2 (D5, S3): render_session's own rescue card when a directory's delivered screen
49
+ # raises (report_screen.rb:1466-1468) - our own output, not model prose. The census (331a2)
50
+ # proved it classified :unknown, orphaning the roster below it in a real reply. `:meta`, not
51
+ # a new kind: it is a one-line grey note under a title and paint's `:meta` arm reads only
52
+ # `line.strip`, with no positional dependency.
53
+ RESCUE_CARD_RE = /\A## \S+ · could not render \(.*\)\z/.freeze
31
54
  BOLD_LEAD_RE = /\A\*\*([^*]+)\*\*(.*)\z/.freeze
32
55
 
56
+ # Intent 317a1 (D3, D4, D5): the data-table palette. Kind and note columns
57
+ # are chosen by the table's own header, never by position; a cell whose
58
+ # text is exactly "not recorded" greys wherever it appears.
59
+ EVIDENCE_PROOF_KINDS = %w[suite red ship doctor deposits verdict].freeze
60
+ EVIDENCE_DEVIATION_KINDS = %w[deviates].freeze
61
+ # "Reason" is the current header (D5, intent 331f); "Why" stays too so a screen captured
62
+ # before the rename still paints (the header map's own forgiving-reader guarantee).
63
+ NOTE_HEADERS = %w[Source Why Reason].freeze
64
+ NOT_RECORDED = "not recorded"
65
+
66
+ # Intent 331f (finding 1, post-exec review): the width bound a column shrink never crosses,
67
+ # and the glyphs that mark a column as a progress bar, never itself shrunk. Shared with
68
+ # ReportScreen.fit_table_block (report_screen.rb requires this file, not the other way
69
+ # around) so a markdown row and a painted row bound their columns the same way.
70
+ FIT_COLUMN_FLOOR = 8
71
+ PROGRESS_BAR_CHARS_RE = /[█░]/.freeze
72
+
73
+ @registry = {}
74
+
33
75
  module_function
34
76
 
77
+ # Intent 331f1 (spec.md's acceptance rule): the conservative terminal cost used ONLY to
78
+ # enforce the 115-column bound - ANSI escapes stripped, then every character at or above
79
+ # U+1100 (the East Asian Wide/Ambiguous threshold; a block glyph like "█"/"░" sits well past
80
+ # it) counts two columns, everything else one. `IntentScreenAnsi.visible_width` stays a
81
+ # plain ANSI-stripped `.length` because it drives PADDING, not the bound - a terminal that
82
+ # draws "█" one column wide would misalign if padding used this conservative cost.
83
+ # Over-bounding only ever makes a row narrower, never wraps one, so using this rule for the
84
+ # bound alone is always safe.
85
+ WIDE_CODEPOINT_MIN = 0x1100
86
+
87
+ def display_columns(text)
88
+ text.to_s.gsub(A::ANSI_RE, "").each_char.sum { |c| c.ord >= WIDE_CODEPOINT_MIN ? 2 : 1 }
89
+ end
90
+
91
+ # Truncate `text` to at most `max_chars` DISPLAY COLUMNS, cutting at the last whitespace at
92
+ # or before the limit (never mid-word) and appending a single ellipsis when truncation
93
+ # happens. The one shared implementation (intent 331f, finding 1; intent 331f1 finding A1):
94
+ # ReportScreen.truncate_on_word_boundary and dashboard.rb's own helper of the same name both
95
+ # delegate here. The ellipsis (U+2026) itself sits above WIDE_CODEPOINT_MIN, so it costs TWO
96
+ # display columns even though it is one character - the budget reserves that display width,
97
+ # not `ellipsis.length`, or every truncated cell lands one column over (finding A1). Any
98
+ # ellipsis already trailing the word-boundary slice is dropped before the fresh one is
99
+ # appended, so a value truncated twice (the assembled-row backstop truncating a cell that
100
+ # was already cut) never stacks a second ellipsis onto the first.
101
+ def truncate_on_word_boundary(text, max_chars)
102
+ t = text.to_s
103
+ return t if display_columns(t) <= max_chars
104
+ ellipsis = A::ELLIPSIS
105
+ budget = [max_chars - display_columns(ellipsis), 0].max
106
+ # Walk characters accumulating DISPLAY columns, not a raw character index: the row-level
107
+ # backstop calls this on an ALREADY-ASSEMBLED row that can carry several prior per-cell
108
+ # ellipses (each one two display columns for one character), so a plain `t[0, limit]`
109
+ # character slice can under-cut and still land over budget once its own wide characters
110
+ # are counted.
111
+ cols = 0
112
+ cut_at = 0
113
+ t.each_char do |c|
114
+ w = c.ord >= WIDE_CODEPOINT_MIN ? 2 : 1
115
+ break if cols + w > budget
116
+ cols += w
117
+ cut_at += 1
118
+ end
119
+ slice = t[0, cut_at]
120
+ cut = slice.rindex(/\s/)
121
+ slice = slice[0, cut] if cut && cut.positive?
122
+ slice = slice.rstrip
123
+ slice = slice.chomp(ellipsis) while slice.end_with?(ellipsis)
124
+ "#{slice}#{ellipsis}"
125
+ end
126
+
127
+ # Shrinks a row of column `widths` until their sum fits `budget`: the widest shrinkable
128
+ # column loses one column at a time, ties break toward the leftmost column, no column ever
129
+ # drops below its own `floors[i]` (FIT_COLUMN_FLOOR for every column when `floors` is
130
+ # omitted, the original behavior), and a column flagged in `bar_columns` (it carries a
131
+ # progress bar) is never touched regardless of its floor. The one shared shrink (intent
132
+ # 331f, finding 1; intent 331f1, S3): ReportScreen.fit_table_block's markdown row and
133
+ # ScreenPaint.paint_data_table's painted row both bound through this rather than carrying
134
+ # two loops that could drift apart. Returns a new array; the caller's own `widths` is left
135
+ # untouched.
136
+ def shrink_column_widths(widths, budget, bar_columns:, floors: nil)
137
+ floors ||= Array.new(widths.length, FIT_COLUMN_FLOOR)
138
+ widths = widths.dup
139
+ loop do
140
+ break if widths.sum <= budget
141
+ candidates = widths.each_index.select { |ci| !bar_columns[ci] && widths[ci] > floors[ci] }
142
+ break if candidates.empty?
143
+ target = candidates.max_by { |ci| [widths[ci], -ci] }
144
+ widths[target] -= 1
145
+ end
146
+ widths
147
+ end
148
+
149
+ # Intent 331f1, S3 (brief 4): the per-column floor a data-table shrink never crosses - a
150
+ # column never shrinks below its own header cell, below its natural width when that is at
151
+ # most 10 columns (the id case: an id column is already narrow, so "natural width" IS its
152
+ # floor and a uniform 8-column floor could still crush it), or below a bar cell (bar columns
153
+ # are handled separately, by `bar_columns:` above, but a caller may still pass a natural
154
+ # width here too - `[header_len, base].max` never fights that).
155
+ def column_floor(header_len, natural_width)
156
+ base = natural_width <= 10 ? natural_width : FIT_COLUMN_FLOOR
157
+ [header_len, base].max
158
+ end
159
+
160
+ # Intent 331f1 (post-execution review, findings P2/P3): a padded cell renders as its column
161
+ # width (characters, from `ljust`) plus that cell's own DISPLAY overage -
162
+ # `display_columns(cell) - cell.length` - because a progress-bar glyph or an already-embedded
163
+ # ellipsis costs more display columns than characters. Crediting overage only to columns
164
+ # flagged `bar_columns:` (the old rule) misses a padded cell that carries an ellipsis in an
165
+ # ordinary text column - exactly what ReportScreen.fit_row_cell hands roadmap_state's Intent
166
+ # column - so the assembled row can still land over the bound even though every column width
167
+ # was computed correctly. The real cost is per ROW, not per column: `rows_of_cells` is an
168
+ # array of rows, each an array of already-stripped, not-yet-padded/truncated cell strings, and
169
+ # this returns the worst row's total overage - the one number ScreenPaint.paint_data_table,
170
+ # ReportScreen.fit_table_block, and ReportScreen.fit_field_table_block all reserve out of their
171
+ # budget, so the three renderers spend one rule rather than three copies that can drift.
172
+ def row_display_overage(rows_of_cells)
173
+ rows_of_cells.map { |cells| cells.sum { |c| display_columns(c.to_s) - c.to_s.length } }.max || 0
174
+ end
175
+
176
+ # Registers a screen kind's opener grammar (a Regexp or a callable taking
177
+ # the stripped opener line and returning truthy/falsy), plus an optional
178
+ # `paint:` lambda for a kind that needs its own palette (`call(text,
179
+ # color:, width:, markdown_safe:)`). Idempotent by kind: registering the
180
+ # same kind again replaces its entry rather than adding a second one.
181
+ def register(kind, opener:, paint: nil)
182
+ @registry[kind.to_sym] = { opener: opener, paint: paint }
183
+ end
184
+
185
+ # Every registered kind's name, shipped and caller-added alike.
186
+ def kinds
187
+ @registry.keys
188
+ end
189
+
190
+ # The first registered kind whose opener matches `text` (an already
191
+ # stripped, single line), or nil.
192
+ def opener_kind(text)
193
+ @registry.find { |_, entry| opener_matches?(entry[:opener], text) }&.first
194
+ end
195
+
196
+ def opener_matches?(opener, text)
197
+ opener.respond_to?(:call) ? !!opener.call(text) : !!opener.match?(text)
198
+ end
199
+
35
200
  # The classifier both paint and region_end share. `idx`/`opener_idx` give
36
201
  # the positional rule its footing: the line right after a title is the meta
37
202
  # line (delivered/delay print one), recognizable by its " · " separators.
@@ -39,27 +204,39 @@ module ScreenPaint
39
204
  text = line.chomp
40
205
  stripped = text.strip
41
206
  return :blank if stripped.empty?
42
- return :opener if OPENER_RE.match?(stripped) && text == stripped
207
+ return :opener if text == stripped && !opener_kind(stripped).nil?
43
208
  return :table if text.lstrip.start_with?("|")
44
209
  return :bold if BOLD_LEAD_RE.match?(stripped) && text == stripped
45
210
  return :meta if idx && opener_idx && idx == opener_idx + 1 && stripped.include?(" · ")
211
+ return :meta if RESCUE_CARD_RE.match?(stripped)
46
212
  return :indented if text.start_with?(" ")
47
213
  return :field if FIELD_LINE_RE.match?(text)
48
214
  return :step if STEP_LINE_RE.match?(text)
49
215
  return :timeline if TIMELINE_RE.match?(text)
50
- return :count if COUNT_LINE_RE.match?(stripped)
51
- return :closer if ["None", "not recorded", "No intents in delivery."].include?(stripped)
216
+ return :count if COUNT_LINE_RE.match?(stripped) || SKIP_NOTE_RE.match?(stripped)
217
+ return :closer if ["None", "not recorded", "No intents in delivery.", "No intents delivered in this session."].include?(stripped)
52
218
  :unknown
53
219
  end
54
220
 
55
221
  # Where the screen region ends inside a larger message (B10): walk from the
56
222
  # opener while every line classifies; the first unknown line - ordinary
57
223
  # prose, a prose bullet - is the boundary. Never consumes past the screen.
224
+ #
225
+ # 331a1: `opener_idx` tracks the NEAREST preceding opener, not the first
226
+ # one in the message. A reply can carry several screens back to back - the
227
+ # roster is a table then ten cards, the session report is many delivered
228
+ # screens - and `classify`'s positional rule is "the line right after a
229
+ # title", which means the title above it. Testing every line against the
230
+ # message's first opener made the timestamp under the SECOND screen
231
+ # :unknown, and the region stopped there: 30 lines of a 350-line session
232
+ # report painted, the rest reaching the terminal as plain Markdown.
58
233
  def region_end(lines, start_idx)
59
234
  i = start_idx + 1
235
+ opener_idx = start_idx
60
236
  while i < lines.length
61
- kind = classify(lines[i], idx: i, opener_idx: start_idx)
237
+ kind = classify(lines[i], idx: i, opener_idx: opener_idx)
62
238
  break if kind == :unknown
239
+ opener_idx = i if kind == :opener
63
240
  # A bare "**Section**" head belongs to the screen only when what follows
64
241
  # is still grammar; "**What this means**" over prose bullets is the
65
242
  # model's own commentary and stays outside, unsplit (B10).
@@ -73,6 +250,22 @@ module ScreenPaint
73
250
  i
74
251
  end
75
252
 
253
+ # The first line the grammar rejects, walking from the opener under the same
254
+ # nearest-opener rule region_end uses. Returns [index, line] or nil when the
255
+ # whole run classifies. 331a1: this is what the opt-in hook trace reports,
256
+ # so a live run says which line stopped the region instead of leaving it to
257
+ # be guessed from a terminal capture.
258
+ def first_rejected(lines, start_idx)
259
+ opener_idx = start_idx
260
+ ((start_idx + 1)...lines.length).each do |i|
261
+ kind = classify(lines[i], idx: i, opener_idx: opener_idx)
262
+ return [i, lines[i].to_s.rstrip] if kind == :unknown
263
+
264
+ opener_idx = i if kind == :opener
265
+ end
266
+ nil
267
+ end
268
+
76
269
  def bare_bold?(line)
77
270
  m = BOLD_LEAD_RE.match(line.strip)
78
271
  m && m[2].to_s.strip.empty?
@@ -93,11 +286,25 @@ module ScreenPaint
93
286
  lines = text.to_s.lines
94
287
  first_idx = lines.index { |l| !l.strip.empty? }
95
288
  return nil if first_idx.nil?
96
- return nil unless classify(lines[first_idx]) == :opener
289
+
290
+ kind = opener_kind(lines[first_idx].strip)
291
+ # Intent 330 (D7/O3.26): a screen that is nothing but a single known
292
+ # closer line (e.g. "No intents delivered in this session.") has no
293
+ # opener to require - it is already the whole, honest message.
294
+ return nil unless kind || classify(lines[first_idx]) == :closer
295
+
296
+ # Intent 331a (D6): a registered kind MAY supply its own paint lambda;
297
+ # when it does, this whole call delegates to it instead of the shared
298
+ # pipeline below. No shipped kind does.
299
+ if kind
300
+ custom = @registry[kind][:paint]
301
+ return custom.call(text, color: color, width: width, markdown_safe: markdown_safe) if custom
302
+ end
97
303
 
98
304
  out = +""
99
305
  table = []
100
306
  ok = true
307
+ indent_run = 0
101
308
 
102
309
  flush = lambda do
103
310
  next if table.empty?
@@ -105,8 +312,19 @@ module ScreenPaint
105
312
  table.clear
106
313
  end
107
314
 
315
+ # 331a1: the same nearest-opener rule region_end uses - a reply carrying
316
+ # several screens must classify each one's meta line against its own
317
+ # title, not against the first title in the message.
318
+ opener_idx = first_idx
319
+
108
320
  lines.each_with_index do |line, idx|
109
- kind = classify(line, idx: idx, opener_idx: first_idx)
321
+ kind = classify(line, idx: idx, opener_idx: opener_idx)
322
+ opener_idx = idx if kind == :opener
323
+ # Intent 317a1 (O6, D8): the run counter tracks consecutive :indented
324
+ # lines so only the SECOND and later lines under a heading like
325
+ # "**Asked**" grey as a note; a :table line (which `next`s below) must
326
+ # reset it too, so a later unrelated indented block starts fresh.
327
+ indent_run = kind == :indented ? indent_run + 1 : 0
110
328
  if kind == :table
111
329
  table << line.strip
112
330
  next
@@ -123,7 +341,8 @@ module ScreenPaint
123
341
  head = A.styled(clean(m[1], markdown_safe), color, A::BOLD, A::NEARWHITE)
124
342
  out << head << clean(m[2], markdown_safe) << "\n"
125
343
  when :indented
126
- out << A.fit_plain(clean(line.chomp, markdown_safe), width) << "\n"
344
+ text = A.fit_plain(clean(line.chomp, markdown_safe), width)
345
+ out << (indent_run > 1 ? A.styled(text, color, A::MIDGREY) : text) << "\n"
127
346
  when :field
128
347
  m = FIELD_LINE_RE.match(line.chomp)
129
348
  out << A.styled(m[1].ljust(8), color, A::BOLD) << " " << clean(m[3], markdown_safe) << "\n"
@@ -132,9 +351,16 @@ module ScreenPaint
132
351
  badge = A.status_cell(m[2] == "done", color)
133
352
  out << "#{m[1].ljust(4)} [#{badge}] #{A.fit_plain(clean(m[3], markdown_safe), width - 12)}\n"
134
353
  when :timeline
354
+ # Intent 317a1 (O5, D6, D7): the time carries no escape at all; the
355
+ # kind label carries the color - amber for the review turning point,
356
+ # teal for a landed commit, mid-grey (no bold) otherwise.
135
357
  m = TIMELINE_RE.match(line.chomp)
136
- out << A.styled(m[1], color, A::MIDGREY) << " " << A.styled(m[2].ljust(6), color, A::BOLD) \
137
- << " " << clean(m[3], markdown_safe) << "\n"
358
+ codes = case m[2]
359
+ when "Review" then [A::BOLD, A::AMBER]
360
+ when "Commit" then [A::BOLD, A::TEAL]
361
+ else [A::MIDGREY]
362
+ end
363
+ out << m[1] << " " << A.styled(m[2].ljust(6), color, *codes) << " " << clean(m[3], markdown_safe) << "\n"
138
364
  when :count
139
365
  out << A.fit(line.strip, width) { |s| A.styled(s, color, A::MIDGREY) } << "\n"
140
366
  when :closer
@@ -160,43 +386,137 @@ module ScreenPaint
160
386
  row.split("|", -1).map(&:strip)[1..-2].to_a
161
387
  end
162
388
 
389
+ # Intent 331f1 (finding A3): the ONE classifier, called by both this painter and
390
+ # ReportScreen.fit_table_block's field-vs-data dispatch, so a table is never fitted by one
391
+ # rule and painted by another. A block is a field table when EVERY non-separator,
392
+ # non-blank-scaffold row's first cell is bold - not just the first row (the old rule):
393
+ # a data table whose header cell happens to be bold (e.g. "**Kind**") still has ordinary,
394
+ # unbold data rows underneath it, so it stays a data table. A block with nothing left after
395
+ # stripping separators/scaffolding classifies as neither (false).
163
396
  def field_table?(rows)
164
- rows.first&.gsub(/[\s|]/, "") == "" || cells_of(rows.first).first.to_s.start_with?("**")
397
+ candidates = rows.reject { |r| SEPARATOR_RE.match?(r) || r.gsub(/[\s|]/, "").empty? }
398
+ return false if candidates.empty?
399
+ candidates.all? { |r| cells_of(r).first.to_s.start_with?("**") }
165
400
  end
166
401
 
167
402
  # A field table ("| | | |" scaffold, "| **Key** | value | note |" rows)
168
- # re-lays as the intent screen's vertical field block: bold key, value,
169
- # mid-grey note on its own line. A data table re-lays as padded columns
170
- # with a bold header, done/open cells colored, no pipes anywhere.
403
+ # re-lays as the intent screen's three-column field block: bold key,
404
+ # value, and a mid-grey note that shares the line when it fits, dropping
405
+ # to its own line only as a fallback (D9-D11). A data table re-lays as
406
+ # padded columns with a bold header, done/open cells colored, no pipes
407
+ # anywhere.
171
408
  def paint_table(rows, color:, width:, markdown_safe:)
172
409
  rows = rows.reject { |r| SEPARATOR_RE.match?(r) || r.gsub(/[\s|]/, "").empty? }
173
410
  return "" if rows.empty?
174
411
 
175
- if cells_of(rows.first).first.to_s.start_with?("**")
176
- out = +""
177
- key_w = rows.map { |r| cells_of(r).first.to_s.gsub("*", "").length }.max
178
- rows.each do |row|
179
- key, value, note = cells_of(row)
180
- key = key.to_s.gsub("*", "")
181
- out << " #{A.styled(key.ljust(key_w), color, A::BOLD)} #{clean(value.to_s, markdown_safe)}\n"
182
- next if note.to_s.empty?
183
- out << (" " * (key_w + 4)) << A.fit(clean(note, markdown_safe), width - key_w - 4) { |s| A.styled(s, color, A::MIDGREY) } << "\n"
184
- end
185
- return out
412
+ if field_table?(rows)
413
+ return paint_field_table(rows, color: color, width: width, markdown_safe: markdown_safe)
186
414
  end
187
415
 
416
+ paint_data_table(rows, color: color, width: width, markdown_safe: markdown_safe)
417
+ end
418
+
419
+ # Intent 317a1 (O3, D9-D11, D14, D15): the same three-column geometry as
420
+ # IntentScreenAnsi.render's field-row loop, on the SAME implementation
421
+ # (D12) - `IntentScreenAnsi.field_table_lines` - so the two renderers
422
+ # cannot drift apart. Notes arrive as plain text here, so `visible_width`
423
+ # equals `length`, but the shared helper is used anyway so both renderers
424
+ # read identically.
425
+ def paint_field_table(rows, color:, width:, markdown_safe:)
426
+ key_w = rows.map { |r| cells_of(r).first.to_s.gsub("*", "").length }.max
427
+ field_rows = rows.map do |row|
428
+ key, value, note = cells_of(row)
429
+ key = key.to_s.gsub("*", "")
430
+ [key, clean(value.to_s, markdown_safe), clean(note.to_s, markdown_safe)]
431
+ end
432
+ A.field_table_lines(field_rows, width: width, color: color, key_width: key_w)
433
+ end
434
+
435
+ # Intent 317a1 (O4, D3-D5): a data table's kind and note columns are chosen
436
+ # by the header, never by position; a cell whose text is exactly "not
437
+ # recorded" greys wherever it appears. `widths[ci] || 0` (rather than a
438
+ # bare `widths[ci]`) is the ragged-row guard: `ReportScreen.escape` writes
439
+ # `\|` while `cells_of` still splits on every `|`, so a row can carry more
440
+ # cells than its header without either side ever raising.
441
+ #
442
+ # Intent 331f (finding 1, post-exec review): padding every cell to its column's max across
443
+ # ALL rows can paint a row wider than `width` even when every plain row on its own measured
444
+ # under the bound (D7 is on the RENDERED row) - a row short in one column but maximal in
445
+ # another paints wider than its own plain row ever was. `width` bounds the column widths the
446
+ # same way ReportScreen.fit_table_block bounds a markdown row's, through the one shared
447
+ # shrink, before any cell is padded or joined.
448
+ def paint_data_table(rows, color:, width:, markdown_safe:)
188
449
  grid = rows.map { |r| cells_of(r).map { |c| clean(c, markdown_safe) } }
189
- widths = grid.first.each_index.map { |i| grid.map { |r| r[i].to_s.length }.max }
450
+ ncols = grid.first.length
451
+ widths = (0...ncols).map { |i| grid.map { |r| r[i].to_s.length }.max }
452
+ bar_columns = (0...ncols).map { |i| grid.any? { |r| r[i].to_s =~ PROGRESS_BAR_CHARS_RE } }
453
+ # Intent 331f1 (post-exec review, P2/P3): the shared row-overage rule, not a per-column bar
454
+ # credit - see ScreenPaint.row_display_overage's own comment for why the bar-only credit
455
+ # missed a padded cell carrying an ellipsis.
456
+ overage = row_display_overage(grid)
457
+ # Intent 331f1, S3 (brief 4): per-column minimums - never below the header cell, never
458
+ # below a natural width of 10 or less (the id case).
459
+ header_len = (0...ncols).map { |i| grid.first[i].to_s.length }
460
+ floors = (0...ncols).map { |i| bar_columns[i] ? widths[i] : column_floor(header_len[i], widths[i]) }
461
+ budget = width - (2 + 2 * (ncols - 1)) - overage
462
+ widths = shrink_column_widths(widths, budget, bar_columns: bar_columns, floors: floors)
463
+ kind_col = grid.first.first == "Kind" ? 0 : nil
464
+ note_col = grid.first.index { |h| NOTE_HEADERS.include?(h) }
465
+
190
466
  out = +""
191
467
  grid.each_with_index do |cols, ri|
192
- cells = cols.each_with_index.map do |cell, ci|
193
- padded = cell.to_s.ljust(widths[ci])
468
+ last_ci = cols.length - 1
469
+ texts = cols.each_with_index.map do |cell, ci|
470
+ text = cell.to_s
471
+ w = widths[ci]
472
+ text = truncate_on_word_boundary(text, w) if w && text.length > w
473
+ ci == last_ci ? text : text.ljust(w || 0)
474
+ end
475
+
476
+ # Intent 331f1 (post-exec review, P4): the row-level backstop. Even with every column
477
+ # pinned at its floor, the assembled PLAIN row can still exceed `width` once padding/bar/
478
+ # ellipsis overage is counted - shrink the widest SHRINKABLE cell's PLAIN TEXT (never a
479
+ # bar column, and always before styling, since truncating an already-styled string cuts
480
+ # ANSI escapes mid-sequence) until the row fits or nothing is left to shrink.
481
+ loop do
482
+ row_dw = display_columns((" " + texts.join(" ")).rstrip)
483
+ break if row_dw <= width
484
+ excess = row_dw - width
485
+ shrinkable = (0...ncols).reject { |ci| bar_columns[ci] }
486
+ break if shrinkable.empty?
487
+ target = shrinkable.max_by { |ci| display_columns(texts[ci]) }
488
+ cur_dw = display_columns(texts[target])
489
+ # A bare ellipsis alone already costs 2 display columns; below that floor there is
490
+ # nothing left to cut. Also bail the moment a cut makes no progress (a text already at
491
+ # or under the ellipsis floor re-truncates to the same "…" forever) - a row this wide
492
+ # even at every floor is the documented extreme case, not an infinite loop.
493
+ break if cur_dw <= 2
494
+ shrunk = truncate_on_word_boundary(texts[target].rstrip, [cur_dw - excess, 2].max)
495
+ break if display_columns(shrunk) >= cur_dw
496
+ texts[target] = shrunk
497
+ end
498
+
499
+ cells = texts.each_with_index.map do |padded, ci|
500
+ cell = cols[ci]
501
+ # An empty cell never gets styled (317a1 post-exec review, finding
502
+ # 1): `A.styled("", ...)` still emits a color-open/RESET pair around
503
+ # nothing visible, and that hides the join separator's own trailing
504
+ # spaces from `.rstrip` below, on the very line the reader sees.
505
+ next padded if cell.to_s.empty?
194
506
  if ri.zero?
195
507
  A.styled(padded, color, A::BOLD)
508
+ elsif ci == kind_col && EVIDENCE_PROOF_KINDS.include?(cell)
509
+ A.styled(padded, color, A::TEAL, A::BOLD)
510
+ elsif ci == kind_col && EVIDENCE_DEVIATION_KINDS.include?(cell)
511
+ A.styled(padded, color, A::AMBER, A::BOLD)
512
+ elsif ci == note_col
513
+ A.styled(padded, color, A::MIDGREY)
196
514
  elsif cell == "done"
197
515
  A.styled(padded, color, A::TEAL)
198
516
  elsif cell == "open"
199
517
  A.styled(padded, color, A::AMBER)
518
+ elsif cell == NOT_RECORDED
519
+ A.styled(padded, color, A::MIDGREY)
200
520
  else
201
521
  padded
202
522
  end
@@ -215,4 +535,21 @@ module ScreenPaint
215
535
  def clean(text, markdown_safe)
216
536
  markdown_safe ? A.clean(text) : text
217
537
  end
538
+
539
+ # --- the five shipped kinds (intent 331a, D6) -----------------------------
540
+ #
541
+ # Each opener is a strict subset of OPENER_RE, decomposed by shape rather
542
+ # than by any per-kind palette: `intent`/`state` share the exact template
543
+ # line (templates/intent-screen.md and templates/report-state.md both open
544
+ # with "## ▶ {{id}} · {{name}}"); `delivered` narrows to the "## ✔ id ·
545
+ # name · delivered" shape report_screen.rb emits; `roster` and `delay`
546
+ # cover every bare (no "## ") glyph line, which is exactly ScreenPaint's
547
+ # original, single OPENER_RE decomposed into its "## "-prefixed half and
548
+ # its bare half - the union is unchanged, so no existing screen stops
549
+ # being recognized.
550
+ register(:intent, opener: /\A## [▶✔] .+ · /)
551
+ register(:state, opener: /\A## [▶✔] .+ · /)
552
+ register(:delivered, opener: /\A## ✔ .+ · /)
553
+ register(:roster, opener: /\A▶ .+ · /)
554
+ register(:delay, opener: /\A✔ .+ · /)
218
555
  end
@@ -0,0 +1,20 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../screen_paint"
5
+
6
+ # The dashboard screen kind (intent 331d, D4). Its opener is a stricter
7
+ # subset of the already-registered :intent opener (screen_paint.rb:337,
8
+ # registered first), so ScreenPaint.opener_kind never actually answers
9
+ # :dashboard on a live paint call - that is deliberate (R2): :dashboard is
10
+ # tested on its OWN grammar (the OPENER constant below), never on
11
+ # opener_kind's answer, which cannot fail meaningfully here. No custom paint
12
+ # lambda (R3): every line of the screen classifies under the shared
13
+ # field-table/data-table grammar ScreenPaint.paint already carries.
14
+ module Screens
15
+ module Dashboard
16
+ OPENER = /\A## ▶ (?:global|project:[a-z0-9][a-z0-9_-]*) · dashboard\z/.freeze
17
+ end
18
+ end
19
+
20
+ ScreenPaint.register(:dashboard, opener: Screens::Dashboard::OPENER)
@@ -0,0 +1,18 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../screen_paint"
5
+
6
+ # Intent 331b (D2/D4): the plan screen's grammar - "## ▶ {id} · {name} ·
7
+ # plan" - registers through 331a's kind registry, a file under
8
+ # scripts/lib/screens/ rather than a diff to screen_paint.rb. F1 (spec.md):
9
+ # opener_kind answers the FIRST registered match, and :intent's own opener
10
+ # (/\A## [▶✔] .+ · /) already matches this title, so this kind's opener is
11
+ # never actually reached - it still names the kind's home and its true
12
+ # grammar, and that is fine, because paint: nil is what D4's "with the state
13
+ # palette" asks for anyway: the shared pipeline branches on LINE SHAPE, never
14
+ # on kind, and no shipped kind carries its own palette. Narrowing :intent's
15
+ # opener from this file to reach a :plan-specific paint lambda is exactly
16
+ # the forbidden loophole spec.md's F1 names - it would silently un-paint the
17
+ # intent and state screens for every other caller, so it never happens here.
18
+ ScreenPaint.register(:plan, opener: /\A## ▶ .+ · plan\z/, paint: nil)
@@ -0,0 +1,15 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../screen_paint"
5
+
6
+ # Intent 331c (D7): the roadmap screen kinds. A new screen kind is a file, never a diff to
7
+ # screen_paint.rb (331a's registry, D6): registers `:roadmap_plan`, `:roadmap_state`, and
8
+ # `:roadmap_delivered`, openers that are strict subsets of the shipped intent/delivered openers
9
+ # (`## ▶ ... · roadmap · plan`, `## ▶ ... · roadmap`, `## ✔ ... · roadmap · delivered`), exactly
10
+ # as 331a's five shipped kinds already overlap each other. No `paint:` lambda is supplied: the
11
+ # palette stays IntentScreenAnsi's shared pipeline (the 318 ceiling) - screen_paint.rb's body is
12
+ # not touched by this file.
13
+ ScreenPaint.register(:roadmap_plan, opener: /\A## ▶ .+ · roadmap · plan\z/)
14
+ ScreenPaint.register(:roadmap_state, opener: /\A## ▶ .+ · roadmap\z/)
15
+ ScreenPaint.register(:roadmap_delivered, opener: /\A## ✔ .+ · roadmap · delivered\z/)
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "sqlite"
5
+
6
+ module Plastic
7
+ module SearchIndex
8
+ NAME = "knowledge_graph.db"
9
+ SCHEMA = <<~SQL
10
+ CREATE TABLE doc(id INTEGER PRIMARY KEY, path TEXT, sz INT, data BLOB, hash TEXT);
11
+ CREATE VIEW doc_v AS SELECT id AS rowid, CAST(sqlar_uncompress(data, sz) AS TEXT) AS body FROM doc;
12
+ CREATE VIRTUAL TABLE ft USING fts5(body, content='doc_v', content_rowid='rowid');
13
+ SQL
14
+ FINISH = "INSERT INTO ft(ft) VALUES('rebuild'); INSERT INTO ft(ft) VALUES('optimize'); VACUUM;"
15
+
16
+ def self.path(home)
17
+ File.join(home, NAME)
18
+ end
19
+
20
+ def self.files(home)
21
+ Dir.glob("{store,projects,stores}/**/*.md", File::FNM_DOTMATCH, base: home).reject { |file| file.include?("/.git/") }.sort
22
+ end
23
+
24
+ def self.build(home)
25
+ files = files(home)
26
+ draft = "#{path(home)}.tmp"
27
+ FileUtils.rm_f(draft)
28
+ Sqlite.call(draft, [SCHEMA, "BEGIN;", *files.map { |file| insert(home, file) }, "COMMIT;", FINISH].join("\n"))
29
+ File.rename(draft, path(home))
30
+ files.size
31
+ ensure
32
+ FileUtils.rm_f(draft.to_s)
33
+ end
34
+
35
+ def self.insert(home, file)
36
+ source = quote(File.join(home, file))
37
+ "INSERT INTO doc(path, sz, data, hash) VALUES(#{quote(file)}, length(readfile(#{source})), " \
38
+ "sqlar_compress(readfile(#{source})), lower(hex(sha3(readfile(#{source}), 256))));"
39
+ end
40
+
41
+ def self.search(home, terms, limit:, prefix: "")
42
+ phrases = terms.map { |term| %("#{term.delete('"')}") }.join(" ")
43
+ Sqlite.call(path(home), <<~SQL, readonly: true)
44
+ SELECT doc.id AS id, doc.path AS path, snippet(ft, 0, '[', ']', ' ... ', 12) AS excerpt
45
+ FROM ft JOIN doc ON doc.id = ft.rowid
46
+ WHERE ft MATCH #{quote(phrases)} AND doc.path LIKE #{quote("#{prefix}%")}
47
+ ORDER BY rank LIMIT #{Integer(limit)};
48
+ SQL
49
+ end
50
+
51
+ def self.quote(text)
52
+ Sqlite.quote(text)
53
+ end
54
+ end
55
+ end