@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,276 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "intent_screen_ansi"
5
+
6
+ # ScreenPaint (intent 317a, D1) - the paint seam 317's Needs-you named. Parses
7
+ # the plain Markdown screens our own renderers emit (intent, state, roster,
8
+ # delivered, delay) and re-lays them out in the shipped intent-screen ANSI
9
+ # vocabulary. A parser and RE-LAYOUTER, not a colorizer (A5): the plain
10
+ # screens are pipe tables whose scaffolding rows only disappear under a
11
+ # Markdown renderer, so the painter drops them and rebuilds the layout;
12
+ # content-survival is the contract - every value and note survives, nothing
13
+ # is invented, and text it does not recognize returns nil so every caller
14
+ # fails open to plain.
15
+ #
16
+ # Harness-agnostic core: no harness assumption lives here. No ENV, no TTY. Color, width, and
17
+ # markdown_safe are caller arguments, exactly like IntentScreenAnsi before it
18
+ # (316a1); the 318 ceiling holds - the palette is IntentScreenAnsi's, no new
19
+ # colors, no box borders.
20
+ module ScreenPaint
21
+ A = IntentScreenAnsi
22
+
23
+ # A screen's first line: "## ▶ id · name", "## ✔ id · name · delivered",
24
+ # "▶ In delivery · ...", "✔ id · name · delivered in ...".
25
+ OPENER_RE = /\A(?:## )?[▶✔] .+ · /.freeze
26
+
27
+ FIELD_LINE_RE = /\A(Stage|Next|Changed|Lead|Progress)(\s{2,})(.*)\z/.freeze
28
+ STEP_LINE_RE = /\A(S\d+)\s+\[ (open|done) \]\s+(.*)\z/.freeze
29
+ TIMELINE_RE = /\A(\d\d:\d\d)\s{2}(\S+)\s{2}(.*)\z/.freeze
30
+ COUNT_LINE_RE = /\A\d+ open( · .*)?\z/.freeze
31
+ BOLD_LEAD_RE = /\A\*\*([^*]+)\*\*(.*)\z/.freeze
32
+
33
+ # Intent 317a1 (D3, D4, D5): the data-table palette. Kind and note columns
34
+ # are chosen by the table's own header, never by position; a cell whose
35
+ # text is exactly "not recorded" greys wherever it appears.
36
+ EVIDENCE_PROOF_KINDS = %w[suite red ship doctor deposits verdict].freeze
37
+ EVIDENCE_DEVIATION_KINDS = %w[deviates].freeze
38
+ NOTE_HEADERS = %w[Source Why].freeze
39
+ NOT_RECORDED = "not recorded"
40
+
41
+ module_function
42
+
43
+ # The classifier both paint and region_end share. `idx`/`opener_idx` give
44
+ # the positional rule its footing: the line right after a title is the meta
45
+ # line (delivered/delay print one), recognizable by its " · " separators.
46
+ def classify(line, idx: nil, opener_idx: nil)
47
+ text = line.chomp
48
+ stripped = text.strip
49
+ return :blank if stripped.empty?
50
+ return :opener if OPENER_RE.match?(stripped) && text == stripped
51
+ return :table if text.lstrip.start_with?("|")
52
+ return :bold if BOLD_LEAD_RE.match?(stripped) && text == stripped
53
+ return :meta if idx && opener_idx && idx == opener_idx + 1 && stripped.include?(" · ")
54
+ return :indented if text.start_with?(" ")
55
+ return :field if FIELD_LINE_RE.match?(text)
56
+ return :step if STEP_LINE_RE.match?(text)
57
+ return :timeline if TIMELINE_RE.match?(text)
58
+ return :count if COUNT_LINE_RE.match?(stripped)
59
+ return :closer if ["None", "not recorded", "No intents in delivery."].include?(stripped)
60
+ :unknown
61
+ end
62
+
63
+ # Where the screen region ends inside a larger message (B10): walk from the
64
+ # opener while every line classifies; the first unknown line - ordinary
65
+ # prose, a prose bullet - is the boundary. Never consumes past the screen.
66
+ def region_end(lines, start_idx)
67
+ i = start_idx + 1
68
+ while i < lines.length
69
+ kind = classify(lines[i], idx: i, opener_idx: start_idx)
70
+ break if kind == :unknown
71
+ # A bare "**Section**" head belongs to the screen only when what follows
72
+ # is still grammar; "**What this means**" over prose bullets is the
73
+ # model's own commentary and stays outside, unsplit (B10).
74
+ if kind == :bold && bare_bold?(lines[i]) && !grammar_follows?(lines, i, start_idx)
75
+ break
76
+ end
77
+ i += 1
78
+ end
79
+ # Trailing blanks belong to the message, not the screen.
80
+ i -= 1 while i > start_idx + 1 && lines[i - 1].strip.empty?
81
+ i
82
+ end
83
+
84
+ def bare_bold?(line)
85
+ m = BOLD_LEAD_RE.match(line.strip)
86
+ m && m[2].to_s.strip.empty?
87
+ end
88
+
89
+ def grammar_follows?(lines, idx, opener_idx)
90
+ j = idx + 1
91
+ j += 1 while j < lines.length && lines[j].strip.empty?
92
+ return false if j >= lines.length
93
+ kind = classify(lines[j], idx: j, opener_idx: opener_idx)
94
+ kind != :unknown && kind != :opener
95
+ end
96
+
97
+ # The painter. Returns the ANSI (or plain re-laid, when color: false) text,
98
+ # or nil when the input does not open with a screen title or carries a line
99
+ # outside the grammar - the caller's cue to print the original untouched.
100
+ def paint(text, color: true, width: A::DEFAULT_WIDTH, markdown_safe: false)
101
+ lines = text.to_s.lines
102
+ first_idx = lines.index { |l| !l.strip.empty? }
103
+ return nil if first_idx.nil?
104
+ return nil unless classify(lines[first_idx]) == :opener
105
+
106
+ out = +""
107
+ table = []
108
+ ok = true
109
+ indent_run = 0
110
+
111
+ flush = lambda do
112
+ next if table.empty?
113
+ out << paint_table(table, color: color, width: width, markdown_safe: markdown_safe)
114
+ table.clear
115
+ end
116
+
117
+ lines.each_with_index do |line, idx|
118
+ kind = classify(line, idx: idx, opener_idx: first_idx)
119
+ # Intent 317a1 (O6, D8): the run counter tracks consecutive :indented
120
+ # lines so only the SECOND and later lines under a heading like
121
+ # "**Asked**" grey as a note; a :table line (which `next`s below) must
122
+ # reset it too, so a later unrelated indented block starts fresh.
123
+ indent_run = kind == :indented ? indent_run + 1 : 0
124
+ if kind == :table
125
+ table << line.strip
126
+ next
127
+ end
128
+ flush.call
129
+ case kind
130
+ when :opener
131
+ t = clean(line.strip.sub(/\A## /, ""), markdown_safe)
132
+ out << A.fit(t, width) { |s| A.styled(s, color, A::BOLD, A::NEARWHITE) } << "\n"
133
+ when :meta
134
+ out << A.fit(clean(line.strip, markdown_safe), width) { |s| A.styled(s, color, A::MIDGREY) } << "\n"
135
+ when :bold
136
+ m = BOLD_LEAD_RE.match(line.strip)
137
+ head = A.styled(clean(m[1], markdown_safe), color, A::BOLD, A::NEARWHITE)
138
+ out << head << clean(m[2], markdown_safe) << "\n"
139
+ when :indented
140
+ text = A.fit_plain(clean(line.chomp, markdown_safe), width)
141
+ out << (indent_run > 1 ? A.styled(text, color, A::MIDGREY) : text) << "\n"
142
+ when :field
143
+ m = FIELD_LINE_RE.match(line.chomp)
144
+ out << A.styled(m[1].ljust(8), color, A::BOLD) << " " << clean(m[3], markdown_safe) << "\n"
145
+ when :step
146
+ m = STEP_LINE_RE.match(line.chomp)
147
+ badge = A.status_cell(m[2] == "done", color)
148
+ out << "#{m[1].ljust(4)} [#{badge}] #{A.fit_plain(clean(m[3], markdown_safe), width - 12)}\n"
149
+ when :timeline
150
+ # Intent 317a1 (O5, D6, D7): the time carries no escape at all; the
151
+ # kind label carries the color - amber for the review turning point,
152
+ # teal for a landed commit, mid-grey (no bold) otherwise.
153
+ m = TIMELINE_RE.match(line.chomp)
154
+ codes = case m[2]
155
+ when "Review" then [A::BOLD, A::AMBER]
156
+ when "Commit" then [A::BOLD, A::TEAL]
157
+ else [A::MIDGREY]
158
+ end
159
+ out << m[1] << " " << A.styled(m[2].ljust(6), color, *codes) << " " << clean(m[3], markdown_safe) << "\n"
160
+ when :count
161
+ out << A.fit(line.strip, width) { |s| A.styled(s, color, A::MIDGREY) } << "\n"
162
+ when :closer
163
+ out << A.styled(line.strip, color, A::MIDGREY) << "\n"
164
+ when :blank
165
+ out << "\n"
166
+ else
167
+ ok = false
168
+ break
169
+ end
170
+ end
171
+ flush.call
172
+ return nil unless ok
173
+
174
+ paint_bars(out.gsub(/\n{3,}/, "\n\n"), color)
175
+ end
176
+
177
+ # --- tables -----------------------------------------------------------------
178
+
179
+ SEPARATOR_RE = /\A\|[\s:|-]+\|?\z/.freeze
180
+
181
+ def cells_of(row)
182
+ row.split("|", -1).map(&:strip)[1..-2].to_a
183
+ end
184
+
185
+ def field_table?(rows)
186
+ rows.first&.gsub(/[\s|]/, "") == "" || cells_of(rows.first).first.to_s.start_with?("**")
187
+ end
188
+
189
+ # A field table ("| | | |" scaffold, "| **Key** | value | note |" rows)
190
+ # re-lays as the intent screen's three-column field block: bold key,
191
+ # value, and a mid-grey note that shares the line when it fits, dropping
192
+ # to its own line only as a fallback (D9-D11). A data table re-lays as
193
+ # padded columns with a bold header, done/open cells colored, no pipes
194
+ # anywhere.
195
+ def paint_table(rows, color:, width:, markdown_safe:)
196
+ rows = rows.reject { |r| SEPARATOR_RE.match?(r) || r.gsub(/[\s|]/, "").empty? }
197
+ return "" if rows.empty?
198
+
199
+ if cells_of(rows.first).first.to_s.start_with?("**")
200
+ return paint_field_table(rows, color: color, width: width, markdown_safe: markdown_safe)
201
+ end
202
+
203
+ paint_data_table(rows, color: color, markdown_safe: markdown_safe)
204
+ end
205
+
206
+ # Intent 317a1 (O3, D9-D11, D14, D15): the same three-column geometry as
207
+ # IntentScreenAnsi.render's field-row loop, on the SAME implementation
208
+ # (D12) - `IntentScreenAnsi.field_table_lines` - so the two renderers
209
+ # cannot drift apart. Notes arrive as plain text here, so `visible_width`
210
+ # equals `length`, but the shared helper is used anyway so both renderers
211
+ # read identically.
212
+ def paint_field_table(rows, color:, width:, markdown_safe:)
213
+ key_w = rows.map { |r| cells_of(r).first.to_s.gsub("*", "").length }.max
214
+ field_rows = rows.map do |row|
215
+ key, value, note = cells_of(row)
216
+ key = key.to_s.gsub("*", "")
217
+ [key, clean(value.to_s, markdown_safe), clean(note.to_s, markdown_safe)]
218
+ end
219
+ A.field_table_lines(field_rows, width: width, color: color, key_width: key_w)
220
+ end
221
+
222
+ # Intent 317a1 (O4, D3-D5): a data table's kind and note columns are chosen
223
+ # by the header, never by position; a cell whose text is exactly "not
224
+ # recorded" greys wherever it appears. `widths[ci] || 0` (rather than a
225
+ # bare `widths[ci]`) is the ragged-row guard: `ReportScreen.escape` writes
226
+ # `\|` while `cells_of` still splits on every `|`, so a row can carry more
227
+ # cells than its header without either side ever raising.
228
+ def paint_data_table(rows, color:, markdown_safe:)
229
+ grid = rows.map { |r| cells_of(r).map { |c| clean(c, markdown_safe) } }
230
+ widths = grid.first.each_index.map { |i| grid.map { |r| r[i].to_s.length }.max }
231
+ kind_col = grid.first.first == "Kind" ? 0 : nil
232
+ note_col = grid.first.index { |h| NOTE_HEADERS.include?(h) }
233
+
234
+ out = +""
235
+ grid.each_with_index do |cols, ri|
236
+ last_ci = cols.length - 1
237
+ cells = cols.each_with_index.map do |cell, ci|
238
+ padded = ci == last_ci ? cell.to_s : cell.to_s.ljust(widths[ci] || 0)
239
+ # An empty cell never gets styled (317a1 post-exec review, finding
240
+ # 1): `A.styled("", ...)` still emits a color-open/RESET pair around
241
+ # nothing visible, and that hides the join separator's own trailing
242
+ # spaces from `.rstrip` below, on the very line the reader sees.
243
+ next padded if cell.to_s.empty?
244
+ if ri.zero?
245
+ A.styled(padded, color, A::BOLD)
246
+ elsif ci == kind_col && EVIDENCE_PROOF_KINDS.include?(cell)
247
+ A.styled(padded, color, A::TEAL, A::BOLD)
248
+ elsif ci == kind_col && EVIDENCE_DEVIATION_KINDS.include?(cell)
249
+ A.styled(padded, color, A::AMBER, A::BOLD)
250
+ elsif ci == note_col
251
+ A.styled(padded, color, A::MIDGREY)
252
+ elsif cell == "done"
253
+ A.styled(padded, color, A::TEAL)
254
+ elsif cell == "open"
255
+ A.styled(padded, color, A::AMBER)
256
+ elsif cell == NOT_RECORDED
257
+ A.styled(padded, color, A::MIDGREY)
258
+ else
259
+ padded
260
+ end
261
+ end
262
+ out << " " << cells.join(" ").rstrip << "\n"
263
+ end
264
+ out
265
+ end
266
+
267
+ def paint_bars(text, color)
268
+ return text unless color
269
+ text.gsub(/█+/) { |run| "#{A::TEAL}#{run}#{A::RESET}" }
270
+ .gsub(/░+/) { |run| "#{A::MIDGREY}#{run}#{A::RESET}" }
271
+ end
272
+
273
+ def clean(text, markdown_safe)
274
+ markdown_safe ? A.clean(text) : text
275
+ end
276
+ end
@@ -0,0 +1,184 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # SessionBackfill (intent 301): file a session day ledger. The four backfilled
5
+ # documents are a pure function of the day's checklist.md and savepoint.md, so
6
+ # every run regenerates them in full and a rerun is correct by construction.
7
+ # The carry step deduplicates against the target day before it appends and
8
+ # flips the source line after, so a crash at any point leaves a rerun correct.
9
+ # Every ledger write goes through SessionLedger. No environment reads here;
10
+ # the CLI passes every path in.
11
+
12
+ require "date"
13
+ require "fileutils"
14
+ require "time"
15
+ require_relative "session_ledger"
16
+
17
+ module SessionBackfill
18
+ module_function
19
+
20
+ DOCS = ["spec.md", "plan.md", "actions/ACTION_1.md", "outcome.md"].freeze
21
+ SAVEPOINT_RE = /\A(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)\s{2,}(\S+)(?:\s{2,}(.*))?\z/m.freeze
22
+ CARRIED_RE = / \(carried from \d{8}\)\z/.freeze
23
+ FILER_SESSION = "filer"
24
+ FILER_PROJECT = "global"
25
+ SUMMARY_CAP = 200
26
+
27
+ # --- reading ---------------------------------------------------------------
28
+
29
+ def parse_savepoint_line(line)
30
+ match = SAVEPOINT_RE.match(line.to_s.chomp.scrub)
31
+ return nil unless match
32
+
33
+ { timestamp: match[1], event: match[2], rest: match[3].to_s }
34
+ end
35
+
36
+ def read_items(dir)
37
+ path = File.join(dir, "checklist.md")
38
+ SessionLedger.read_locked(path).each_line.filter_map { |l| SessionLedger.parse_checklist_line(l) }
39
+ end
40
+
41
+ def read_savepoint_lines(dir)
42
+ path = File.join(dir, "savepoint.md")
43
+ SessionLedger.read_locked(path).each_line.map(&:chomp).reject(&:empty?)
44
+ end
45
+
46
+ # --- rendering, pure ---------------------------------------------------------
47
+
48
+ def format_item(item)
49
+ marker = SessionLedger::STATES.fetch(item[:state])
50
+ "- [#{marker}] [#{item[:session]}] [#{item[:project]}] #{item[:summary]}"
51
+ end
52
+
53
+ def section(title, lines)
54
+ body = lines.empty? ? "(none)" : lines.join("\n")
55
+ "## #{title}\n#{body}\n"
56
+ end
57
+
58
+ def render(dir, day:)
59
+ items = read_items(dir)
60
+ steps = read_savepoint_lines(dir)
61
+ requests = items.reject { |i| i[:state] == :dropped }.map { |i| "- [#{i[:session]}] [#{i[:project]}] #{i[:summary]}" }
62
+ done = items.select { |i| i[:state] == :done }.map { |i| format_item(i) }
63
+ carried = items.select { |i| i[:state] == :moved }.map { |i| format_item(i) }
64
+ promoted = items.select { |i| i[:state] == :promoted }.map { |i| format_item(i) }
65
+ disposition = done.empty? ? "abandoned" : "delivered"
66
+
67
+ {
68
+ "spec.md" => "# Spec: session ledger #{day}\n\n#{section('Requests', requests)}",
69
+ "plan.md" => "# Plan: session ledger #{day}\n\n#{section('Steps', steps.map { |s| "- #{s}" })}",
70
+ "actions/ACTION_1.md" => "# ACTION_1: session ledger #{day}\n\n#{section('Items', items.map { |i| format_item(i) })}",
71
+ "outcome.md" => "---\ndisposition: #{disposition}\n---\n# Outcome: session ledger #{day}\n\n" \
72
+ "#{section('Delivered', done)}\n#{section('Carried', carried)}\n#{section('Promoted', promoted)}",
73
+ }
74
+ end
75
+
76
+ # --- writing, atomic inside the day dir ----------------------------------------
77
+
78
+ def write_atomic(dir, relative, content)
79
+ target = File.join(dir, relative)
80
+ FileUtils.mkdir_p(File.dirname(target))
81
+ tmp = File.join(File.dirname(target), ".filing-#{File.basename(target)}")
82
+ File.write(tmp, content)
83
+ File.rename(tmp, target)
84
+ end
85
+
86
+ def write_docs(dir, docs)
87
+ docs.each { |relative, content| write_atomic(dir, relative, content) }
88
+ end
89
+
90
+ # --- closed marker ---------------------------------------------------------------
91
+
92
+ def closed_at(day_file)
93
+ return nil unless File.exist?(day_file)
94
+
95
+ frontmatter = File.read(day_file).split(/^---\s*$/, 3)[1].to_s
96
+ value = frontmatter[/^closed:\s*(\S+)/, 1]
97
+ value ? Time.iso8601(value) : nil
98
+ rescue ArgumentError
99
+ nil
100
+ end
101
+
102
+ def stamp_closed(day_file, now:)
103
+ stamp = "closed: #{now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')}\n"
104
+ content = File.read(day_file)
105
+ updated =
106
+ if content =~ /^closed:.*\n/
107
+ content.sub(/^closed:.*\n/, stamp)
108
+ else
109
+ parts = content.split(/^(---\s*\n)/, 3)
110
+ # parts: ["", "---\n", frontmatter, "---\n", rest]
111
+ parts.length >= 4 ? parts[0] + parts[1] + parts[2] + stamp + parts[3] + parts[4].to_s : content + stamp
112
+ end
113
+ write_atomic(File.dirname(day_file), File.basename(day_file), updated)
114
+ end
115
+
116
+ # A day is closed when it carries a closed: stamp and its checklist has not
117
+ # been written since (integer-second comparison; a line appended within the
118
+ # closing second waits for the next line).
119
+ def closed?(store, day)
120
+ closed = closed_at(SessionLedger.day_file(store, day))
121
+ return false unless closed
122
+
123
+ checklist = SessionLedger.checklist_path(store, day)
124
+ return true unless File.exist?(checklist)
125
+
126
+ File.mtime(checklist).to_i <= closed.to_i
127
+ end
128
+
129
+ # --- carry -------------------------------------------------------------------------
130
+
131
+ def carried_summary(summary, from)
132
+ suffix = " (carried from #{from})"
133
+ base = SessionLedger.sanitize_summary(summary.sub(CARRIED_RE, ""))
134
+ room = SUMMARY_CAP - suffix.length
135
+ base = "#{base[0, room - 3]}..." if base.length > room
136
+ base + suffix
137
+ end
138
+
139
+ def note(store, day, text, now:)
140
+ SessionLedger.append_line(SessionLedger.savepoint_path(store, day),
141
+ SessionLedger.savepoint_line("Note", FILER_SESSION, FILER_PROJECT, text, now: now))
142
+ end
143
+
144
+ def carry(store:, from:, to:, templates:, author:, now: Time.now)
145
+ SessionLedger.open_day(store: store, day: to, templates: templates, author: author, now: now)
146
+ source = SessionLedger.checklist_path(store, from)
147
+ target = SessionLedger.checklist_path(store, to)
148
+ existing = read_items(SessionLedger.day_dir(store, to)).map { |i| [i[:session], i[:summary]] }
149
+ count = 0
150
+ read_items(SessionLedger.day_dir(store, from)).select { |i| i[:state] == :open }.each do |item|
151
+ summary = carried_summary(item[:summary], from)
152
+ unless existing.include?([item[:session], summary])
153
+ SessionLedger.append_line(target, SessionLedger.checklist_line(:open, item[:session], item[:project], summary),
154
+ header: SessionLedger.checklist_header(to))
155
+ existing << [item[:session], summary]
156
+ end
157
+ SessionLedger.set_state(source, from: :open, to: :moved, session: item[:session], match: item[:summary])
158
+ count += 1
159
+ end
160
+ return 0 if count.zero?
161
+
162
+ note(store, from, "carried #{count} items to #{to}", now: now)
163
+ note(store, to, "received #{count} items from #{from}", now: now)
164
+ count
165
+ end
166
+
167
+ # --- the filer -----------------------------------------------------------------------
168
+
169
+ # Returns :closed when the day is already filed and unchanged, else :filed.
170
+ def file_day(store:, day:, templates:, author:, carry_to: nil, now: Time.now)
171
+ SessionLedger.open_day(store: store, day: day, templates: templates, author: author, now: now)
172
+ return :closed if closed?(store, day)
173
+
174
+ dir = SessionLedger.day_dir(store, day)
175
+ checklist = SessionLedger.checklist_path(store, day)
176
+ dropped = SessionLedger.flip_all(checklist, from: :pending, to: :dropped)
177
+ note(store, day, "dropped #{dropped} pending lines at filing", now: now) if dropped.positive?
178
+ carry(store: store, from: day, to: carry_to, templates: templates, author: author, now: now) if carry_to
179
+ write_docs(dir, render(dir, day: day))
180
+ stamp = [Time.now, (File.exist?(checklist) ? File.mtime(checklist) : Time.at(0))].max
181
+ stamp_closed(SessionLedger.day_file(store, day), now: stamp)
182
+ :filed
183
+ end
184
+ end
@@ -0,0 +1,104 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # SessionClose (intent 301): the per-session close, run by hooks/close at
5
+ # SessionEnd. Cheap, fail-open, and a no-op for the reasons that do not end a
6
+ # session (`clear`, `resume`). Every job runs in its own rescue so one failure
7
+ # never stops the next. No environment reads; the hook script passes the
8
+ # store, today's day id, and the spawner in.
9
+
10
+ require "fileutils"
11
+ require_relative "session_ledger"
12
+ require_relative "handoff"
13
+
14
+ module SessionClose
15
+ module_function
16
+
17
+ NOOP_REASONS = %w[clear resume].freeze
18
+
19
+ # The default hand-off writer (intent 311, spec D6): renders this session's
20
+ # share of the pointer day into handoff--<session>.md before the tmp dir
21
+ # goes. The hook script builds it with the shipped templates dir.
22
+ def default_handoff(templates)
23
+ lambda do |store, day, session|
24
+ Handoff.write(store: store, day: day, session: session, trigger: "close", templates: templates)
25
+ end
26
+ end
27
+
28
+ # The default spawner starts the day filer detached so a slow filing never
29
+ # blocks the harness shutdown (Codex kills a SessionEnd hook after 3 s).
30
+ def default_spawner(script)
31
+ lambda do |args|
32
+ pid = Process.spawn({ "RUBYOPT" => nil }, RbConfig.ruby, script, *args,
33
+ pgroup: true, in: File::NULL, out: File::NULL, err: File::NULL)
34
+ Process.detach(pid)
35
+ end
36
+ end
37
+
38
+ # Returns a small report hash; never raises.
39
+ def run(payload:, store:, today:, spawner:, handoff: nil, now: Time.now)
40
+ report = { reason: nil, dropped: 0, removed_tmp: false, spawned: nil, handoff: false }
41
+ reason = payload.is_a?(Hash) ? payload["reason"].to_s : ""
42
+ report[:reason] = reason
43
+ return report if NOOP_REASONS.include?(reason)
44
+
45
+ session = SessionLedger.short_session_id(payload.is_a?(Hash) ? payload["session_id"] : nil, nil)
46
+ return report if session.empty?
47
+
48
+ pointer_day = safely { read_pointer_day(store, session) } || today
49
+ project = "global"
50
+
51
+ report[:dropped] = safely do
52
+ checklist = SessionLedger.checklist_path(store, pointer_day)
53
+ count = SessionLedger.flip_all(checklist, from: :pending, to: :dropped, session: session)
54
+ if count.positive?
55
+ SessionLedger.append_line(SessionLedger.savepoint_path(store, pointer_day),
56
+ SessionLedger.savepoint_line("Note", session, project,
57
+ "dropped #{count} pending lines at close", now: now))
58
+ end
59
+ count
60
+ end || 0
61
+
62
+ # The hand-off (intent 311, spec D6) is written after the drop, so it
63
+ # reflects it, and before the tmp dir goes, since the pointer lives there.
64
+ # An intent pointer falls back to today, the same as the drop above.
65
+ if handoff
66
+ report[:handoff] = safely do
67
+ handoff.call(store, pointer_day, session)
68
+ true
69
+ end || false
70
+ end
71
+
72
+ report[:removed_tmp] = safely do
73
+ dir = SessionLedger.session_tmp_dir(store, session)
74
+ FileUtils.rm_rf(dir) if Dir.exist?(dir)
75
+ true
76
+ end || false
77
+
78
+ if pointer_day < today
79
+ report[:spawned] = safely do
80
+ args = ["--day", pointer_day, "--carry-to", today, "--store", store]
81
+ spawner.call(args)
82
+ args
83
+ end
84
+ end
85
+ report
86
+ end
87
+
88
+ # The pointer names the intent this session records into; a day id means
89
+ # the day ledger. Anything else (an intent id) means an auto team owns the
90
+ # record and the close touches no day ledger.
91
+ def read_pointer_day(store, session)
92
+ path = SessionLedger.pointer_path(store, session)
93
+ return nil unless File.exist?(path)
94
+
95
+ value = File.read(path).strip
96
+ SessionLedger.valid_day_id?(value) ? value : nil
97
+ end
98
+
99
+ def safely
100
+ yield
101
+ rescue StandardError
102
+ nil
103
+ end
104
+ end