@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.20

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 (117) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/hooks/hooks.json +12 -0
  4. package/hooks/message-display +134 -0
  5. package/hooks/statusline +4 -27
  6. package/package.json +2 -2
  7. package/scripts/agent-report +8 -2
  8. package/scripts/dashboard.rb +297 -18
  9. package/scripts/doctor.rb +652 -5
  10. package/scripts/end-intent +219 -0
  11. package/scripts/hook-capture +25 -113
  12. package/scripts/hook-message-display +81 -0
  13. package/scripts/hook-record +12 -4
  14. package/scripts/hook-session-start +23 -1
  15. package/scripts/index-projection +74 -0
  16. package/scripts/intent-screen +77 -0
  17. package/scripts/lib/action_graph_shim.rb +277 -0
  18. package/scripts/lib/arm.rb +26 -1
  19. package/scripts/lib/atomic_write.rb +31 -0
  20. package/scripts/lib/core_integrity.rb +71 -0
  21. package/scripts/lib/dashboard_screen.rb +40 -0
  22. package/scripts/lib/day_summary.rb +7 -2
  23. package/scripts/lib/doctor_core.rb +101 -5
  24. package/scripts/lib/doctor_session_ledger.rb +52 -0
  25. package/scripts/lib/graph_edges.rb +121 -0
  26. package/scripts/lib/graph_file.rb +246 -0
  27. package/scripts/lib/graph_tree.rb +98 -0
  28. package/scripts/lib/guarded_append.rb +155 -0
  29. package/scripts/lib/hook_registry.rb +14 -0
  30. package/scripts/lib/hook_replay.rb +211 -0
  31. package/scripts/lib/index_projection.rb +201 -0
  32. package/scripts/lib/installer_core.rb +131 -4
  33. package/scripts/lib/intent_screen.rb +309 -0
  34. package/scripts/lib/intent_screen_ansi.rb +262 -0
  35. package/scripts/lib/message_display.rb +586 -0
  36. package/scripts/lib/node_file.rb +214 -0
  37. package/scripts/lib/node_ids.rb +99 -0
  38. package/scripts/lib/node_ledger.rb +377 -0
  39. package/scripts/lib/node_packet.rb +886 -0
  40. package/scripts/lib/node_return.rb +199 -0
  41. package/scripts/lib/node_worktree.rb +337 -0
  42. package/scripts/lib/outcome_report.rb +440 -0
  43. package/scripts/lib/packet_wrapper.rb +132 -0
  44. package/scripts/lib/ready_set.rb +462 -0
  45. package/scripts/lib/release_guard.rb +16 -0
  46. package/scripts/lib/report_screen.rb +1967 -0
  47. package/scripts/lib/roadmap_graph.rb +210 -0
  48. package/scripts/lib/roadmap_migration.rb +95 -0
  49. package/scripts/lib/roadmap_queue.rb +155 -5
  50. package/scripts/lib/roadmap_render.rb +150 -0
  51. package/scripts/lib/roadmap_savepoint.rb +62 -12
  52. package/scripts/lib/runner_absorb.rb +620 -0
  53. package/scripts/lib/runner_answer.rb +206 -0
  54. package/scripts/lib/runner_core.rb +194 -0
  55. package/scripts/lib/runner_dispatch.rb +482 -0
  56. package/scripts/lib/runner_policy.rb +142 -0
  57. package/scripts/lib/runner_proposals.rb +254 -0
  58. package/scripts/lib/runner_rewind.rb +201 -0
  59. package/scripts/lib/runner_sweep.rb +231 -0
  60. package/scripts/lib/savepoint.rb +149 -12
  61. package/scripts/lib/screen_paint.rb +555 -0
  62. package/scripts/lib/screens/dashboard.rb +20 -0
  63. package/scripts/lib/screens/plan.rb +18 -0
  64. package/scripts/lib/screens/roadmap.rb +15 -0
  65. package/scripts/lib/session_git.rb +49 -18
  66. package/scripts/lib/session_ledger.rb +128 -0
  67. package/scripts/lib/verify_intent.rb +33 -0
  68. package/scripts/lib/work_graph_validator.rb +201 -0
  69. package/scripts/node-packet +92 -0
  70. package/scripts/node-transition +291 -0
  71. package/scripts/outcome-report +74 -0
  72. package/scripts/plastic-lock +8 -1
  73. package/scripts/ready-set +126 -0
  74. package/scripts/release-check +118 -0
  75. package/scripts/report-screen +281 -0
  76. package/scripts/roadmap-graph +119 -0
  77. package/scripts/roadmap-savepoint +7 -0
  78. package/scripts/runner +392 -0
  79. package/scripts/savepoint-note +69 -0
  80. package/scripts/spawn-preamble +9 -2
  81. package/scripts/validate-work-graph +39 -0
  82. package/scripts/verify-intent +2 -1
  83. package/skills/auto/SKILL.md +24 -17
  84. package/skills/auto/references/human-report-contract.md +136 -54
  85. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  86. package/skills/dashboard/SKILL.md +13 -2
  87. package/skills/dashboard/templates/dashboard-global.md +1 -1
  88. package/skills/dashboard/templates/dashboard-project.md +2 -2
  89. package/skills/doctor/SKILL.md +10 -4
  90. package/skills/intent-continuing/SKILL.md +51 -41
  91. package/skills/intent-continuing/references/board-fill.md +9 -0
  92. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  93. package/skills/intent-continuing/references/context-management.md +1 -1
  94. package/skills/intent-ending/SKILL.md +36 -16
  95. package/skills/intent-executing/SKILL.md +17 -5
  96. package/skills/intent-executing/implementer-prompt.md +6 -1
  97. package/skills/intent-speccing/SKILL.md +7 -4
  98. package/skills/releasing/SKILL.md +39 -0
  99. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  100. package/skills/releasing/references/release-lines.md +1 -1
  101. package/skills/roadmap/SKILL.md +26 -0
  102. package/skills/roadmap/references/file-format.md +10 -0
  103. package/templates/dashboard-screen.md +22 -0
  104. package/templates/display-fixture.md +21 -0
  105. package/templates/graph.md +16 -0
  106. package/templates/intent-screen.md +17 -0
  107. package/templates/node-decision.md +11 -0
  108. package/templates/node-research.md +11 -0
  109. package/templates/node-verify.md +13 -0
  110. package/templates/node-work.md +22 -0
  111. package/templates/outcome.md +19 -1
  112. package/templates/report-plan.md +15 -0
  113. package/templates/report-roadmap-delivered.md +10 -0
  114. package/templates/report-roadmap-plan.md +9 -0
  115. package/templates/report-roadmap-state.md +9 -0
  116. package/templates/report-state.md +11 -0
  117. package/templates/roadmap.md +13 -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