@zalom/plastic 2.0.0-alpha.8 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (322) hide show
  1. package/PLASTIC.md +13 -136
  2. package/README.md +357 -133
  3. package/agents/plastic-enforcer.md +20 -15
  4. package/agents/plastic-executor.md +15 -4
  5. package/agents/plastic-node-research.md +30 -0
  6. package/agents/plastic-node-verify.md +28 -0
  7. package/agents/plastic-node-work.md +33 -0
  8. package/agents/{plastic-advisor.md → plastic-primary-advisor.md} +7 -9
  9. package/agents/{plastic-faux-advisor.md → plastic-secondary-advisor.md} +9 -12
  10. package/assets/plastic-logo.svg +1 -0
  11. package/bin/crap +4 -0
  12. package/bin/lib/context_budget.rb +43 -6
  13. package/bin/lib/skill_census.rb +839 -0
  14. package/bin/plastic +6 -0
  15. package/bin/plastic-skill-census +114 -0
  16. package/bin/test +24 -4
  17. package/bin/verify-change +345 -0
  18. package/config_asks.yml +4 -4
  19. package/deprecations.yml +1 -1
  20. package/{skills/agent-advisor/references → docs/help}/advisor-protocol.md +19 -24
  21. package/{skills/auto/references → docs/help}/agent-architecture.md +16 -14
  22. package/{skills/conventions/references → docs/help}/completion-and-done.md +15 -16
  23. package/docs/help/human-report-contract.md +152 -0
  24. package/{skills/conventions/references → docs/help}/knowledge-graph.md +9 -0
  25. package/{skills/conventions/references → docs/help}/locks-and-worktrees.md +23 -13
  26. package/{skills/conventions/references → docs/help}/maintenance-and-revisions.md +1 -1
  27. package/{skills/conventions/references → docs/help}/roadmaps.md +2 -2
  28. package/{skills/tutorial/references → docs/help}/track-1-guided.md +28 -47
  29. package/{skills/tutorial/references → docs/help}/track-2-auto.md +8 -8
  30. package/{skills/tutorial/references → docs/help}/track-3-projects-and-roadmaps.md +24 -17
  31. package/hooks/call-budget +4 -0
  32. package/hooks/hooks.json +24 -0
  33. package/hooks/message-display +62 -1
  34. package/hooks/session-start +5 -1
  35. package/hooks/statusline +32 -27
  36. package/hooks/stop +5 -0
  37. package/package.json +5 -3
  38. package/scripts/append-ledger +2 -1
  39. package/scripts/dashboard.rb +267 -16
  40. package/scripts/day-summary +2 -1
  41. package/scripts/doctor.rb +680 -39
  42. package/scripts/end-intent +238 -22
  43. package/scripts/exec-worktree +5 -5
  44. package/scripts/file-session-intent +2 -1
  45. package/scripts/graph-measure +249 -0
  46. package/scripts/hook-call-budget +222 -0
  47. package/scripts/hook-capture +24 -123
  48. package/scripts/hook-close +2 -1
  49. package/scripts/hook-message-display +19 -5
  50. package/scripts/hook-record +24 -16
  51. package/scripts/hook-savepoint +27 -3
  52. package/scripts/hook-session-start +359 -321
  53. package/scripts/hook-stop +58 -0
  54. package/scripts/index-projection +74 -0
  55. package/scripts/insight-append +17 -4
  56. package/scripts/install.rb +9 -7
  57. package/scripts/lib/action_graph_shim.rb +279 -0
  58. package/scripts/lib/active_delivery.rb +105 -0
  59. package/scripts/lib/agent_models.rb +63 -25
  60. package/scripts/lib/arm.rb +48 -83
  61. package/scripts/lib/atomic_write.rb +31 -0
  62. package/scripts/lib/backup.rb +65 -0
  63. package/scripts/lib/cli/command.rb +92 -0
  64. package/scripts/lib/cli/commands/auto.rb +18 -0
  65. package/scripts/lib/cli/commands/auto_brief.rb +44 -0
  66. package/scripts/lib/cli/commands/auto_lock.rb +99 -0
  67. package/scripts/lib/cli/commands/auto_report.rb +50 -0
  68. package/scripts/lib/cli/commands/auto_take.rb +31 -0
  69. package/scripts/lib/cli/commands/backup.rb +43 -0
  70. package/scripts/lib/cli/commands/checkout.rb +25 -0
  71. package/scripts/lib/cli/commands/continue.rb +66 -0
  72. package/scripts/lib/cli/commands/doctor.rb +81 -0
  73. package/scripts/lib/cli/commands/feedback.rb +40 -0
  74. package/scripts/lib/cli/commands/help.rb +69 -0
  75. package/scripts/lib/cli/commands/hook.rb +32 -0
  76. package/scripts/lib/cli/commands/index.rb +23 -0
  77. package/scripts/lib/cli/commands/install.rb +21 -0
  78. package/scripts/lib/cli/commands/installer_verb.rb +37 -0
  79. package/scripts/lib/cli/commands/intent.rb +19 -0
  80. package/scripts/lib/cli/commands/intent_answer.rb +37 -0
  81. package/scripts/lib/cli/commands/intent_command.rb +53 -0
  82. package/scripts/lib/cli/commands/intent_end.rb +62 -0
  83. package/scripts/lib/cli/commands/intent_new.rb +62 -0
  84. package/scripts/lib/cli/commands/intent_note.rb +43 -0
  85. package/scripts/lib/cli/commands/intent_rule.rb +36 -0
  86. package/scripts/lib/cli/commands/intent_show.rb +32 -0
  87. package/scripts/lib/cli/commands/intent_spec.rb +44 -0
  88. package/scripts/lib/cli/commands/intent_step.rb +71 -0
  89. package/scripts/lib/cli/commands/intent_verify.rb +26 -0
  90. package/scripts/lib/cli/commands/migrate.rb +16 -0
  91. package/scripts/lib/cli/commands/migrate_stores.rb +31 -0
  92. package/scripts/lib/cli/commands/next.rb +54 -0
  93. package/scripts/lib/cli/commands/project.rb +19 -0
  94. package/scripts/lib/cli/commands/project_links.rb +42 -0
  95. package/scripts/lib/cli/commands/project_list.rb +20 -0
  96. package/scripts/lib/cli/commands/project_new.rb +72 -0
  97. package/scripts/lib/cli/commands/query.rb +31 -0
  98. package/scripts/lib/cli/commands/render.rb +33 -0
  99. package/scripts/lib/cli/commands/roadmap.rb +19 -0
  100. package/scripts/lib/cli/commands/roadmap_check.rb +54 -0
  101. package/scripts/lib/cli/commands/roadmap_log.rb +54 -0
  102. package/scripts/lib/cli/commands/roadmap_migrate.rb +53 -0
  103. package/scripts/lib/cli/commands/roadmap_next.rb +70 -0
  104. package/scripts/lib/cli/commands/roadmap_show.rb +45 -0
  105. package/scripts/lib/cli/commands/rollback.rb +20 -0
  106. package/scripts/lib/cli/commands/search.rb +60 -0
  107. package/scripts/lib/cli/commands/session.rb +18 -0
  108. package/scripts/lib/cli/commands/session_commit.rb +42 -0
  109. package/scripts/lib/cli/commands/session_handoff.rb +34 -0
  110. package/scripts/lib/cli/commands/session_summary.rb +35 -0
  111. package/scripts/lib/cli/commands/status.rb +68 -0
  112. package/scripts/lib/cli/commands/subcommand_list.rb +36 -0
  113. package/scripts/lib/cli/commands/sync.rb +46 -0
  114. package/scripts/lib/cli/commands/uninstall.rb +20 -0
  115. package/scripts/lib/cli/commands/update.rb +20 -0
  116. package/scripts/lib/cli/commands/version.rb +53 -0
  117. package/scripts/lib/cli/frontier.rb +86 -0
  118. package/scripts/lib/cli/intent_progress.rb +36 -0
  119. package/scripts/lib/cli/legacy.rb +80 -0
  120. package/scripts/lib/cli/output.rb +137 -0
  121. package/scripts/lib/cli/scope.rb +134 -0
  122. package/scripts/lib/cli/table.rb +65 -0
  123. package/scripts/lib/cli.rb +94 -0
  124. package/scripts/lib/codex_adapter.rb +198 -0
  125. package/scripts/lib/compact_instructions.rb +13 -5
  126. package/scripts/lib/core_integrity.rb +71 -0
  127. package/scripts/lib/dashboard_screen.rb +40 -0
  128. package/scripts/lib/data_boundary.rb +132 -0
  129. package/scripts/lib/day_summary.rb +30 -16
  130. package/scripts/lib/doctor_core.rb +114 -38
  131. package/scripts/lib/doctor_session_ledger.rb +5 -53
  132. package/scripts/lib/engine_permissions.rb +88 -0
  133. package/scripts/lib/exec_worktree.rb +24 -21
  134. package/scripts/lib/feedback_report.rb +1 -1
  135. package/scripts/lib/graph_edges.rb +137 -0
  136. package/scripts/lib/graph_file.rb +246 -0
  137. package/scripts/lib/graph_measure.rb +645 -0
  138. package/scripts/lib/graph_measure_budget.rb +409 -0
  139. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  140. package/scripts/lib/graph_measure_models.rb +413 -0
  141. package/scripts/lib/graph_measure_report.rb +532 -0
  142. package/scripts/lib/graph_tree.rb +98 -0
  143. package/scripts/lib/guarded_append.rb +155 -0
  144. package/scripts/lib/handoff.rb +40 -13
  145. package/scripts/lib/harness_adapter.rb +184 -0
  146. package/scripts/lib/hook_registry.rb +27 -3
  147. package/scripts/lib/hook_replay.rb +229 -0
  148. package/scripts/lib/index_entry.rb +62 -0
  149. package/scripts/lib/index_projection.rb +201 -0
  150. package/scripts/lib/insights.rb +1 -1
  151. package/scripts/lib/installer_core.rb +532 -71
  152. package/scripts/lib/intent_screen.rb +9 -6
  153. package/scripts/lib/intent_screen_ansi.rb +73 -12
  154. package/scripts/lib/intent_validator.rb +2 -2
  155. package/scripts/lib/lock.rb +10 -11
  156. package/scripts/lib/message_display.rb +377 -173
  157. package/scripts/lib/meter_watch.rb +185 -0
  158. package/scripts/lib/node_file.rb +234 -0
  159. package/scripts/lib/node_ids.rb +99 -0
  160. package/scripts/lib/node_input.rb +913 -0
  161. package/scripts/lib/node_input_compatibility.rb +62 -0
  162. package/scripts/lib/node_ledger.rb +386 -0
  163. package/scripts/lib/node_progress.rb +153 -0
  164. package/scripts/lib/node_return.rb +204 -0
  165. package/scripts/lib/node_worktree.rb +337 -0
  166. package/scripts/lib/outcome_report.rb +440 -0
  167. package/scripts/lib/preflight.rb +4 -6
  168. package/scripts/lib/project_config.rb +46 -0
  169. package/scripts/lib/project_validator.rb +3 -2
  170. package/scripts/lib/qmd_sync.rb +8 -7
  171. package/scripts/lib/ready_set.rb +462 -0
  172. package/scripts/lib/reference_archive.rb +45 -0
  173. package/scripts/lib/release_guard.rb +18 -0
  174. package/scripts/lib/report_screen.rb +1460 -74
  175. package/scripts/lib/rlm/corpus.rb +13 -0
  176. package/scripts/lib/rlm/probe.rb +29 -0
  177. package/scripts/lib/rlm/query.rb +22 -0
  178. package/scripts/lib/roadmap_graph.rb +210 -0
  179. package/scripts/lib/roadmap_migration.rb +95 -0
  180. package/scripts/lib/roadmap_queue.rb +158 -8
  181. package/scripts/lib/roadmap_render.rb +150 -0
  182. package/scripts/lib/roadmap_savepoint.rb +64 -14
  183. package/scripts/lib/runner_absorb.rb +703 -0
  184. package/scripts/lib/runner_answer.rb +206 -0
  185. package/scripts/lib/runner_core.rb +194 -0
  186. package/scripts/lib/runner_dispatch.rb +525 -0
  187. package/scripts/lib/runner_policy.rb +191 -0
  188. package/scripts/lib/runner_proposals.rb +275 -0
  189. package/scripts/lib/runner_rewind.rb +201 -0
  190. package/scripts/lib/runner_sweep.rb +231 -0
  191. package/scripts/lib/runner_until_empty.rb +252 -0
  192. package/scripts/lib/runner_watch.rb +389 -0
  193. package/scripts/lib/savepoint.rb +141 -19
  194. package/scripts/lib/scaffold_intent.rb +6 -3
  195. package/scripts/lib/screen_paint.rb +555 -0
  196. package/scripts/lib/screens/dashboard.rb +20 -0
  197. package/scripts/lib/screens/plan.rb +18 -0
  198. package/scripts/lib/screens/roadmap.rb +15 -0
  199. package/scripts/lib/search_index.rb +55 -0
  200. package/scripts/lib/session_close.rb +30 -28
  201. package/scripts/lib/session_git.rb +25 -18
  202. package/scripts/lib/session_ledger.rb +48 -4
  203. package/scripts/lib/session_usage.rb +190 -0
  204. package/scripts/lib/sqlite.rb +22 -0
  205. package/scripts/lib/stop_gate.rb +95 -0
  206. package/scripts/lib/store_discovery.rb +7 -6
  207. package/scripts/lib/store_layout.rb +54 -0
  208. package/scripts/lib/store_provisioning.rb +2 -1
  209. package/scripts/lib/store_sync.rb +85 -0
  210. package/scripts/lib/stores_move.rb +93 -0
  211. package/scripts/lib/verify_intent.rb +36 -8
  212. package/scripts/lib/version_number.rb +48 -0
  213. package/scripts/lib/work_graph.rb +59 -0
  214. package/scripts/lib/work_graph_validator.rb +201 -0
  215. package/scripts/lib/worktree.rb +27 -32
  216. package/scripts/lib/worktree_sweep.rb +6 -5
  217. package/scripts/link-suggest +2 -1
  218. package/scripts/meter-watch +57 -0
  219. package/scripts/migrate-to-global +1 -1
  220. package/scripts/new-intent +4 -13
  221. package/scripts/node-input +92 -0
  222. package/scripts/node-run +225 -0
  223. package/scripts/node-transition +291 -0
  224. package/scripts/outcome-report +74 -0
  225. package/scripts/plastic-lock +42 -33
  226. package/scripts/promote-session-item +3 -2
  227. package/scripts/read-config +53 -9
  228. package/scripts/ready-set +126 -0
  229. package/scripts/release-check +123 -0
  230. package/scripts/report-screen +192 -30
  231. package/scripts/roadmap-graph +119 -0
  232. package/scripts/roadmap-savepoint +7 -0
  233. package/scripts/runner +581 -0
  234. package/scripts/savepoint-note +11 -9
  235. package/scripts/session-commit +2 -1
  236. package/scripts/session-usage +56 -0
  237. package/scripts/skill-lint +115 -6
  238. package/scripts/spawn-preamble +2 -2
  239. package/scripts/update.rb +25 -4
  240. package/scripts/validate-work-graph +39 -0
  241. package/scripts/verify-intent +3 -2
  242. package/scripts/write-handoff +2 -1
  243. package/templates/agents.md +7 -7
  244. package/templates/config.yml +16 -9
  245. package/templates/dashboard-screen.md +22 -0
  246. package/templates/display-fixture.md +21 -0
  247. package/templates/graph.md +16 -0
  248. package/templates/index.md +1 -1
  249. package/templates/intent-screen.md +1 -1
  250. package/templates/node-decision.md +11 -0
  251. package/templates/node-research.md +13 -0
  252. package/templates/node-verify.md +13 -0
  253. package/templates/node-work.md +22 -0
  254. package/templates/outcome.md +15 -2
  255. package/templates/project.yml +1 -1
  256. package/templates/render.css +10 -0
  257. package/templates/report-plan.md +15 -0
  258. package/templates/report-roadmap-delivered.md +10 -0
  259. package/templates/report-roadmap-plan.md +9 -0
  260. package/templates/report-roadmap-state.md +9 -0
  261. package/templates/report-state.md +1 -1
  262. package/templates/roadmap.md +13 -0
  263. package/bin/plastic.js +0 -70
  264. package/scripts/lib/bridge.rb +0 -116
  265. package/skills/agent-advisor/SKILL.md +0 -92
  266. package/skills/auto/SKILL.md +0 -295
  267. package/skills/auto/evals/evals.json +0 -255
  268. package/skills/auto/references/end-tail.md +0 -66
  269. package/skills/auto/references/human-report-contract.md +0 -78
  270. package/skills/conventions/SKILL.md +0 -29
  271. package/skills/dashboard/SKILL.md +0 -169
  272. package/skills/dashboard/evals/evals.json +0 -38
  273. package/skills/dashboard/references/classification.md +0 -22
  274. package/skills/dashboard/templates/dashboard-global.md +0 -20
  275. package/skills/dashboard/templates/dashboard-project.md +0 -19
  276. package/skills/direct/SKILL.md +0 -66
  277. package/skills/direct/references/request-signals.md +0 -59
  278. package/skills/doctor/SKILL.md +0 -299
  279. package/skills/doctor/report.md +0 -102
  280. package/skills/feedback/SKILL.md +0 -98
  281. package/skills/feedback/references/transport-and-privacy.md +0 -65
  282. package/skills/feedback/report.md +0 -36
  283. package/skills/install/SKILL.md +0 -217
  284. package/skills/intent-continuing/SKILL.md +0 -154
  285. package/skills/intent-continuing/references/board-fill.md +0 -43
  286. package/skills/intent-continuing/references/boarding-matrix.md +0 -34
  287. package/skills/intent-continuing/references/context-management.md +0 -28
  288. package/skills/intent-continuing/references/liveness-ranking.md +0 -57
  289. package/skills/intent-creating/SKILL.md +0 -164
  290. package/skills/intent-creating/evals/evals.json +0 -72
  291. package/skills/intent-creating/references/lifecycle.md +0 -81
  292. package/skills/intent-creating/references/wikilinks.md +0 -8
  293. package/skills/intent-ending/SKILL.md +0 -173
  294. package/skills/intent-ending/evals/evals.json +0 -74
  295. package/skills/intent-executing/SKILL.md +0 -170
  296. package/skills/intent-executing/evals/evals.json +0 -66
  297. package/skills/intent-executing/implementer-prompt.md +0 -42
  298. package/skills/intent-executing/spec-reviewer-prompt.md +0 -27
  299. package/skills/intent-speccing/SKILL.md +0 -130
  300. package/skills/intent-speccing/evals/evals.json +0 -126
  301. package/skills/intent-speccing/references/design-principles.md +0 -44
  302. package/skills/intent-speccing/references/per-section-fill-rules.md +0 -92
  303. package/skills/intent-speccing/references/self-verify-checklist.md +0 -37
  304. package/skills/project-creating/SKILL.md +0 -162
  305. package/skills/project-creating/references/hubs-projects.md +0 -55
  306. package/skills/project-creating/references/project-scaffolding.md +0 -97
  307. package/skills/releasing/SKILL.md +0 -337
  308. package/skills/releasing/references/deprecations.md +0 -60
  309. package/skills/releasing/references/promotion-and-tagging.md +0 -66
  310. package/skills/releasing/references/release-lines.md +0 -105
  311. package/skills/roadmap/SKILL.md +0 -64
  312. package/skills/roadmap/references/file-format.md +0 -124
  313. package/skills/roadmap/references/operations.md +0 -112
  314. package/skills/rollback/SKILL.md +0 -91
  315. package/skills/tutorial/SKILL.md +0 -65
  316. package/skills/tutorial/evals/evals.json +0 -186
  317. package/skills/uninstall/SKILL.md +0 -75
  318. package/skills/update/SKILL.md +0 -126
  319. /package/{skills/auto/references → docs/help}/agent-report-contract.md +0 -0
  320. /package/{skills/intent-executing → docs/help}/code-quality-reviewer-prompt.md +0 -0
  321. /package/{skills/conventions/references → docs/help}/lifecycle-and-savepoints.md +0 -0
  322. /package/{skills/intent-executing → docs/help}/plan-reviewer-prompt.md +0 -0
@@ -2,10 +2,8 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "fileutils"
5
- require_relative "intent_screen"
6
- require_relative "intent_screen_ansi"
7
- require_relative "store_discovery"
8
- require_relative "store_provisioning"
5
+ require "json"
6
+ require_relative "screen_paint"
9
7
 
10
8
  # MessageDisplay (intent 316a, O4/O5, round 3 concurrency fix) - the Claude
11
9
  # Code MessageDisplay hook handler. One process per streamed chunk of every
@@ -49,22 +47,87 @@ require_relative "store_provisioning"
49
47
  # buffered or blanked. D10 (any failure while finalizing returns the
50
48
  # buffered original, never nil, never "") and D12 (color: false never
51
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.
52
93
  class MessageDisplay
53
- MARKER_RE = /\A## ▶ (\S+) · /.freeze
94
+ # 317a (A4): engagement is grammar, not identity - any screen-family
95
+ # opener engages, with NO intent-id resolution (the roster and delay
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.
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,38 +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.
121
- def handle_chunk_zero(dir, delta, cwd, final)
122
- stripped = delta.sub(/\A[ \t]+/, "")
123
- m = stripped.match(MARKER_RE)
124
- resolved = m && resolve_intent_dir(m[1], cwd)
125
-
126
- unless resolved
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.
220
+ def handle_chunk_zero(dir, delta, _cwd, final)
221
+ split = split_at_opener(delta)
222
+ unless split
223
+ @trace_detail["decision"] = "noscreen"
127
224
  write_noscreen(dir)
128
225
  return nil
129
226
  end
130
227
 
131
- write_screen(dir, resolved)
132
- write_chunk(dir, 0, delta)
133
- final ? finalize_final(dir, 0) : ""
228
+ @trace_detail["decision"] = "engage"
229
+ engage(dir, 0, split, final)
134
230
  end
135
231
 
136
- # A later chunk (index > 0) never redoes chunk 0's work: it only asks
137
- # whether a decision already exists, waiting for one (bounded) when it
138
- # does not and the chunk looks like it could matter. The final chunk
139
- # 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.
140
237
  def handle_later_chunk(dir, index, delta, final)
141
- 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
142
257
 
143
258
  return nil unless decision == :screen
144
259
 
@@ -146,17 +261,83 @@ class MessageDisplay
146
261
  final ? finalize_final(dir, index) : ""
147
262
  end
148
263
 
149
- # Checks for an existing decision first (free) and only pays the cheap
150
- # shape test, then the bounded poll, when neither SCREEN nor NOSCREEN is
151
- # there yet. `gate_delta: nil` (the final chunk) skips the shape test
152
- # entirely and always polls for the decision.
153
- 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:)
154
325
  decision = read_decision_now(dir)
155
326
  return decision if decision
156
327
 
328
+ if pending?(dir)
329
+ return :noscreen if pending_stale?(dir, index)
330
+
331
+ return poll_for_decision(dir, index)
332
+ end
333
+
157
334
  return :timeout if gate_delta && !maybe_screen?(gate_delta)
158
335
 
159
- 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
160
341
  @sleeper.call(@poll_ms / 1000.0)
161
342
  decision = read_decision_now(dir)
162
343
  return decision if decision
@@ -172,97 +353,211 @@ class MessageDisplay
172
353
  nil
173
354
  end
174
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
+
175
388
  # Cheap, local, no file I/O: could this chunk's own delta plausibly be
176
389
  # part of an intent screen (ignoring leading whitespace)? Every chunk of
177
390
  # every ordinary prose message answers no, at zero cost.
178
391
  def maybe_screen?(delta)
179
392
  stripped = delta.lstrip
180
- stripped.empty? || stripped.start_with?("|") || stripped.start_with?("**Steps**")
393
+ stripped.empty? || stripped.start_with?("|") || stripped.start_with?("**")
181
394
  end
182
395
 
183
- # The final chunk additionally waits (same budget) for every earlier chunk
184
- # file to exist before it reassembles and splices. On timeout it proceeds
185
- # anyway with whatever is there (matrix, lead's guard): never nil, never
186
- # 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.
187
403
  def finalize_final(dir, index)
188
- wait_for_chunk_files(dir, index)
404
+ start_index = read_start_index(dir)
405
+ wait_for_chunk_files(dir, start_index, index)
189
406
 
190
407
  buffered = nil
191
408
  begin
192
- buffered = read_buffered_chunks(dir, index)
193
- decision = read_screen_decision(dir)
194
- finalize(buffered, decision)
409
+ buffered = read_buffered_chunks(dir, start_index, index)
410
+ finalize(buffered, nil)
195
411
  rescue StandardError
196
- 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 ||= ""
197
419
  ensure
198
420
  FileUtils.rm_rf(dir)
199
421
  end
200
422
  end
201
423
 
202
- def wait_for_chunk_files(dir, index)
203
- 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
204
431
 
205
- needed = (0...index).map(&:to_s)
206
- max_polls_for_budget.times do
432
+ needed = (start_index...index).map(&:to_s)
433
+ max_polls_for_budget(index).times do
207
434
  return if needed.all? { |n| File.exist?(File.join(dir, n)) }
208
435
 
209
436
  @sleeper.call(@poll_ms / 1000.0)
210
437
  end
211
438
  end
212
439
 
213
- 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)
214
449
  return 0 unless @poll_ms.to_f.positive?
215
450
 
216
- (@wait_ms / @poll_ms.to_f).ceil
451
+ (budget_ms(index) / @poll_ms.to_f).ceil
217
452
  end
218
453
 
219
- # Whatever chunk files exist, in index order, concatenated -- gaps (a
220
- # chunk that never arrived, or arrived too late) are skipped rather than
221
- # blocking reassembly (lead's guard: never return nothing).
222
- def read_buffered_chunks(dir, index)
223
- (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|
224
460
  path = File.join(dir, i.to_s)
225
461
  File.exist?(path) ? File.read(path) : nil
226
462
  end.join
227
463
  end
228
464
 
229
- def read_screen_decision(dir)
230
- content = File.read(File.join(dir, SCREEN_FILE))
231
- intent_dir, store_root = content.split("\n")
232
- { intent_dir: intent_dir, store_root: store_root }
233
- end
234
-
235
- def finalize(buffered, decision)
236
- intent_dir = decision[:intent_dir]
237
- store_root = decision[:store_root]
238
- # markdown_safe: true (intent 316a1, D5) - Claude Code still Markdown-
239
- # processes displayContent even inside a raw ANSI block (316a's live
240
- # capture showed backticks silently stripped from step text), so the
241
- # Claude adapter asks the harness-agnostic core to strip markdown noise
242
- # before it ever reaches the block. A harness whose display surface
243
- # passes raw ANSI through untouched would ask for false instead.
244
- ansi = IntentScreenAnsi.render(intent_dir: intent_dir, store_root: store_root, color: true, markdown_safe: true)
245
- plain = IntentScreen.render(intent_dir: intent_dir, store_root: store_root, template: File.read(template_path))
246
- splice(buffered, plain, ansi)
465
+ # 317a (D1/B10): paint what was printed. The buffered message's screen
466
+ # region - located and bounded by ScreenPaint's own grammar - is re-laid in
467
+ # the ANSI vocabulary; prose before and after survives verbatim. A region
468
+ # the painter cannot parse returns the buffered original (A3: chunks were
469
+ # already blanked, so nil here would truncate the message to its final
470
+ # delta; nil is only for the never-engaged path in handle).
471
+ #
472
+ # markdown_safe: true (intent 316a1, D5) - Claude Code still Markdown-
473
+ # processes displayContent even inside a raw ANSI block, so the Claude
474
+ # adapter asks the harness-agnostic core to strip markdown noise. A harness
475
+ # whose display surface passes raw ANSI through untouched would ask for
476
+ # false instead.
477
+ def finalize(buffered, _decision)
478
+ lines = buffered.lines
479
+ start = lines.index { |l| ScreenPaint.classify(l) == :opener }
480
+ return buffered unless start
481
+
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)
485
+ return buffered unless painted
486
+
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+/, "")
499
+ out = +"#{lines[0...start].join}#{painted.rstrip}\n"
500
+ out << "\n#{suffix}" unless suffix.empty?
501
+ out
247
502
  end
248
503
 
249
- def template_path
250
- File.expand_path("../../templates/intent-screen.md", __dir__)
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
251
522
  end
252
523
 
253
524
  def write_chunk(dir, index, delta)
254
525
  atomic_write(File.join(dir, index.to_s), delta)
255
526
  end
256
527
 
257
- # IntentScreen/IntentScreenAnsi's store_root: is the TIER root (what HOLDS
258
- # store/ — e.g. .../projects/<slug> or plastic_home itself), never the
259
- # store/ directory itself; resolve_intent_dir's `root:` is already that.
260
- def write_screen(dir, resolved)
261
- atomic_write(File.join(dir, SCREEN_FILE), "#{resolved[:intent_dir]}\n#{resolved[:root]}\n")
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)
262
538
  end
263
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.
264
542
  def write_noscreen(dir)
265
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
266
561
  end
267
562
 
268
563
  def atomic_write(path, content)
@@ -272,98 +567,7 @@ class MessageDisplay
272
567
  File.rename(tmp_path, path)
273
568
  end
274
569
 
275
- # D16: replace the plain render's own text wherever it sits in the buffered
276
- # message, keeping everything after it verbatim. Falls back to a line-based
277
- # boundary (the "## ▶ " line through the last line starting with "|") only
278
- # when the buffered text does not start with the plain render exactly (the
279
- # model reformatted something, or a chunk gap broke the exact match) — the
280
- # fallback also has to work for a checklist-less intent, whose only Steps
281
- # row is "| | | no steps yet |".
282
- def splice(buffered, plain, ansi)
283
- suffix =
284
- if buffered.start_with?(plain)
285
- buffered[plain.length..]
286
- else
287
- line_based_suffix(buffered, plain)
288
- end
289
- return buffered if suffix.nil?
290
-
291
- "#{ansi.rstrip}\n\n#{suffix}"
292
- end
293
-
294
- # Bounded fallback (matrix, lead's B1): walk forward from the "## ▶ " line
295
- # only through the screen's OWN contiguous run of blank lines, "|"-prefixed
296
- # table rows and the "**Steps**" heading, and stop at the first line that is
297
- # none of those. The boundary is the last "|" line seen before that stop —
298
- # never the last "|" line anywhere in the message. Scanning to the end
299
- # unbounded (the old behavior) swallows any prose the model wrote between
300
- # the screen and an unrelated Markdown table further down (a real hazard:
301
- # Plastic replies carry tables often).
302
- def line_based_suffix(buffered, plain)
303
- lines = buffered.lines
304
- start_idx = lines.index { |l| l.start_with?("## ▶ ") }
305
- return nil unless start_idx
306
-
307
- last_pipe_idx = nil
308
- i = start_idx + 1
309
- while i < lines.length
310
- line = lines[i]
311
- stripped = line.strip
312
- break unless stripped.empty? || line.start_with?("|") || stripped == "**Steps**"
313
-
314
- last_pipe_idx = i if line.start_with?("|")
315
- i += 1
316
- end
317
- return nil unless last_pipe_idx
318
-
319
- # Guard: never let the bounded scan consume more lines than the freshly
320
- # rendered plain screen itself has. If it would, something about the
321
- # buffered text does not match the shape splice() expects at all — pass
322
- # the original through rather than risk eating real prose.
323
- consumed = last_pipe_idx + 1 - start_idx
324
- return nil if consumed > plain.lines.length
325
-
326
- lines[(last_pipe_idx + 1)..].join
327
- end
328
570
 
329
- # O5: candidates are every discovered store holding a "<id>--*" directory.
330
- # A single candidate resolves outright (no ambiguity to break). With two or
331
- # more, the store whose project root is a path prefix of the payload's cwd
332
- # decides; if that narrows to anything other than exactly one, pass through
333
- # rather than guess (matrix 36).
334
- #
335
- # "cwd is a path prefix" is checked against the project's REAL checkout
336
- # path (projects.yml's own `path:`, e.g. ~/apps/personal/plastic) — never
337
- # against StoreDiscovery's `root` (~/.plastic/projects/<slug>, which only
338
- # holds INDEX.md and store/). Those are two different directories; a real
339
- # session's cwd lives under the former, never the latter. The global store
340
- # has no such checkout path, so it never wins by cwd — only by being the
341
- # sole candidate.
342
- def resolve_intent_dir(id, cwd)
343
- pattern = "#{glob_escape(id)}--*"
344
- candidates = StoreDiscovery.discover(@plastic_home)[:stores].filter_map do |s|
345
- dir = Dir.glob(File.join(s[:store], pattern)).find { |d| File.directory?(d) }
346
- dir && { slug: s[:slug], root: s[:root], intent_dir: dir }
347
- end
348
- return nil if candidates.empty?
349
- return candidates.first if candidates.length == 1
350
-
351
- registered = StoreProvisioning.load_projects(@plastic_home)
352
- cwd_matches = candidates.select do |c|
353
- real_path = registered.dig(c[:slug], "path")
354
- real_path && (cwd == real_path || cwd.start_with?("#{real_path}#{File::SEPARATOR}"))
355
- end
356
- return cwd_matches.first if cwd_matches.length == 1
357
-
358
- nil
359
- end
360
-
361
- # A recognized id should just be [A-Za-z0-9]+, but the id comes out of the
362
- # assistant's own streamed text, not a trusted schema — escape glob
363
- # metacharacters rather than assume it is well-formed.
364
- def glob_escape(str)
365
- str.gsub(/([*?\[\]{}])/) { "\\#{Regexp.last_match(1)}" }
366
- end
367
571
 
368
572
  def prune_old_buffers
369
573
  root = File.join(@tmp_root, BUFFER_DIR_NAME)