@zalom/plastic 1.14.1 → 2.0.0-alpha.10

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 (213) hide show
  1. package/PLASTIC.md +101 -294
  2. package/README.md +5 -6
  3. package/agents/plastic-advisor.md +18 -17
  4. package/agents/plastic-enforcer.md +57 -69
  5. package/agents/plastic-executor.md +34 -20
  6. package/agents/plastic-faux-advisor.md +18 -18
  7. package/bin/lib/context_budget.rb +453 -0
  8. package/bin/plastic-bench +78 -0
  9. package/config_asks.yml +38 -0
  10. package/hooks/capture +4 -0
  11. package/hooks/close +5 -0
  12. package/hooks/hooks.json +14 -42
  13. package/hooks/message-display +81 -0
  14. package/hooks/record +4 -0
  15. package/hooks/savepoint +5 -5
  16. package/package.json +3 -2
  17. package/scripts/agent-report +15 -10
  18. package/scripts/append-ledger +208 -0
  19. package/scripts/codex-hook +78 -90
  20. package/scripts/dashboard.rb +43 -14
  21. package/scripts/day-summary +53 -0
  22. package/scripts/doctor.rb +269 -43
  23. package/scripts/end-intent +170 -76
  24. package/scripts/exec-worktree +11 -20
  25. package/scripts/file-session-intent +61 -0
  26. package/scripts/hook-capture +255 -0
  27. package/scripts/hook-close +36 -0
  28. package/scripts/hook-message-display +74 -0
  29. package/scripts/hook-record +157 -0
  30. package/scripts/hook-savepoint +45 -0
  31. package/scripts/hook-session-start +128 -12
  32. package/scripts/install.rb +8 -6
  33. package/scripts/intent-screen +77 -0
  34. package/scripts/lib/agent_models.rb +1 -7
  35. package/scripts/lib/arm.rb +308 -0
  36. package/scripts/lib/backfill_intent.rb +316 -0
  37. package/scripts/lib/bridge.rb +24 -1597
  38. package/scripts/lib/compact_instructions.rb +56 -0
  39. package/scripts/lib/day_summary.rb +211 -0
  40. package/scripts/lib/doctor_core.rb +66 -88
  41. package/scripts/lib/doctor_session_ledger.rb +158 -0
  42. package/scripts/lib/exec_worktree.rb +14 -60
  43. package/scripts/lib/handoff.rb +184 -0
  44. package/scripts/lib/harness_text.rb +5 -4
  45. package/scripts/lib/hook_registry.rb +57 -106
  46. package/scripts/lib/insights.rb +3 -4
  47. package/scripts/lib/installer_core.rb +136 -41
  48. package/scripts/lib/intent_screen.rb +309 -0
  49. package/scripts/lib/intent_screen_ansi.rb +262 -0
  50. package/scripts/lib/lock.rb +6 -26
  51. package/scripts/lib/message_display.rb +290 -0
  52. package/scripts/lib/outcome_guard.rb +7 -5
  53. package/scripts/lib/project_validator.rb +30 -1
  54. package/scripts/lib/report_screen.rb +648 -0
  55. package/scripts/lib/roadmap_queue.rb +3 -3
  56. package/scripts/lib/roadmap_savepoint.rb +1 -1
  57. package/scripts/lib/rule_catalog.rb +6 -3
  58. package/scripts/lib/savepoint.rb +334 -0
  59. package/scripts/lib/scaffold_intent.rb +13 -179
  60. package/scripts/lib/screen_paint.rb +276 -0
  61. package/scripts/lib/session_backfill.rb +184 -0
  62. package/scripts/lib/session_close.rb +104 -0
  63. package/scripts/lib/session_git.rb +500 -0
  64. package/scripts/lib/session_ledger.rb +621 -0
  65. package/scripts/lib/store_discovery.rb +1 -1
  66. package/scripts/lib/store_provisioning.rb +1 -1
  67. package/scripts/maintenance-run +7 -7
  68. package/scripts/new-intent +63 -4
  69. package/scripts/plastic-lock +101 -41
  70. package/scripts/promote-session-item +150 -0
  71. package/scripts/read-config +3 -0
  72. package/scripts/rebuild-graph +1 -1
  73. package/scripts/report-screen +120 -0
  74. package/scripts/roadmap-next +2 -2
  75. package/scripts/rollback.rb +48 -3
  76. package/scripts/savepoint-note +67 -0
  77. package/scripts/scaffold-intent +37 -63
  78. package/scripts/session-commit +116 -0
  79. package/scripts/spawn-preamble +14 -7
  80. package/scripts/update.rb +7 -3
  81. package/scripts/write-handoff +60 -0
  82. package/skills/agent-advisor/SKILL.md +3 -3
  83. package/skills/agent-advisor/references/advisor-protocol.md +20 -20
  84. package/skills/auto/SKILL.md +223 -317
  85. package/skills/auto/evals/evals.json +10 -10
  86. package/skills/auto/references/agent-architecture.md +102 -90
  87. package/skills/auto/references/agent-report-contract.md +4 -21
  88. package/skills/auto/references/end-tail.md +47 -53
  89. package/skills/auto/references/human-report-contract.md +62 -56
  90. package/skills/conventions/SKILL.md +1 -3
  91. package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
  92. package/skills/conventions/references/locks-and-worktrees.md +62 -58
  93. package/skills/conventions/references/maintenance-and-revisions.md +12 -12
  94. package/skills/dashboard/SKILL.md +1 -1
  95. package/skills/direct/SKILL.md +66 -0
  96. package/skills/direct/references/request-signals.md +59 -0
  97. package/skills/doctor/SKILL.md +58 -11
  98. package/skills/doctor/report.md +3 -1
  99. package/skills/install/SKILL.md +11 -9
  100. package/skills/intent-continuing/SKILL.md +142 -91
  101. package/skills/intent-continuing/references/boarding-matrix.md +34 -0
  102. package/skills/intent-continuing/references/context-management.md +3 -3
  103. package/skills/intent-creating/SKILL.md +2 -2
  104. package/skills/intent-creating/references/lifecycle.md +1 -1
  105. package/skills/intent-ending/SKILL.md +34 -37
  106. package/skills/intent-ending/evals/evals.json +4 -4
  107. package/skills/intent-executing/SKILL.md +15 -46
  108. package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
  109. package/skills/intent-speccing/SKILL.md +108 -51
  110. package/skills/intent-speccing/evals/evals.json +5 -5
  111. package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
  112. package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
  113. package/skills/project-creating/SKILL.md +1 -1
  114. package/skills/releasing/SKILL.md +7 -8
  115. package/skills/tutorial/SKILL.md +2 -2
  116. package/skills/tutorial/references/track-1-guided.md +16 -18
  117. package/skills/tutorial/references/track-2-auto.md +12 -13
  118. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
  119. package/templates/agents.md +4 -7
  120. package/templates/config.yml +5 -0
  121. package/templates/intent-screen.md +17 -0
  122. package/templates/outcome.md +14 -1
  123. package/templates/project.yml +7 -0
  124. package/templates/report-state.md +11 -0
  125. package/templates/roadmap.md +1 -1
  126. package/templates/savepoint.md +2 -2
  127. package/templates/session-intent.md +29 -0
  128. package/agents/plastic-brainstorming.md +0 -48
  129. package/agents/plastic-future-intent-researcher.md +0 -35
  130. package/agents/plastic-intent-curator.md +0 -47
  131. package/agents/plastic-intent-discovery.md +0 -43
  132. package/agents/plastic-planner.md +0 -56
  133. package/agents/plastic-spec-specialist.md +0 -53
  134. package/hooks/auto-arm +0 -5
  135. package/hooks/bash-gate +0 -3
  136. package/hooks/continue +0 -31
  137. package/hooks/edit-gates +0 -3
  138. package/hooks/future-intent-check +0 -25
  139. package/hooks/gate-check +0 -12
  140. package/hooks/power-tools +0 -8
  141. package/scripts/hook-auto-arm +0 -51
  142. package/scripts/hook-bash-gate +0 -78
  143. package/scripts/hook-code-gate +0 -38
  144. package/scripts/hook-continue +0 -48
  145. package/scripts/hook-create-gate +0 -51
  146. package/scripts/hook-edit-gates +0 -58
  147. package/scripts/hook-future-intent-check +0 -90
  148. package/scripts/hook-gate-check +0 -169
  149. package/scripts/hook-links-gate +0 -54
  150. package/scripts/hook-lock-gate +0 -47
  151. package/scripts/hook-power-tools +0 -38
  152. package/scripts/hook-savepoint-pre +0 -32
  153. package/scripts/lib/codex_edit_gates.rb +0 -138
  154. package/scripts/lib/edit_gates.rb +0 -398
  155. package/scripts/lib/links_gate.rb +0 -140
  156. package/scripts/lib/qmd_hook.rb +0 -41
  157. package/scripts/lib/spec_header.rb +0 -83
  158. package/scripts/lib/start_intent.rb +0 -296
  159. package/scripts/start-intent +0 -89
  160. package/skills/_active-intent-gate.md +0 -26
  161. package/skills/auto/references/tiers.md +0 -100
  162. package/skills/continuing/SKILL.md +0 -34
  163. package/skills/continuing/evals/evals.json +0 -91
  164. package/skills/conventions/references/gates-and-enforcement.md +0 -53
  165. package/skills/conventions/references/tiers-and-dispatch.md +0 -135
  166. package/skills/doctor/references/gates-stuck-detection.md +0 -43
  167. package/skills/intent-brainstorming/SKILL.md +0 -118
  168. package/skills/intent-brainstorming/evals/evals.json +0 -67
  169. package/skills/intent-continuing/evals/evals.json +0 -145
  170. package/skills/intent-discovering/SKILL.md +0 -56
  171. package/skills/intent-grilling/SKILL.md +0 -108
  172. package/skills/intent-linking/SKILL.md +0 -128
  173. package/skills/intent-linking/evals/evals.json +0 -22
  174. package/skills/intent-linking/references/zettelkasten.md +0 -45
  175. package/skills/intent-locking/SKILL.md +0 -64
  176. package/skills/intent-planning/SKILL.md +0 -145
  177. package/skills/intent-planning/evals/evals.json +0 -81
  178. package/skills/intent-planning/references/plan-format.md +0 -130
  179. package/skills/intent-researching/SKILL.md +0 -123
  180. package/skills/intent-researching/evals/evals.json +0 -22
  181. package/skills/intent-savepoint/SKILL.md +0 -83
  182. package/skills/intent-savepoint/references/context-management.md +0 -32
  183. package/skills/intent-starting/SKILL.md +0 -151
  184. package/skills/intent-starting/evals/evals.json +0 -117
  185. package/skills/intent-starting/references/boarding-matrix.md +0 -35
  186. package/skills/project-continuing/SKILL.md +0 -119
  187. package/skills/project-continuing/evals/evals.json +0 -100
  188. package/skills/roadmap-continuing/SKILL.md +0 -89
  189. package/skills/roadmap-continuing/evals/evals.json +0 -82
  190. package/skills/skill-creating/SKILL.md +0 -75
  191. package/skills/skill-creating/evals/evals.json +0 -108
  192. package/skills/skill-creating/references/agents.md +0 -168
  193. package/skills/skill-creating/references/defaults-first.md +0 -23
  194. package/skills/skill-creating/references/evals.md +0 -41
  195. package/skills/skill-creating/references/hooks.md +0 -251
  196. package/skills/skill-creating/references/progressive-disclosure.md +0 -176
  197. package/skills/skill-creating/references/scripts.md +0 -166
  198. package/skills/skill-creating/references/skills.md +0 -169
  199. package/skills/skill-creating/scripts/scaffold.rb +0 -313
  200. package/skills/skill-evaluating/SKILL.md +0 -141
  201. package/skills/skill-evaluating/assets/eval-template.json +0 -12
  202. package/skills/skill-evaluating/evals/evals.json +0 -75
  203. package/skills/skill-evaluating/references/convention-checks.md +0 -76
  204. package/skills/skill-evaluating/references/eval-methodology.md +0 -154
  205. package/skills/store-curating/SKILL.md +0 -64
  206. package/skills/store-curating/evals/evals.json +0 -38
  207. package/skills/store-indexing/SKILL.md +0 -93
  208. package/skills/store-indexing/evals/evals.json +0 -22
  209. package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
  210. package/skills/store-provisioning/SKILL.md +0 -55
  211. /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
  212. /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
  213. /package/skills/{intent-brainstorming → intent-speccing}/references/design-principles.md +0 -0
@@ -0,0 +1,262 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "intent_screen"
5
+
6
+ # IntentScreenAnsi (intent 316a, O2) - renders one intent screen with raw
7
+ # truecolor ANSI escapes, productionizing 318's mockup--render.rb. Standard
8
+ # library only. Calls the SAME public IntentScreen.* field methods
9
+ # scripts/intent-screen calls (store_fields, index_fields, savepoint_fields,
10
+ # checklist_items, progress_fields, next_fields, insight_fields,
11
+ # items_present?, fallback_name, step_text) and re-derives nothing, so every
12
+ # field the ANSI block prints is the identical value the plain screen prints
13
+ # (D3), just carried through a different layout.
14
+ #
15
+ # `color:` is a constructor/call argument, never an environment read (D18):
16
+ # the plain path (`color: false`) is one call away and testable without
17
+ # touching NO_COLOR or a TTY. No ENV, no Dir.pwd, no Dir.home.
18
+ #
19
+ # Harness-agnostic core: no harness assumption lives here. `markdown_safe:`
20
+ # (intent 316a1, D3/D5) is the one choice a caller supplies rather than a
21
+ # choice this module makes for itself: a display surface that passes raw
22
+ # ANSI through untouched should not inherit a concession it never needed.
23
+ # See docs/reference/harness-adapters.md for which caller asks for it and
24
+ # why.
25
+ module IntentScreenAnsi
26
+ ESC = "\e"
27
+ RESET = "#{ESC}[0m".freeze
28
+ BOLD = "#{ESC}[1m".freeze
29
+ TEAL = "#{ESC}[38;2;45;212;191m".freeze
30
+ AMBER = "#{ESC}[38;2;245;158;11m".freeze
31
+ GRAPHITE_BG = "#{ESC}[48;2;31;41;55m".freeze
32
+ MIDGREY = "#{ESC}[38;2;148;163;184m".freeze
33
+ NEARWHITE = "#{ESC}[38;2;243;244;246m".freeze
34
+
35
+ BAR_CELLS = 24
36
+ EIGHTHS = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"].freeze
37
+
38
+ # Intent 317a1 (D14): the approved design is a 115-column layout, measured
39
+ # from design--terminal-output.html:52-58 with its tags stripped.
40
+ DEFAULT_WIDTH = 115
41
+
42
+ ELLIPSIS = "…"
43
+
44
+ # Intent 317a1 (D9-D11, D15): the three-column field table's geometry.
45
+ # ANSI_RE strips escapes so padding measures what the terminal actually
46
+ # draws (D10); VALUE_COL_MAX is the design's own value-column figure and
47
+ # the owner's "50 chars" (mockup--report-screen.md:74); NOTE_FLOOR is the
48
+ # minimum note length that earns the own-line fallback when the note alone
49
+ # (not the value) is what overflows the same-line budget.
50
+ ANSI_RE = /\e\[[0-9;]*m/.freeze
51
+ VALUE_COL_MAX = 50
52
+ NOTE_FLOOR = 24
53
+
54
+ def self.visible_width(text)
55
+ text.to_s.gsub(ANSI_RE, "").length
56
+ end
57
+
58
+ def self.render(intent_dir:, store_root:, color: true, width: DEFAULT_WIDTH, markdown_safe: false)
59
+ base = File.basename(intent_dir)
60
+ id = base.split("--", 2).first
61
+ intent_text = File.read(File.join(intent_dir, "#{base}.md"))
62
+
63
+ fields = {}
64
+ fields.merge!(IntentScreen.store_fields(store_root))
65
+ status, title = IntentScreen.index_fields(store_root, id)
66
+ fields["status"] = status
67
+ fields["status.note"] = status == "unlisted" ? "no INDEX.md line names this id" : "listed under ## #{status} in INDEX.md"
68
+ fields["id"] = id
69
+ fields["name"] = title || IntentScreen.fallback_name(intent_text)
70
+ fields.merge!(IntentScreen.savepoint_fields(intent_dir, intent_text))
71
+ items = IntentScreen.checklist_items(intent_dir)
72
+ fields.merge!(IntentScreen.progress_fields(items))
73
+ fields.merge!(IntentScreen.next_fields(items, status, checklist_present: IntentScreen.items_present?(intent_dir), escape_pipes: false))
74
+ fields.merge!(IntentScreen.insight_fields(intent_text, escape_pipes: false))
75
+ fields.transform_values! { |v| markdown_safe ? clean(v) : v }
76
+
77
+ done_n = fields["progress.done"].to_i
78
+ total_n = fields["progress.total"].to_i
79
+
80
+ out = +""
81
+ out << fit("▶ #{fields['id']} · #{fields['name']}", width) { |t| styled(t, color, BOLD, NEARWHITE) }
82
+ out << "\n\n"
83
+
84
+ # The 4th column marks a row whose value is already a finished, pre-fit
85
+ # string (the Progress bar, built above from styled glyphs plus a count)
86
+ # rather than raw field text still needing `fit_plain`. Naming that
87
+ # explicitly here reads better than testing the value for a leading ESC
88
+ # byte further down, which is really just asking "is this the Progress
89
+ # row?" through a type check.
90
+ field_rows = [
91
+ ["Store", fields["store"], fields["store.note"], false],
92
+ ["Status", fields["status"], fields["status.note"], false],
93
+ ["Stage", fields["stage"], fields["stage.note"], false],
94
+ ["Savepoint", fields["savepoint"], fields["savepoint.note"], false],
95
+ ["Progress", "#{render_bar(done_n, total_n, color)} #{done_n} / #{total_n}", fields["progress.note"], true],
96
+ ["Next", fields["next"], fields["next.note"], false],
97
+ ["Insight", fields["insight"], fields["insight.note"], false],
98
+ ]
99
+ key_width = field_rows.map { |k, _, _, _| k.length }.max
100
+
101
+ out << field_table_lines(field_rows, width: width, color: color, key_width: key_width)
102
+
103
+ out << "\n"
104
+ out << fit("Steps", width) { |t| styled(t, color, BOLD, NEARWHITE) }
105
+ out << "\n\n"
106
+
107
+ if items.empty?
108
+ out << " no steps yet\n"
109
+ else
110
+ # Padded to the widest label (matrix B2): at 10+ steps "S10" is one
111
+ # column wider than "S1..S9", and without padding every badge past S9
112
+ # drifts out of column with the rows above it.
113
+ label_width = "S#{items.size}".length
114
+ items.each_with_index do |item, i|
115
+ num = "S#{i + 1}".ljust(label_width)
116
+ badge = status_cell(item[:done], color)
117
+ prefix_plain = " #{num} [ #{item[:done] ? 'done' : 'open'} ] "
118
+ text_budget = [width - prefix_plain.length, 0].max
119
+ step = IntentScreen.step_text(item[:text])
120
+ text = fit_plain(markdown_safe ? clean(step) : step, text_budget)
121
+ out << " #{num} [#{badge}] #{text}\n"
122
+ end
123
+ end
124
+
125
+ out
126
+ end
127
+
128
+ # --- shared field-table geometry (D9-D12, D14, D15) ------------------------
129
+
130
+ # The field table's basis, cap, budget arithmetic, both fallbacks, and the
131
+ # right-strip, shared by `render` above and `ScreenPaint.paint_table`'s
132
+ # `**Key**` branch, so the two renderers can never drift apart (D12) - the
133
+ # duplication this replaced was character-for-character identical apart
134
+ # from the module prefix. `rows` is `[key, value, note]`, or `[key, value,
135
+ # note, true]` when `value` is already a finished, pre-fit string (the
136
+ # Progress bar) rather than raw text still needing `fit_plain`.
137
+ #
138
+ # Notes become a third column, padded to the widest RENDERED noted value
139
+ # (capped at VALUE_COL_MAX, D15) so every note starts at one raw-text
140
+ # position; a row whose value or note will not fit drops to the
141
+ # note-on-its-own-line form instead of squeezing anything invisibly. When
142
+ # the same-line budget has already collapsed to zero or less, the note
143
+ # falls straight to its own line rather than being fed to `fit` and
144
+ # silently returning "" - the note must be cut with a visible ellipsis or
145
+ # kept whole, never dropped outright (317a1 post-exec review, finding 4).
146
+ def self.field_table_lines(rows, width:, color:, key_width:)
147
+ prefix_width = key_width + 4 # " " + key.ljust + " "
148
+
149
+ rendered_rows = rows.map do |key, value, note, prebuilt|
150
+ value_budget = [width - prefix_width, 0].max
151
+ value_text = prebuilt ? value : fit_plain(value.to_s, value_budget)
152
+ [key.to_s, value_text, note.to_s]
153
+ end
154
+
155
+ noted_widths = rendered_rows.filter_map { |_, value_text, note| visible_width(value_text) unless note.empty? }
156
+ value_col = [noted_widths.max.to_i, VALUE_COL_MAX].min
157
+ note_budget = width - prefix_width - value_col - 2
158
+
159
+ out = +""
160
+ rendered_rows.each do |key, value_text, note|
161
+ row = " #{styled(key.ljust(key_width), color, BOLD)} #{value_text}"
162
+ if note.empty?
163
+ out << row.rstrip << "\n"
164
+ next
165
+ end
166
+
167
+ value_fits = visible_width(value_text) <= value_col
168
+ note_width = visible_width(note)
169
+ note_overflows = note_width > note_budget
170
+ note_fits = !note_overflows || (note_budget.positive? && note_width <= NOTE_FLOOR)
171
+ if value_fits && note_fits
172
+ pad = value_col - visible_width(value_text)
173
+ out << row << (" " * pad) << " " << fit(note, note_budget) { |t| styled(t, color, MIDGREY) } << "\n"
174
+ else
175
+ out << row.rstrip << "\n"
176
+ own_budget = [width - prefix_width, 0].max
177
+ out << (" " * prefix_width) << fit(note, own_budget) { |t| styled(t, color, MIDGREY) } << "\n"
178
+ end
179
+ end
180
+ out
181
+ end
182
+
183
+ # --- markdown-noise stripping, adapter-optional (intent 316a1, D3/D5) ------
184
+ #
185
+ # Not every display surface passes text through a Markdown renderer, so
186
+ # stripping is not this module's call to make (see `markdown_safe:` on
187
+ # `render` above; the justification for WHY a caller would ever ask for
188
+ # this lives with that caller, in scripts/lib/message_display.rb). When
189
+ # asked, strips backticks and neutralises `*`/`_` runs from a value.
190
+ # Single underscores are left alone: they are common inside ordinary words
191
+ # (`intent_screen.rb`) and GFM does not treat an intraword underscore as
192
+ # emphasis; only a run of 2+ (the bold marker `__`) is markdown-active.
193
+ def self.clean(text)
194
+ text.to_s.delete("`*").gsub(/_{2,}/, "")
195
+ end
196
+
197
+ # --- width cap (D15, matrix 18) --------------------------------------------
198
+
199
+ # Truncates `text` to `max` visible columns with a trailing ellipsis when
200
+ # cut, then yields the truncated plain text to the block for coloring.
201
+ # Coloring never adds visible width. The cap itself is harness-neutral: a
202
+ # fixed width, not a re-flow, is what lets a column layout survive whatever
203
+ # display eventually shows it — no display's own wrapping is assumed here.
204
+ def self.fit(text, max)
205
+ plain = fit_plain(text, max)
206
+ block_given? ? yield(plain) : plain
207
+ end
208
+
209
+ def self.fit_plain(text, max)
210
+ return "" if max <= 0
211
+ return text if text.length <= max
212
+ return ELLIPSIS[0, max] if max <= 1
213
+
214
+ "#{text[0, max - 1]}#{ELLIPSIS}"
215
+ end
216
+
217
+ # --- palette ----------------------------------------------------------------
218
+
219
+ def self.styled(text, color, *codes)
220
+ return text unless color
221
+
222
+ "#{codes.join}#{text}#{RESET}"
223
+ end
224
+
225
+ def self.status_cell(done, color)
226
+ label = done ? " done " : " open "
227
+ return label unless color
228
+
229
+ hue = done ? TEAL : AMBER
230
+ "#{hue}#{BOLD}#{label}#{RESET}"
231
+ end
232
+
233
+ # `.dup` matters, not just style (318's own note, carried forward): these
234
+ # constants are built via string interpolation, which frozen_string_literal
235
+ # does NOT freeze automatically — only static literals get that. `.freeze`
236
+ # above makes them immutable, but `bar << ...` below still needs its OWN
237
+ # mutable copy or it would raise (or, without the freeze, silently corrupt
238
+ # the shared constant for every later call in the same process — matrix 16).
239
+ def self.render_bar(done, total, color)
240
+ ratio = total.zero? ? 0.0 : done.to_f / total
241
+
242
+ unless color
243
+ on = total.zero? ? 0 : (done * BAR_CELLS) / total
244
+ return ("#" * on) + ("." * (BAR_CELLS - on))
245
+ end
246
+
247
+ units = (ratio * BAR_CELLS * 8).round.clamp(0, BAR_CELLS * 8)
248
+ full, rem = units.divmod(8)
249
+ full = [full, BAR_CELLS].min
250
+
251
+ bar = TEAL.dup
252
+ bar << ("█" * full)
253
+ if full < BAR_CELLS && rem.positive?
254
+ bar << EIGHTHS[rem]
255
+ full += 1
256
+ end
257
+ track = BAR_CELLS - full
258
+ bar << GRAPHITE_BG << (" " * track) if track.positive?
259
+ bar << RESET
260
+ bar
261
+ end
262
+ end
@@ -49,11 +49,11 @@ module Lock
49
49
  DELEGATE_ACTIVITY_LIMIT = 20
50
50
  DELEGATE_STATUSES = %w[active finished failed].freeze
51
51
 
52
- # Lease TTL. Heartbeats fire from the write-path hooks (PostToolUse
53
- # gate-check and the lock-gate allow path), so a delivering session
54
- # refreshes constantly; 30 minutes tolerates long read-only stretches
55
- # without opening a takeover window mid-delivery. Reclaim is explicit
56
- # either way (takeover), so the TTL only bounds WHEN takeover is allowed.
52
+ # Lease TTL. Heartbeats fire from the PostToolUse record hook on every write
53
+ # inside the intent dir (intent 302), so a delivering session refreshes
54
+ # constantly; 30 minutes tolerates long read-only stretches without opening
55
+ # a takeover window mid-delivery. Reclaim is explicit either way (takeover),
56
+ # so the TTL only bounds WHEN takeover is allowed.
57
57
  TTL_SECONDS = 1800
58
58
 
59
59
  # The write guard is a mutex, not a lock in the Plastic sense: it carries no
@@ -122,7 +122,7 @@ module Lock
122
122
  Array(data["delegates"]).map(&:to_s).include?(session.to_s)
123
123
  end
124
124
 
125
- # The one question gates ask: does session hold this intent's lock?
125
+ # The one question the write path asks: does session hold this intent's lock?
126
126
  # Owner/delegate on an EXISTING lock counts even when stale (a stale lock is
127
127
  # still theirs until an explicit takeover replaces it); freshness only
128
128
  # guards AGAINST other sessions.
@@ -676,24 +676,4 @@ module Claim
676
676
  end
677
677
  end
678
678
 
679
- # Second, independent write gate at the artifact grain (intent 111 D7). Returns a
680
- # deny reason String to BLOCK, or nil to ALLOW. Composes UNDER the delivery-lock
681
- # gate: only reached after the session already holds the intent's delivery lock.
682
- # ENGAGES only when a claim file exists (dormant otherwise, so single-owner flows
683
- # and the existing suite stay green, AC7). Fails open on stale/corrupt via
684
- # fail_open?, the named contract.
685
- def claim_gate_reason(intent_dir, artifact, session:, ttl: Lock::TTL_SECONDS, now: Time.now,
686
- harness: :claude)
687
- return nil if Lock.blank?(artifact)
688
- return nil unless File.exist?(path(intent_dir, artifact)) # dormant: no claim
689
- return nil if holds_claim?(intent_dir, artifact, session: session) # you hold it
690
- return nil if fail_open?(intent_dir, artifact, ttl: ttl, now: now) # stale/corrupt: yield
691
- data = read(intent_dir, artifact)
692
- holder = data && data["owner_session"]
693
- since = data && data["acquired_at"]
694
- "artifact #{artifact} is claimed by #{holder} since #{since}; another writer holds " \
695
- "it. Back off or run #{Lock.skill_ref('plastic-doctor', harness: harness)} check the " \
696
- "lock status. If you are a distinct delegate, the owner must register you: " \
697
- "plastic-lock delegate --intent-dir #{intent_dir} --session <your-session-id>"
698
- end
699
679
  end
@@ -0,0 +1,290 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require_relative "screen_paint"
6
+
7
+ # MessageDisplay (intent 316a, O4/O5, round 3 concurrency fix) - the Claude
8
+ # Code MessageDisplay hook handler. One process per streamed chunk of every
9
+ # assistant message (D11), so it must be cheap and decide fast. Pure: every
10
+ # dependency (tmp_root, plastic_home, color, now, wait_ms, poll_ms, sleeper)
11
+ # is a constructor argument, never an ENV read, a Dir.pwd/Dir.home read, or
12
+ # the real Time.now/Kernel#sleep — the thin CLI (scripts/hook-message-display)
13
+ # is the one place allowed to read any of those.
14
+ #
15
+ # Claude adapter: Claude Code only; the core is harness-agnostic. (intent
16
+ # 316a1, D3 supersedes 316a's D6.) This is the sole caller that asks
17
+ # IntentScreenAnsi.render for `markdown_safe: true` (scripts/lib/
18
+ # intent_screen_ansi.rb) — see `finalize` below for why.
19
+ #
20
+ # A live run under a real pty (round 3) found that Claude Code fires the
21
+ # per-chunk hook processes CONCURRENTLY, not strictly in order. Chunk 0 is
22
+ # the one that recognizes the screen and creates the buffer (D13), and it can
23
+ # lose the race to chunks with a higher index: they would find no buffer yet
24
+ # and pass their raw Markdown straight through, producing a half plain /
25
+ # half styled screen. This class now survives that:
26
+ #
27
+ # - One file per chunk (index-named), written atomically (temp name in the
28
+ # same directory, then File.rename), so reassembly never depends on
29
+ # arrival order — only on the index each chunk already carries.
30
+ # - A decision file written BEFORE anything slow: chunk 0 writes SCREEN
31
+ # (the resolved intent dir + store root) the moment it engages, or
32
+ # NOSCREEN the moment it does not, so later chunks can decide without
33
+ # redoing any of chunk 0's work.
34
+ # - A later chunk asks a cheap, local question before ever waiting: could
35
+ # this delta plausibly be part of a screen (leading "|", "**Steps**", or
36
+ # blank)? An ordinary prose chunk arriving before SCREEN/NOSCREEN exists
37
+ # passes through at once, at zero cost. A chunk shaped like part of a
38
+ # screen polls for the decision, bounded (wait_ms/poll_ms), then fails
39
+ # open. The final chunk always waits for the decision, whatever its own
40
+ # shape, since it is the one that must not race — and it additionally
41
+ # waits (same budget) for every earlier chunk file to exist before it
42
+ # splices, returning whatever it does have rather than nothing when the
43
+ # budget runs out.
44
+ #
45
+ # Protocol (D13, preserved): chunk 0 still decides, once, before anything is
46
+ # buffered or blanked. D10 (any failure while finalizing returns the
47
+ # buffered original, never nil, never "") and D12 (color: false never
48
+ # buffers or blanks anything) are unchanged.
49
+ class MessageDisplay
50
+ # 317a (A4): engagement is grammar, not identity - any screen-family
51
+ # opener engages, with NO intent-id resolution (the roster and delay
52
+ # screens have none to resolve). ScreenPaint owns the full grammar.
53
+ ENGAGE_RE = /\A(?:##? )?[▶✔] /.freeze
54
+ BUFFER_DIR_NAME = "plastic-message-display"
55
+ BUFFER_MAX_AGE_SECONDS = 3600
56
+ SCREEN_FILE = "SCREEN"
57
+ NOSCREEN_FILE = "NOSCREEN"
58
+
59
+ def initialize(tmp_root:, plastic_home:, color:, now:, wait_ms: 300, poll_ms: 20,
60
+ sleeper: ->(seconds) { sleep(seconds) })
61
+ @tmp_root = tmp_root
62
+ @plastic_home = plastic_home
63
+ @color = color
64
+ @now = now
65
+ @wait_ms = wait_ms
66
+ @poll_ms = poll_ms
67
+ @sleeper = sleeper
68
+ end
69
+
70
+ def handle(payload)
71
+ return nil unless @color
72
+ return nil unless payload.is_a?(Hash)
73
+
74
+ prune_old_buffers
75
+
76
+ message_id = payload["message_id"].to_s
77
+ session_id = payload["session_id"].to_s
78
+ delta = payload["delta"].to_s
79
+ final = payload["final"] == true
80
+ index = payload["index"]
81
+ cwd = payload["cwd"].to_s
82
+
83
+ return nil if message_id.empty? || session_id.empty?
84
+
85
+ dir = self.class.buffer_path(tmp_root: @tmp_root, session_id: session_id, message_id: message_id)
86
+
87
+ if index == 0
88
+ handle_chunk_zero(dir, delta, cwd, final)
89
+ else
90
+ handle_later_chunk(dir, index, delta, final)
91
+ end
92
+ end
93
+
94
+ # The message directory both this class and the bash launcher (hooks/
95
+ # message-display) must agree on byte for byte (matrix 40): the launcher
96
+ # checks this exact path's existence to decide whether chunk > 0 of an
97
+ # engaged message gets handed to Ruby at all.
98
+ def self.buffer_path(tmp_root:, session_id:, message_id:)
99
+ File.join(tmp_root, BUFFER_DIR_NAME, session_id, message_id)
100
+ end
101
+
102
+ def self.chunk_path(tmp_root:, session_id:, message_id:, index:)
103
+ File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), index.to_s)
104
+ end
105
+
106
+ def self.screen_path(tmp_root:, session_id:, message_id:)
107
+ File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), SCREEN_FILE)
108
+ end
109
+
110
+ def self.noscreen_path(tmp_root:, session_id:, message_id:)
111
+ File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), NOSCREEN_FILE)
112
+ end
113
+
114
+ private
115
+
116
+ # 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
+ unless ENGAGE_RE.match?(stripped)
124
+ write_noscreen(dir)
125
+ return nil
126
+ end
127
+
128
+ write_screen(dir)
129
+ write_chunk(dir, 0, delta)
130
+ final ? finalize_final(dir, 0) : ""
131
+ end
132
+
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.
137
+ def handle_later_chunk(dir, index, delta, final)
138
+ decision = wait_for_decision(dir, gate_delta: final ? nil : delta)
139
+
140
+ return nil unless decision == :screen
141
+
142
+ write_chunk(dir, index, delta)
143
+ final ? finalize_final(dir, index) : ""
144
+ end
145
+
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:)
151
+ decision = read_decision_now(dir)
152
+ return decision if decision
153
+
154
+ return :timeout if gate_delta && !maybe_screen?(gate_delta)
155
+
156
+ max_polls_for_budget.times do
157
+ @sleeper.call(@poll_ms / 1000.0)
158
+ decision = read_decision_now(dir)
159
+ return decision if decision
160
+ end
161
+
162
+ :timeout
163
+ end
164
+
165
+ def read_decision_now(dir)
166
+ return :screen if File.exist?(File.join(dir, SCREEN_FILE))
167
+ return :noscreen if File.exist?(File.join(dir, NOSCREEN_FILE))
168
+
169
+ nil
170
+ end
171
+
172
+ # Cheap, local, no file I/O: could this chunk's own delta plausibly be
173
+ # part of an intent screen (ignoring leading whitespace)? Every chunk of
174
+ # every ordinary prose message answers no, at zero cost.
175
+ def maybe_screen?(delta)
176
+ stripped = delta.lstrip
177
+ stripped.empty? || stripped.start_with?("|") || stripped.start_with?("**")
178
+ end
179
+
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.
184
+ def finalize_final(dir, index)
185
+ wait_for_chunk_files(dir, index)
186
+
187
+ buffered = nil
188
+ begin
189
+ buffered = read_buffered_chunks(dir, index)
190
+ finalize(buffered, nil)
191
+ rescue StandardError
192
+ buffered
193
+ ensure
194
+ FileUtils.rm_rf(dir)
195
+ end
196
+ end
197
+
198
+ def wait_for_chunk_files(dir, index)
199
+ return if index <= 0
200
+
201
+ needed = (0...index).map(&:to_s)
202
+ max_polls_for_budget.times do
203
+ return if needed.all? { |n| File.exist?(File.join(dir, n)) }
204
+
205
+ @sleeper.call(@poll_ms / 1000.0)
206
+ end
207
+ end
208
+
209
+ def max_polls_for_budget
210
+ return 0 unless @poll_ms.to_f.positive?
211
+
212
+ (@wait_ms / @poll_ms.to_f).ceil
213
+ end
214
+
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|
220
+ path = File.join(dir, i.to_s)
221
+ File.exist?(path) ? File.read(path) : nil
222
+ end.join
223
+ end
224
+
225
+ # 317a (D1/B10): paint what was printed. The buffered message's screen
226
+ # region - located and bounded by ScreenPaint's own grammar - is re-laid in
227
+ # the ANSI vocabulary; prose before and after survives verbatim. A region
228
+ # the painter cannot parse returns the buffered original (A3: chunks were
229
+ # already blanked, so nil here would truncate the message to its final
230
+ # delta; nil is only for the never-engaged path in handle).
231
+ #
232
+ # markdown_safe: true (intent 316a1, D5) - Claude Code still Markdown-
233
+ # processes displayContent even inside a raw ANSI block, so the Claude
234
+ # adapter asks the harness-agnostic core to strip markdown noise. A harness
235
+ # whose display surface passes raw ANSI through untouched would ask for
236
+ # false instead.
237
+ def finalize(buffered, _decision)
238
+ lines = buffered.lines
239
+ start = lines.index { |l| ScreenPaint.classify(l) == :opener }
240
+ return buffered unless start
241
+
242
+ stop = ScreenPaint.region_end(lines, start)
243
+ painted = ScreenPaint.paint(lines[start...stop].join, color: true, markdown_safe: true)
244
+ return buffered unless painted
245
+
246
+ suffix = lines[stop..].to_a.join.sub(/\A\n+/, "")
247
+ out = +"#{lines[0...start].join}#{painted.rstrip}\n"
248
+ out << "\n#{suffix}" unless suffix.empty?
249
+ out
250
+ end
251
+
252
+ def write_chunk(dir, index, delta)
253
+ atomic_write(File.join(dir, index.to_s), delta)
254
+ end
255
+
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), "")
261
+ end
262
+
263
+ def write_noscreen(dir)
264
+ atomic_write(File.join(dir, NOSCREEN_FILE), "")
265
+ end
266
+
267
+ def atomic_write(path, content)
268
+ FileUtils.mkdir_p(File.dirname(path))
269
+ tmp_path = "#{path}.tmp#{Process.pid}-#{rand(1_000_000)}"
270
+ File.write(tmp_path, content)
271
+ File.rename(tmp_path, path)
272
+ end
273
+
274
+
275
+
276
+ def prune_old_buffers
277
+ root = File.join(@tmp_root, BUFFER_DIR_NAME)
278
+ return unless File.directory?(root)
279
+
280
+ Dir.children(root).each do |session_dir|
281
+ full = File.join(root, session_dir)
282
+ next unless File.directory?(full)
283
+
284
+ age = @now.to_i - File.mtime(full).to_i
285
+ FileUtils.rm_rf(full) if age > BUFFER_MAX_AGE_SECONDS
286
+ end
287
+ rescue StandardError
288
+ nil
289
+ end
290
+ end
@@ -1,14 +1,16 @@
1
1
  # encoding: UTF-8
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative "bridge"
4
+ require_relative "savepoint"
5
5
  require_relative "intent_validator"
6
6
 
7
7
  # OutcomeGuard - the single definition of "is outcome.md real and disposition-matched for
8
8
  # THIS close" (intent 222). Extracted verbatim from scripts/end-intent's own
9
- # outcome_guard_reason (intent 161/188) so end-intent's existing exit-2 path and doctor's new
10
- # per-intent check (intent_lifecycle_artifacts) can never independently drift on what counts
11
- # as a valid outcome.md. Pure, read-only: authors nothing regardless of outcome.
9
+ # outcome_guard_reason (intent 161/188) so its two callers, doctor's per-intent check
10
+ # (intent_lifecycle_artifacts) and end-intent's post-backfill self-check, can never drift on
11
+ # what counts as a valid outcome.md. Since intent 308 nothing blocks on it: end-intent
12
+ # backfills a missing or placeholder outcome.md first, prints a non-nil reason, and proceeds.
13
+ # Pure, read-only: authors nothing regardless of outcome.
12
14
  module OutcomeGuard
13
15
  module_function
14
16
 
@@ -23,7 +25,7 @@ module OutcomeGuard
23
25
 
24
26
  content = File.read(path)
25
27
  first_line = content.each_line.first.to_s.chomp
26
- if first_line == Bridge::PLACEHOLDER_SENTINEL
28
+ if first_line == Savepoint::PLACEHOLDER_SENTINEL
27
29
  return "outcome.md is still the scaffold placeholder (first line is the sentinel)"
28
30
  end
29
31
 
@@ -24,6 +24,15 @@ require "yaml"
24
24
  module ProjectValidator
25
25
  module_function
26
26
 
27
+ # The two enumerated flow: knobs (intent 300, spec D2). Kept as a plain
28
+ # literal here rather than a require on SessionGit: this validator only
29
+ # reports a bad value in `errors` (project.yml is still "accepted", spec
30
+ # D2's matrix row), it never blocks spawn completeness, since a bad value
31
+ # degrades gracefully at commit time (SessionGit falls back with a Note)
32
+ # rather than blocking the spawn.
33
+ FLOW_MODES = %w[direct pull_request].freeze
34
+ FLOW_WORKSPACES = %w[checkout worktree].freeze
35
+
27
36
  def validate(slug, plastic_home: File.join(Dir.home, ".plastic"))
28
37
  missing = []
29
38
  errors = []
@@ -53,7 +62,9 @@ module ProjectValidator
53
62
  rescue StandardError
54
63
  nil
55
64
  end
56
- unless parsed.is_a?(Hash)
65
+ if parsed.is_a?(Hash)
66
+ validate_flow_block(parsed, errors)
67
+ else
57
68
  missing << "project.yml (valid YAML)"
58
69
  errors << "project.yml exists at #{project_yml_path} but does not parse as YAML"
59
70
  end
@@ -88,6 +99,24 @@ module ProjectValidator
88
99
  { ok: missing.empty?, missing: missing, errors: errors }
89
100
  end
90
101
 
102
+ # Non-blocking: an unknown `mode` or `workspace` value in project.yml's
103
+ # `flow:` block, if present, is reported in `errors` but never added to
104
+ # `missing`, so it never flips `ok`.
105
+ def validate_flow_block(parsed, errors)
106
+ flow = parsed["flow"]
107
+ return unless flow.is_a?(Hash)
108
+
109
+ validate_flow_knob(flow, "mode", FLOW_MODES, errors)
110
+ validate_flow_knob(flow, "workspace", FLOW_WORKSPACES, errors)
111
+ end
112
+
113
+ def validate_flow_knob(flow, key, allowed, errors)
114
+ value = flow[key]
115
+ return if value.nil? || allowed.include?(value.to_s)
116
+
117
+ errors << "project.yml flow.#{key} is #{value.inspect}, must be one of #{allowed.join(", ")}"
118
+ end
119
+
91
120
  # Invariant 1: registered in projects.yml with a 'path'. Returns the
92
121
  # project's entry Hash, or nil when unregistered or the entry has no path.
93
122
  def registration_for(slug, plastic_home)