@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
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "fileutils"
5
+ require "json"
5
6
  require_relative "screen_paint"
6
7
 
7
8
  # MessageDisplay (intent 316a, O4/O5, round 3 concurrency fix) - the Claude
@@ -46,25 +47,87 @@ require_relative "screen_paint"
46
47
  # buffered or blanked. D10 (any failure while finalizing returns the
47
48
  # buffered original, never nil, never "") and D12 (color: false never
48
49
  # buffers or blanks anything) are unchanged.
50
+ #
51
+ # Intent 331a: engagement is late-capable. A chunk carrying a screen opener
52
+ # engages the message from that chunk on, whatever its own index - not only
53
+ # chunk 0. Chunks before it pass through untouched (they already reached the
54
+ # terminal live, via the ordinary passthrough path). The engaging chunk
55
+ # returns the text before the opener as its displayContent and buffers the
56
+ # opener onward at its OWN index; SCREEN now carries that index (a decimal
57
+ # integer, not an empty marker) so the final chunk - a separate process in
58
+ # production - knows where to start waiting and splicing (D3/D6), and so
59
+ # NOSCREEN, no longer a final answer (D2), can be replaced once a later
60
+ # chunk engages. A fence line immediately wrapping the opener is dropped
61
+ # (D4): a lone fence in the engaging chunk's own prefix, and a lone closing
62
+ # fence right after the painted region in `finalize`. Neither ever reaches
63
+ # back into an earlier, already-displayed chunk.
64
+ #
65
+ # Intent 331a1: the decision marker (D1-D3). 331a's own comment above already
66
+ # names the concurrency; what it did not close is chunk 0's own boot time.
67
+ # Chunk 0's Ruby process takes on the order of 150 ms to boot before it ever
68
+ # writes SCREEN or NOSCREEN - long enough, under a fast real stream, for a
69
+ # dozen or more later chunks to be judged with nothing on disk at all, so
70
+ # every one of them fell back to the cheap shape test and, being ordinary
71
+ # non-table prose, passed straight through plain. The bash launcher (hooks/
72
+ # message-display) now stakes a PENDING file with builtins the instant
73
+ # chunk 0 is handed off, before Ruby ever starts, so a later chunk finds the
74
+ # message directory within microseconds instead of after Ruby's own boot.
75
+ # While PENDING exists, a later chunk polls for the real decision WHATEVER
76
+ # ITS OWN SHAPE looks like - `maybe_screen?` is not consulted at all, because
77
+ # a decision is certainly coming, and the cheap shape gate exists only for
78
+ # the "nothing at all exists yet, is a wait even worth paying for" case,
79
+ # which no longer applies once something IS on disk. A PENDING whose mtime
80
+ # is already older than THIS chunk's own poll budget reads as NOSCREEN (D2,
81
+ # fail open): chunk 0 must have died or hung, and waiting out a whole budget
82
+ # for a decision that is provably not coming would only delay every chunk
83
+ # behind it. That staleness check runs ONCE, before any polling, since a
84
+ # file's mtime never changes while this process looks at it. The poll
85
+ # budget itself scales with the chunk's own index (`budget_ms`, D3): base
86
+ # wait_ms plus index_wait_ms per index, capped at max_wait_ms, so the final
87
+ # chunk of a long streamed message (335 chunks, in the live capture that
88
+ # reproduced this) is allowed to wait for a decision that is certainly on
89
+ # its way, while chunk 1 of an ordinary short message still fails open
90
+ # quickly. Chunk 0 removes PENDING the moment it writes SCREEN or NOSCREEN
91
+ # (`write_screen`/`write_noscreen`), on both paths, so "a decision already
92
+ # exists" and "PENDING is still there" are never both true for long.
49
93
  class MessageDisplay
50
94
  # 317a (A4): engagement is grammar, not identity - any screen-family
51
95
  # opener engages, with NO intent-id resolution (the roster and delay
52
- # screens have none to resolve). ScreenPaint owns the full grammar.
96
+ # screens have none to resolve). ScreenPaint owns the full grammar. Used
97
+ # per LINE (331a), not only against the start of a whole delta: a chunk's
98
+ # own text is scanned line by line for the first line that opens a screen,
99
+ # wherever it falls.
53
100
  ENGAGE_RE = /\A(?:##? )?[▶✔] /.freeze
101
+ # 331a (D4): a lone fence line, opening (optional info string) or closing
102
+ # (never one), by itself on its own line.
103
+ FENCE_OPEN_RE = /\A```[^\n]*\z/.freeze
104
+ FENCE_CLOSE_RE = /\A```\z/.freeze
54
105
  BUFFER_DIR_NAME = "plastic-message-display"
55
106
  BUFFER_MAX_AGE_SECONDS = 3600
56
107
  SCREEN_FILE = "SCREEN"
57
108
  NOSCREEN_FILE = "NOSCREEN"
58
-
109
+ # 331a1 (D1): staked by the bash launcher, with builtins, the instant
110
+ # chunk 0 is handed off - before Ruby ever boots. Replaced by SCREEN or
111
+ # NOSCREEN (D2), never read by this class as a decision in its own right.
112
+ PENDING_FILE = "PENDING"
113
+
114
+ # `trace` (331a1): an optional callable taking one Hash per chunk. The class
115
+ # stays pure - it never reads PLASTIC_HOOK_TRACE, never opens a file of its
116
+ # own; the CLI reads the variable and injects `file_trace`. nil, the
117
+ # default, costs the common path one `unless` and nothing else.
59
118
  def initialize(tmp_root:, plastic_home:, color:, now:, wait_ms: 300, poll_ms: 20,
60
- sleeper: ->(seconds) { sleep(seconds) })
119
+ index_wait_ms: 20, max_wait_ms: 2000,
120
+ sleeper: ->(seconds) { sleep(seconds) }, trace: nil)
61
121
  @tmp_root = tmp_root
62
122
  @plastic_home = plastic_home
63
123
  @color = color
64
124
  @now = now
65
125
  @wait_ms = wait_ms
66
126
  @poll_ms = poll_ms
127
+ @index_wait_ms = index_wait_ms
128
+ @max_wait_ms = max_wait_ms
67
129
  @sleeper = sleeper
130
+ @trace = trace
68
131
  end
69
132
 
70
133
  def handle(payload)
@@ -84,11 +147,28 @@ class MessageDisplay
84
147
 
85
148
  dir = self.class.buffer_path(tmp_root: @tmp_root, session_id: session_id, message_id: message_id)
86
149
 
87
- if index == 0
88
- handle_chunk_zero(dir, delta, cwd, final)
89
- else
90
- handle_later_chunk(dir, index, delta, final)
91
- end
150
+ @trace_detail = {}
151
+ result =
152
+ if index == 0
153
+ handle_chunk_zero(dir, delta, cwd, final)
154
+ else
155
+ handle_later_chunk(dir, index, delta, final)
156
+ end
157
+
158
+ emit_trace(index, final, result)
159
+ result
160
+ end
161
+
162
+ # One row per chunk, only when a sink was injected. Never raises: a trace
163
+ # is a diagnostic and must not be able to change what the hook returns.
164
+ def emit_trace(index, final, result)
165
+ return unless @trace
166
+
167
+ row = { "index" => index, "final" => final,
168
+ "displayed_bytes" => result.is_a?(String) ? result.bytesize : nil }
169
+ @trace.call(row.merge(@trace_detail.to_h))
170
+ rescue StandardError
171
+ nil
92
172
  end
93
173
 
94
174
  # The message directory both this class and the bash launcher (hooks/
@@ -107,35 +187,73 @@ class MessageDisplay
107
187
  File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), SCREEN_FILE)
108
188
  end
109
189
 
190
+ # A trace sink that appends one JSON object per chunk to `path`. Every
191
+ # failure is swallowed: an unwritable path must never turn a diagnostic
192
+ # into a broken hook (D5, fail open).
193
+ def self.file_trace(path)
194
+ lambda do |row|
195
+ File.open(path, "a") { |f| f.puts(JSON.generate(row)) }
196
+ rescue StandardError
197
+ nil
198
+ end
199
+ end
200
+
110
201
  def self.noscreen_path(tmp_root:, session_id:, message_id:)
111
202
  File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), NOSCREEN_FILE)
112
203
  end
113
204
 
205
+ # 331a1 (matrix L1): the bash launcher (hooks/message-display) and this
206
+ # class must agree, byte for byte, on where PENDING lives - the same
207
+ # contract `buffer_path` already carries for SCREEN/NOSCREEN (matrix 40).
208
+ def self.pending_path(tmp_root:, session_id:, message_id:)
209
+ File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), PENDING_FILE)
210
+ end
211
+
114
212
  private
115
213
 
116
214
  # Chunk 0 decides, synchronously, before anything else touches this
117
- # message: recognize the marker (after leading whitespace only) AND
118
- # resolve the id, both before anything is buffered or blanked (F4). Either
119
- # failure writes NOSCREEN so every later chunk can decide instantly rather
120
- # than waiting out its own budget for a decision that will never arrive.
215
+ # message: does ITS OWN delta carry an opener anywhere (331a; used to be
216
+ # only at the very start)? No opener writes NOSCREEN so every later chunk
217
+ # can decide instantly rather than waiting out its own budget for a
218
+ # decision that will never arrive - but NOSCREEN is no longer final (D2):
219
+ # a later chunk carrying an opener still replaces it.
121
220
  def handle_chunk_zero(dir, delta, _cwd, final)
122
- stripped = delta.sub(/\A[ \t]+/, "")
123
- unless ENGAGE_RE.match?(stripped)
221
+ split = split_at_opener(delta)
222
+ unless split
223
+ @trace_detail["decision"] = "noscreen"
124
224
  write_noscreen(dir)
125
225
  return nil
126
226
  end
127
227
 
128
- write_screen(dir)
129
- write_chunk(dir, 0, delta)
130
- final ? finalize_final(dir, 0) : ""
228
+ @trace_detail["decision"] = "engage"
229
+ engage(dir, 0, split, final)
131
230
  end
132
231
 
133
- # A later chunk (index > 0) never redoes chunk 0's work: it only asks
134
- # whether a decision already exists, waiting for one (bounded) when it
135
- # does not and the chunk looks like it could matter. The final chunk
136
- # always waits for the decision regardless of its own shape.
232
+ # A later chunk (index > 0) tests its OWN delta for an opener FIRST,
233
+ # before consulting any existing decision (331a, D2): an opener engages
234
+ # the message whatever the current decision says, including when NOSCREEN
235
+ # is already on disk. Only once its own delta carries no opener does it
236
+ # fall back to the original decision-driven wait.
137
237
  def handle_later_chunk(dir, index, delta, final)
138
- decision = wait_for_decision(dir, gate_delta: final ? nil : delta)
238
+ # 331a1: an opener engages only a message that is NOT already a screen.
239
+ # 331a's late engagement exists for the prose-first reply, where chunk 0
240
+ # wrote NOSCREEN and a later chunk carries the title; it must not fire
241
+ # again once SCREEN is on disk. A reply can hold several screens back to
242
+ # back - the roster is a table then ten cards, the session report is many
243
+ # delivered screens - and re-engaging on each one returned that chunk's
244
+ # own prefix as raw Markdown and rewrote the start index, so the final
245
+ # chunk spliced from the LAST opener and everything before it reached the
246
+ # terminal unpainted. Inside an engaged message a later opener is simply
247
+ # content: it is buffered like any other line and the painter, which
248
+ # already understands a run of screens, lays all of them out.
249
+ split = split_at_opener(delta)
250
+ if split && !engaged?(dir)
251
+ @trace_detail["decision"] = "engage"
252
+ return engage(dir, index, split, final)
253
+ end
254
+
255
+ decision = wait_for_decision(dir, gate_delta: final ? nil : delta, index: index)
256
+ @trace_detail["decision"] = decision.to_s
139
257
 
140
258
  return nil unless decision == :screen
141
259
 
@@ -143,17 +261,83 @@ class MessageDisplay
143
261
  final ? finalize_final(dir, index) : ""
144
262
  end
145
263
 
146
- # Checks for an existing decision first (free) and only pays the cheap
147
- # shape test, then the bounded poll, when neither SCREEN nor NOSCREEN is
148
- # there yet. `gate_delta: nil` (the final chunk) skips the shape test
149
- # entirely and always polls for the decision.
150
- def wait_for_decision(dir, gate_delta:)
264
+ def engaged?(dir)
265
+ File.exist?(File.join(dir, SCREEN_FILE))
266
+ end
267
+
268
+ # Engages the message starting at THIS chunk (whatever its index): writes
269
+ # SCREEN carrying this chunk's index (replacing any NOSCREEN, D2/D6),
270
+ # buffers the opener onward at this chunk's own index, and returns the
271
+ # text before the opener (fence-stripped, D4) as the displayContent. When
272
+ # this chunk is also final, the prefix is prepended to whatever `finalize`
273
+ # produces (painted, or the buffered original on a fail-open) rather than
274
+ # dropped.
275
+ def engage(dir, index, split, final)
276
+ prefix, rest = split
277
+ prefix = strip_preceding_fence(prefix)
278
+ write_screen(dir, index)
279
+ remove_noscreen(dir)
280
+ write_chunk(dir, index, rest)
281
+
282
+ return prefix unless final
283
+
284
+ finalized = finalize_final(dir, index)
285
+ prefix.empty? ? finalized : "#{prefix}#{finalized}"
286
+ end
287
+
288
+ # Scans `delta` line by line for the first line that opens a screen
289
+ # (331a: an opener can fall anywhere in a chunk's own delta, not only at
290
+ # its start). Returns [prefix, rest] - the text before that line, and the
291
+ # line onward - or nil when no line in this delta engages.
292
+ def split_at_opener(delta)
293
+ lines = delta.each_line.to_a
294
+ idx = lines.index { |line| ENGAGE_RE.match?(line.sub(/\A[ \t]+/, "")) }
295
+ return nil unless idx
296
+
297
+ [lines[0...idx].join, lines[idx..].join]
298
+ end
299
+
300
+ # 331a (D4): a lone fence line immediately preceding the opener, inside
301
+ # THIS SAME chunk's own prefix, is dropped - it never reaches the screen
302
+ # (it would otherwise print directly above the painted block). A fence in
303
+ # an earlier chunk is never touched: it was already displayed, verbatim,
304
+ # by that earlier chunk's own return value.
305
+ def strip_preceding_fence(prefix)
306
+ lines = prefix.each_line.to_a
307
+ return prefix if lines.empty?
308
+ return prefix unless FENCE_OPEN_RE.match?(lines.last.strip)
309
+
310
+ lines[0...-1].join
311
+ end
312
+
313
+ # Checks for an existing decision first (free). Then, 331a1 (D1/D2), the
314
+ # whole fix: when PENDING exists, a decision is certainly coming, so this
315
+ # chunk polls for it WHATEVER ITS OWN SHAPE looks like - `maybe_screen?`
316
+ # is never even consulted on this branch - unless PENDING is already
317
+ # stale (older than this chunk's own budget), which reads as NOSCREEN at
318
+ # once, fail open, without ever polling. Only when there is no PENDING
319
+ # AT ALL (chunk 0 has not even been handed off to the bash launcher yet,
320
+ # or this replay never wrote one) does today's original behavior apply:
321
+ # the cheap shape test gates whether a bounded poll is worth paying for.
322
+ # `gate_delta: nil` (the final chunk) skips that shape test entirely and
323
+ # always polls.
324
+ def wait_for_decision(dir, gate_delta:, index:)
151
325
  decision = read_decision_now(dir)
152
326
  return decision if decision
153
327
 
328
+ if pending?(dir)
329
+ return :noscreen if pending_stale?(dir, index)
330
+
331
+ return poll_for_decision(dir, index)
332
+ end
333
+
154
334
  return :timeout if gate_delta && !maybe_screen?(gate_delta)
155
335
 
156
- max_polls_for_budget.times do
336
+ poll_for_decision(dir, index)
337
+ end
338
+
339
+ def poll_for_decision(dir, index)
340
+ max_polls_for_budget(index).times do
157
341
  @sleeper.call(@poll_ms / 1000.0)
158
342
  decision = read_decision_now(dir)
159
343
  return decision if decision
@@ -169,6 +353,38 @@ class MessageDisplay
169
353
  nil
170
354
  end
171
355
 
356
+ def pending?(dir)
357
+ File.exist?(File.join(dir, PENDING_FILE))
358
+ end
359
+
360
+ # Checked ONCE, before any polling - a file's mtime never changes while
361
+ # this process is looking at it, so re-checking inside the poll loop
362
+ # would only ever repeat the same answer. Any error reading the mtime
363
+ # (a race where PENDING vanished between `pending?` and here, most
364
+ # likely because the real decision just landed) is NOT staleness: it
365
+ # falls through to the ordinary poll, which will pick up that decision
366
+ # on its very next read.
367
+ def pending_stale?(dir, index)
368
+ age_ms = (@now.to_f - File.mtime(File.join(dir, PENDING_FILE)).to_f) * 1000
369
+ age_ms > budget_ms(index)
370
+ rescue StandardError
371
+ false
372
+ end
373
+
374
+ # 331a (M5a): the start index crosses process boundaries through SCREEN's
375
+ # own content, never in-memory state - the final chunk is routinely a
376
+ # SEPARATE process from the one that engaged. An empty or missing file
377
+ # reads back as 0 (chunk 0 engaged, today's shape, so nothing that ever
378
+ # wrote an empty SCREEN breaks).
379
+ def read_start_index(dir)
380
+ path = File.join(dir, SCREEN_FILE)
381
+ return 0 unless File.exist?(path)
382
+
383
+ File.read(path).to_i
384
+ rescue StandardError
385
+ 0
386
+ end
387
+
172
388
  # Cheap, local, no file I/O: could this chunk's own delta plausibly be
173
389
  # part of an intent screen (ignoring leading whitespace)? Every chunk of
174
390
  # every ordinary prose message answers no, at zero cost.
@@ -177,46 +393,70 @@ class MessageDisplay
177
393
  stripped.empty? || stripped.start_with?("|") || stripped.start_with?("**")
178
394
  end
179
395
 
180
- # The final chunk additionally waits (same budget) for every earlier chunk
181
- # file to exist before it reassembles and splices. On timeout it proceeds
182
- # anyway with whatever is there (matrix, lead's guard): never nil, never
183
- # swallowed.
396
+ # The final chunk additionally waits (same budget) for every chunk file
397
+ # from the start index onward to exist before it reassembles and splices
398
+ # (331a, D3/M5: from the start index, not from 0 - chunks before the
399
+ # engaging one were never buffered at all, so waiting for them would only
400
+ # ever burn the whole budget for files that will never appear). On
401
+ # timeout it proceeds anyway with whatever is there (matrix, lead's
402
+ # guard): never nil, never swallowed.
184
403
  def finalize_final(dir, index)
185
- wait_for_chunk_files(dir, index)
404
+ start_index = read_start_index(dir)
405
+ wait_for_chunk_files(dir, start_index, index)
186
406
 
187
407
  buffered = nil
188
408
  begin
189
- buffered = read_buffered_chunks(dir, index)
409
+ buffered = read_buffered_chunks(dir, start_index, index)
190
410
  finalize(buffered, nil)
191
411
  rescue StandardError
192
- buffered
412
+ # A read failing inside the assignment above leaves `buffered` at its
413
+ # nil default (the assignment never completes), which the review pass
414
+ # caught: D8/D10 promise the buffered original on any finalize
415
+ # failure, never nil, once chunks were blanked. `||=` covers exactly
416
+ # that gap without touching the ordinary case (buffered already holds
417
+ # the real chunks read before `finalize` itself raised).
418
+ buffered ||= ""
193
419
  ensure
194
420
  FileUtils.rm_rf(dir)
195
421
  end
196
422
  end
197
423
 
198
- def wait_for_chunk_files(dir, index)
199
- return if index <= 0
424
+ # 331a1 (D3): index-scaled too, same as the decision poll - the final
425
+ # chunk of a long streamed message (335 chunks, in the live capture that
426
+ # reproduced this) must be allowed to wait long enough for the earlier
427
+ # chunk files to land, not just the base wait_ms an ordinary short
428
+ # message gets by with.
429
+ def wait_for_chunk_files(dir, start_index, index)
430
+ return if index <= start_index
200
431
 
201
- needed = (0...index).map(&:to_s)
202
- max_polls_for_budget.times do
432
+ needed = (start_index...index).map(&:to_s)
433
+ max_polls_for_budget(index).times do
203
434
  return if needed.all? { |n| File.exist?(File.join(dir, n)) }
204
435
 
205
436
  @sleeper.call(@poll_ms / 1000.0)
206
437
  end
207
438
  end
208
439
 
209
- def max_polls_for_budget
440
+ # 331a1 (D3): base wait_ms plus index_wait_ms per chunk index, capped at
441
+ # max_wait_ms - a chunk deep into a long streamed message is certainly
442
+ # going to see its decision eventually, so it is allowed to wait longer
443
+ # than chunk 1 of an ordinary short message.
444
+ def budget_ms(index)
445
+ [@wait_ms + @index_wait_ms * index.to_i, @max_wait_ms].min
446
+ end
447
+
448
+ def max_polls_for_budget(index)
210
449
  return 0 unless @poll_ms.to_f.positive?
211
450
 
212
- (@wait_ms / @poll_ms.to_f).ceil
451
+ (budget_ms(index) / @poll_ms.to_f).ceil
213
452
  end
214
453
 
215
- # Whatever chunk files exist, in index order, concatenated -- gaps (a
216
- # chunk that never arrived, or arrived too late) are skipped rather than
217
- # blocking reassembly (lead's guard: never return nothing).
218
- def read_buffered_chunks(dir, index)
219
- (0..index).filter_map do |i|
454
+ # Whatever chunk files exist FROM THE START INDEX onward, in index order,
455
+ # concatenated -- gaps (a chunk that never arrived, or arrived too late)
456
+ # are skipped rather than blocking reassembly (lead's guard: never return
457
+ # nothing).
458
+ def read_buffered_chunks(dir, start_index, index)
459
+ (start_index..index).filter_map do |i|
220
460
  path = File.join(dir, i.to_s)
221
461
  File.exist?(path) ? File.read(path) : nil
222
462
  end.join
@@ -239,29 +479,85 @@ class MessageDisplay
239
479
  start = lines.index { |l| ScreenPaint.classify(l) == :opener }
240
480
  return buffered unless start
241
481
 
242
- stop = ScreenPaint.region_end(lines, start)
243
- painted = ScreenPaint.paint(lines[start...stop].join, color: true, markdown_safe: true)
482
+ region_stop = ScreenPaint.region_end(lines, start)
483
+ painted = ScreenPaint.paint(lines[start...region_stop].join, color: true, markdown_safe: true)
484
+ trace_region(buffered, lines, start, region_stop, painted)
244
485
  return buffered unless painted
245
486
 
246
- suffix = lines[stop..].to_a.join.sub(/\A\n+/, "")
487
+ # 331a (D4): a lone CLOSING fence immediately after the painted region is
488
+ # dropped - never the painting boundary itself (region_stop, used above,
489
+ # is untouched), only where the suffix starts. An unrelated fenced code
490
+ # block further down, with prose or a blank line between it and the
491
+ # region, is never adjacent, so it always survives verbatim (M8b); a
492
+ # closing fence never carries an info string, so an adjacent OPENING
493
+ # fence of a real code block (which usually does) is never mistaken for
494
+ # it either.
495
+ suffix_start = region_stop
496
+ suffix_start += 1 if suffix_start < lines.length && FENCE_CLOSE_RE.match?(lines[suffix_start].strip)
497
+
498
+ suffix = lines[suffix_start..].to_a.join.sub(/\A\n+/, "")
247
499
  out = +"#{lines[0...start].join}#{painted.rstrip}\n"
248
500
  out << "\n#{suffix}" unless suffix.empty?
249
501
  out
250
502
  end
251
503
 
504
+ # What the final chunk actually painted, for the opt-in trace: how much was
505
+ # buffered, where the region ran, and the first line the grammar rejected -
506
+ # the one fact a terminal capture cannot give you. Computed only when a
507
+ # sink was injected, so an ordinary run pays nothing.
508
+ def trace_region(buffered, lines, start, region_stop, painted)
509
+ return unless @trace
510
+
511
+ rejected = ScreenPaint.first_rejected(lines, start)
512
+ @trace_detail.merge!(
513
+ "buffered_bytes" => buffered.bytesize,
514
+ "buffered_lines" => lines.length,
515
+ "region_start" => start,
516
+ "region_stop" => region_stop,
517
+ "painted" => !painted.nil?,
518
+ "first_rejected_line" => rejected && { "index" => rejected[0], "text" => rejected[1][0, 200] },
519
+ )
520
+ rescue StandardError
521
+ nil
522
+ end
523
+
252
524
  def write_chunk(dir, index, delta)
253
525
  atomic_write(File.join(dir, index.to_s), delta)
254
526
  end
255
527
 
256
- # IntentScreen/IntentScreenAnsi's store_root: is the TIER root (what HOLDS
257
- # store/ — e.g. .../projects/<slug> or plastic_home itself), never the
258
- # store/ directory itself; resolve_intent_dir's `root:` is already that.
259
- def write_screen(dir)
260
- atomic_write(File.join(dir, SCREEN_FILE), "")
528
+ # SCREEN's content is the engaging chunk's own index, as a decimal integer
529
+ # (331a, D6) - read back by `read_start_index` so the final chunk (a
530
+ # separate process, in production) knows where to start waiting and
531
+ # splicing, and so `finalize_final` never touches chunks that were passed
532
+ # through untouched before engagement.
533
+ # 331a1 (D2): the decision REPLACES PENDING, on this path too, whichever
534
+ # chunk turns out to be the one that engages.
535
+ def write_screen(dir, index)
536
+ atomic_write(File.join(dir, SCREEN_FILE), "#{index}\n")
537
+ remove_pending(dir)
261
538
  end
262
539
 
540
+ # 331a1 (D2): same replacement on the NOSCREEN path, so a later chunk
541
+ # never finds both PENDING and NOSCREEN and has to choose between them.
263
542
  def write_noscreen(dir)
264
543
  atomic_write(File.join(dir, NOSCREEN_FILE), "")
544
+ remove_pending(dir)
545
+ end
546
+
547
+ # 331a (D2/D6): NOSCREEN is no longer a final answer - a later chunk that
548
+ # engages replaces it with SCREEN and, for safety, removes NOSCREEN so a
549
+ # stale marker can never be read back once the real decision exists.
550
+ def remove_noscreen(dir)
551
+ FileUtils.rm_f(File.join(dir, NOSCREEN_FILE))
552
+ end
553
+
554
+ # 331a1 (D2): removal must never raise - a decision was already written
555
+ # successfully by the time this runs, and a stray filesystem error here
556
+ # must never turn a successful decision into an unhandled exception.
557
+ def remove_pending(dir)
558
+ FileUtils.rm_f(File.join(dir, PENDING_FILE))
559
+ rescue StandardError
560
+ nil
265
561
  end
266
562
 
267
563
  def atomic_write(path, content)