@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,255 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # Usage: hook-capture (reads the UserPromptSubmit stdin JSON payload)
6
+ # Intent 298. One UserPromptSubmit process replacing hook-continue,
7
+ # hook-future-intent-check, and hook-auto-arm: it appends a pending line to
8
+ # the session day ledger, detects "continue" and "auto" prompts, and hints at
9
+ # matching Future intents. Every job runs in its own rescue and the hook
10
+ # always exits 0 (spec D2).
11
+
12
+ require "json"
13
+ require "yaml"
14
+ require "open3"
15
+ require "fileutils"
16
+ require_relative "lib/session_ledger"
17
+ require_relative "lib/dashboard_banner"
18
+
19
+ raw = begin
20
+ $stdin.read
21
+ rescue StandardError
22
+ nil
23
+ end
24
+ exit 0 if raw.nil? || raw.strip.empty?
25
+
26
+ payload = begin
27
+ JSON.parse(raw)
28
+ rescue StandardError
29
+ nil
30
+ end
31
+ exit 0 unless payload.is_a?(Hash)
32
+
33
+ session_id = payload["session_id"].to_s
34
+ prompt = payload["prompt"].to_s
35
+ prompt = payload["user_prompt"].to_s if prompt.empty?
36
+ cwd = payload["cwd"].to_s
37
+ cwd = Dir.pwd if cwd.empty?
38
+
39
+ plastic_home = File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
40
+ store = File.join(plastic_home, "store")
41
+ templates = File.expand_path("../templates", __dir__)
42
+ sid = SessionLedger.short_session_id(nil, session_id)
43
+ today = SessionLedger.day_id
44
+
45
+ # --- Extracted matching logic, shared by both the global and project store
46
+ # passes of job (f) below (mirrors hook-future-intent-check verbatim). ---
47
+ def future_intent_matches(store_root, message)
48
+ index_path = File.join(store_root, "INDEX.md")
49
+ return [] unless File.exist?(index_path)
50
+
51
+ lines = File.readlines(index_path)
52
+ future_dirs = []
53
+ section = nil
54
+
55
+ lines.each do |line|
56
+ section = :future if line.start_with?("## Future")
57
+ section = nil if line.start_with?("## ") && !line.start_with?("## Future")
58
+ next unless section == :future && line.strip.start_with?("- [")
59
+
60
+ future_dirs << $1 if line =~ /store\/([\w-]+)\//
61
+ end
62
+ return [] if future_dirs.empty?
63
+
64
+ matches = []
65
+ message_words = message.split(/\W+/).reject { |w| w.length < 4 }
66
+
67
+ future_dirs.each do |dir|
68
+ intent_path = File.join(store_root, "store", dir, "#{dir}.md")
69
+ next unless File.exist?(intent_path)
70
+
71
+ content = File.read(intent_path)
72
+
73
+ tags = []
74
+ tags = $1.split(",").map(&:strip).map(&:downcase) if content =~ /^tags:\s*\[([^\]]+)\]/
75
+
76
+ intent_name = ""
77
+ intent_name = $1.downcase if content =~ /^intent:\s*["']?(.+?)["']?\s*$/
78
+
79
+ keywords = (tags + intent_name.split(/\W+/).reject { |w| w.length < 4 }).map(&:downcase).uniq
80
+
81
+ matched_keywords = keywords.select { |kw| message.include?(kw) }
82
+ name_matches = message_words.select { |w| intent_name.include?(w) }
83
+ matched_keywords = (matched_keywords + name_matches).uniq
84
+
85
+ next if matched_keywords.empty?
86
+
87
+ index_line = lines.find { |l| l.include?(dir) }&.strip || "#{dir} - #{intent_name}"
88
+ matches << { "index_line" => index_line, "keywords" => matched_keywords }
89
+ end
90
+ matches
91
+ end
92
+
93
+ # The store_root that carries the current project's own store, resolved from
94
+ # cwd through projects.yml, mirroring SessionLedger.project_slug's matching
95
+ # but returning nil (not "global") when nothing matches, since there is then
96
+ # no distinct second store to hint against.
97
+ def resolve_project_store_root(cwd, plastic_home)
98
+ projects_path = File.join(plastic_home, "projects.yml")
99
+ return nil unless File.exist?(projects_path)
100
+
101
+ data = begin
102
+ YAML.safe_load(File.read(projects_path))
103
+ rescue StandardError
104
+ nil
105
+ end
106
+ data = {} unless data.is_a?(Hash)
107
+ projects = data["projects"].is_a?(Hash) ? data["projects"] : {}
108
+ expanded_cwd = File.expand_path(cwd)
109
+
110
+ matches = projects.filter_map do |slug, info|
111
+ next unless info.is_a?(Hash) && slug.is_a?(String)
112
+
113
+ path = info["path"]
114
+ next unless path
115
+
116
+ root = File.expand_path(path)
117
+ next unless expanded_cwd == root || expanded_cwd.start_with?("#{root}#{File::SEPARATOR}")
118
+
119
+ [root.length, slug]
120
+ end
121
+
122
+ best = matches.max_by { |(length, _slug)| length }
123
+ best ? File.join(plastic_home, "projects", best[1]) : nil
124
+ end
125
+
126
+ def truncate(text, max)
127
+ return text if text.length <= max
128
+
129
+ "#{text[0, max - 3]}..."
130
+ end
131
+
132
+ # --- (a) tmp root + heartbeat, guarded on the pointer (spec D9) ------------
133
+ # Session start writes the `current` pointer; a capture with no pointer yet
134
+ # means session start never ran for this session at all (a `-p` print
135
+ # session, a resumed background job, an unregistered SessionStart hook), so
136
+ # neither the per-session dir nor its heartbeat is created. UserPromptSubmit
137
+ # fires before PostToolUse, so capture is the FIRST creator of every orphan
138
+ # `.tmp/<sid>/` this guard prevents; hook-record carries the same guard for
139
+ # its own (later) heartbeat write.
140
+ begin
141
+ if File.exist?(SessionLedger.pointer_path(store, sid))
142
+ SessionLedger.ensure_tmp_root(store)
143
+ FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store, sid))
144
+ File.write(SessionLedger.heartbeat_path(store, sid), "#{Time.now.utc.iso8601}\n")
145
+ end
146
+ rescue StandardError
147
+ nil
148
+ end
149
+
150
+ # --- (b) read the pointer -----------------------------------------------
151
+ skip_day_ledger = false
152
+ begin
153
+ pointer = SessionLedger.pointer_path(store, sid)
154
+ if File.exist?(pointer)
155
+ current = File.read(pointer).strip
156
+ skip_day_ledger = !current.empty? && current != today
157
+ end
158
+ rescue StandardError
159
+ nil
160
+ end
161
+
162
+ # --- (c) pending checklist line -------------------------------------------
163
+ # Gated on SessionLedger.capture_worthy?(prompt) (spec D2), a pure predicate
164
+ # over the RAW prompt so it sees the true first character and multi-line
165
+ # shape; the line text itself is still the sanitized, truncated copy.
166
+ unless skip_day_ledger
167
+ begin
168
+ if SessionLedger.capture_worthy?(prompt)
169
+ project = SessionLedger.project_slug(cwd, plastic_home: plastic_home)
170
+ clean = truncate(SessionLedger.sanitize_summary(prompt), 120)
171
+ SessionLedger.open_day(store: store, day: today, templates: templates, author: sid)
172
+ line = SessionLedger.checklist_line(:pending, sid, project, clean)
173
+ SessionLedger.append_line(SessionLedger.checklist_path(store, today), line,
174
+ header: SessionLedger.checklist_header(today))
175
+ end
176
+ rescue StandardError
177
+ nil
178
+ end
179
+ end
180
+
181
+ context_parts = []
182
+ system_message = nil
183
+
184
+ # --- (d) "continue" cockpit -------------------------------------------------
185
+ begin
186
+ if prompt.match?(/\bcontinue\b/i)
187
+ dashboard = File.expand_path("dashboard.rb", __dir__)
188
+ if File.exist?(dashboard)
189
+ cockpit, _err, status = Open3.capture3({ "RUBYOPT" => nil }, "ruby", dashboard, "continue")
190
+ if status.success? && !cockpit.strip.empty?
191
+ context_parts << (cockpit.rstrip +
192
+ "\n\nFollow the plastic-intent-continuing skill workflow to resume: " \
193
+ "if active intents exist, read their state and resume; otherwise surface the " \
194
+ "most-valuable next work and any stale or triage intents from the board above.")
195
+
196
+ begin
197
+ data_json, _data_err, data_status = Open3.capture3({ "RUBYOPT" => nil }, "ruby", dashboard,
198
+ "continue", "--data")
199
+ if data_status.success?
200
+ banner = DashboardBanner.render(JSON.parse(data_json))
201
+ system_message = banner if banner
202
+ end
203
+ rescue StandardError
204
+ nil
205
+ end
206
+ end
207
+ end
208
+ end
209
+ rescue StandardError
210
+ nil
211
+ end
212
+
213
+ # --- (e) auto-trigger steering (hook-auto-arm job 1 only) ------------------
214
+ begin
215
+ message_lower = prompt.downcase
216
+ auto_triggers = ["take it from here", "deliver this", "/plastic-auto", "auto mode"].freeze
217
+ triggered = auto_triggers.any? { |p| message_lower.include?(p) } || message_lower.match?(/\bauto\b/)
218
+ if triggered
219
+ context_parts << "Auto-mode request detected. Invoke the plastic-auto skill to deliver the " \
220
+ "active intent autonomously - it takes the intent under a lock and a worktree " \
221
+ "and points this session at it."
222
+ end
223
+ rescue StandardError
224
+ nil
225
+ end
226
+
227
+ # --- (f) Future-intent hint, global store then the project's own store -----
228
+ begin
229
+ message = prompt.to_s.downcase
230
+ if message.strip.length >= 10 && message.strip != "continue"
231
+ matches = future_intent_matches(plastic_home, message)
232
+ project_root = resolve_project_store_root(cwd, plastic_home)
233
+ matches += future_intent_matches(project_root, message) if project_root
234
+
235
+ if matches.any?
236
+ parts = ["PLASTIC - Future intents related to this message:\n"]
237
+ matches.each { |m| parts << "#{m["index_line"]} (matched: #{m["keywords"].join(", ")})" }
238
+ parts << "\nConsider asking the user if they want to activate any of these, or note the connection."
239
+ context_parts << parts.join("\n")
240
+ end
241
+ end
242
+ rescue StandardError
243
+ nil
244
+ end
245
+
246
+ exit 0 if context_parts.empty?
247
+
248
+ payload_out = {
249
+ "hookSpecificOutput" => {
250
+ "hookEventName" => "UserPromptSubmit",
251
+ "additionalContext" => context_parts.join("\n\n")
252
+ }
253
+ }
254
+ payload_out["systemMessage"] = system_message if system_message
255
+ puts JSON.generate(payload_out)
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # hook-close (intent 301): the SessionEnd hook. Reads the harness payload from
6
+ # stdin (session_id, cwd, reason), takes the Plastic home from argv, and runs
7
+ # SessionClose with the detached filer spawner. Always exits 0, prints nothing.
8
+ #
9
+ # Usage: hook-close <plastic_home> (stdin: the hook JSON)
10
+
11
+ require "json"
12
+ require_relative "lib/session_ledger"
13
+ require_relative "lib/session_close"
14
+
15
+ plastic_home = ARGV[0]
16
+ exit 0 if plastic_home.nil? || plastic_home.empty?
17
+
18
+ payload =
19
+ begin
20
+ raw = $stdin.tty? ? "" : $stdin.read
21
+ raw.to_s.strip.empty? ? {} : JSON.parse(raw)
22
+ rescue JSON::ParserError
23
+ {}
24
+ end
25
+
26
+ store = File.join(File.expand_path(plastic_home), "store")
27
+ filer = File.expand_path("file-session-intent", __dir__)
28
+ templates = File.expand_path("../templates", __dir__)
29
+ begin
30
+ SessionClose.run(payload: payload, store: store, today: SessionLedger.day_id,
31
+ spawner: SessionClose.default_spawner(filer),
32
+ handoff: SessionClose.default_handoff(templates))
33
+ rescue StandardError
34
+ nil
35
+ end
36
+ exit 0
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # hook-message-display (intent 316a, O4): the MessageDisplay hook CLI. Reads
6
+ # the harness's per-chunk JSON payload from stdin, hands it to MessageDisplay
7
+ # (the pure handler class), and prints the hookSpecificOutput envelope when a
8
+ # String comes back. Always exits 0, whatever happens — a raised exception
9
+ # here must never surface as a non-zero exit or stray stderr on an ordinary
10
+ # chunk of an ordinary message (matrix 31).
11
+ #
12
+ # This script is the one place in the O4 stack allowed to read ENV, the
13
+ # clock, or Dir.tmpdir: MessageDisplay itself takes tmp_root/plastic_home/
14
+ # color/now as constructor arguments and touches none of them directly.
15
+ #
16
+ # Claude adapter: Claude Code only; the core is harness-agnostic.
17
+
18
+ # 317a (B1): the requires sit inside their own rescue - a missing or broken
19
+ # lib file must never surface as a non-zero exit or stderr on a chunk of an
20
+ # ordinary message (LoadError is a ScriptError, outside StandardError).
21
+ begin
22
+ require "json"
23
+ require "time"
24
+ require "tmpdir"
25
+ require "yaml"
26
+ require_relative "lib/message_display"
27
+ rescue ScriptError, StandardError
28
+ exit 0
29
+ end
30
+
31
+ def color_enabled?(plastic_home)
32
+ return false unless ENV["NO_COLOR"].to_s.empty?
33
+
34
+ cfg_path = File.join(plastic_home, "config.yml")
35
+ return true unless File.exist?(cfg_path)
36
+
37
+ cfg = YAML.safe_load(File.read(cfg_path))
38
+ display = cfg.is_a?(Hash) ? cfg["display"] : nil
39
+ return true unless display.is_a?(Hash)
40
+
41
+ display.fetch("ansi_screen", true) != false
42
+ rescue StandardError
43
+ true
44
+ end
45
+
46
+ begin
47
+ raw = $stdin.tty? ? "" : $stdin.read
48
+ payload = raw.to_s.strip.empty? ? nil : JSON.parse(raw)
49
+
50
+ if payload.is_a?(Hash)
51
+ tmp_root = ENV["PLASTIC_TMP"].to_s.empty? ? Dir.tmpdir : ENV["PLASTIC_TMP"]
52
+ plastic_home = File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
53
+
54
+ handler = MessageDisplay.new(
55
+ tmp_root: tmp_root,
56
+ plastic_home: plastic_home,
57
+ color: color_enabled?(plastic_home),
58
+ now: Time.now,
59
+ )
60
+ result = handler.handle(payload)
61
+
62
+ if result.is_a?(String)
63
+ puts JSON.generate(
64
+ "hookSpecificOutput" => {
65
+ "hookEventName" => "MessageDisplay",
66
+ "displayContent" => result,
67
+ },
68
+ )
69
+ end
70
+ end
71
+ rescue StandardError
72
+ nil
73
+ end
74
+ exit 0
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # Usage: hook-record (reads the PostToolUse stdin JSON payload)
6
+ # Intent 298. Replaces hook-gate-check: never blocks, keeps the decoupled
7
+ # intent-dir savepoint append byte for byte, refreshes the delivery-lock lease
8
+ # for the owning session (intent 302, the lock gate's old allow-path job),
9
+ # promotes the session's newest pending day-ledger line when a project file
10
+ # lands, and calls the session-commit seam when that script exists. Always
11
+ # exits 0 (spec D3).
12
+
13
+ require "json"
14
+ require "timeout"
15
+ require "fileutils"
16
+ require_relative "lib/savepoint"
17
+ require_relative "lib/lock"
18
+ require_relative "lib/session_ledger"
19
+
20
+ raw = begin
21
+ $stdin.read
22
+ rescue StandardError
23
+ nil
24
+ end
25
+ exit 0 if raw.nil? || raw.strip.empty?
26
+
27
+ payload = begin
28
+ JSON.parse(raw)
29
+ rescue StandardError
30
+ nil
31
+ end
32
+ exit 0 unless payload.is_a?(Hash)
33
+
34
+ file_path = payload.dig("tool_input", "file_path") || payload.dig("tool_params", "file_path")
35
+ exit 0 if file_path.to_s.empty?
36
+
37
+ session_id = payload["session_id"].to_s
38
+ cwd = payload["cwd"].to_s
39
+ cwd = Dir.pwd if cwd.empty?
40
+
41
+ file_path_abs = File.expand_path(file_path)
42
+
43
+ # --- (a) decoupled savepoint ledger, byte for byte -------------------------
44
+ intent_dir_abs = nil
45
+ begin
46
+ intent_dir_abs = Savepoint.intent_dir_for(file_path_abs)
47
+ if intent_dir_abs
48
+ begin
49
+ Savepoint.append_savepoint(intent_dir_abs, file_path_abs)
50
+ if File.basename(file_path_abs) == "checklist.md" && Savepoint.stage_file_present?(file_path_abs)
51
+ Savepoint.append_exec_started(intent_dir_abs)
52
+ end
53
+ rescue StandardError
54
+ nil
55
+ end
56
+ # The lease heartbeat (intent 302, spec D10): the lock gate's allow path used
57
+ # to refresh delivery.lock on every edit; record does it now. Lock.heartbeat
58
+ # refuses a session that does not hold the lock, so a stranger's write never
59
+ # refreshes the owner's lease.
60
+ begin
61
+ Lock.heartbeat(intent_dir_abs, session: session_id) unless session_id.empty?
62
+ rescue StandardError
63
+ nil
64
+ end
65
+ end
66
+ rescue StandardError
67
+ nil
68
+ end
69
+
70
+ plastic_home = File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
71
+
72
+ # --- (b) stop the day-ledger path when inside ~/.plastic or an intent dir --
73
+ inside_plastic = file_path_abs == plastic_home || file_path_abs.start_with?("#{plastic_home}/")
74
+ skip_day_ledger = inside_plastic || !intent_dir_abs.nil?
75
+
76
+ # --- (c) promote the newest pending line, Item savepoint, seam -------------
77
+ unless skip_day_ledger
78
+ begin
79
+ store = File.join(plastic_home, "store")
80
+ sid = SessionLedger.short_session_id(nil, session_id)
81
+ today = SessionLedger.day_id
82
+
83
+ proceed = true
84
+ pointer = SessionLedger.pointer_path(store, sid)
85
+ if File.exist?(pointer)
86
+ current = File.read(pointer).strip
87
+ proceed = current.empty? || current == today
88
+ end
89
+
90
+ if proceed
91
+ checklist = SessionLedger.checklist_path(store, today)
92
+ target_summary = begin
93
+ SessionLedger.set_state(checklist, from: :pending, to: :open, session: sid)
94
+ rescue SessionLedger::LockUnavailableError
95
+ nil
96
+ end
97
+
98
+ if target_summary
99
+ project = SessionLedger.project_slug(cwd, plastic_home: plastic_home)
100
+ line = SessionLedger.savepoint_line("Item", sid, project, target_summary, now: Time.now)
101
+ SessionLedger.append_line(SessionLedger.savepoint_path(store, today), line, header: nil)
102
+
103
+ seam = File.expand_path("session-commit", __dir__)
104
+ if File.executable?(seam)
105
+ begin
106
+ # A bare Timeout.timeout around Open3.capture3 does not reliably bound a
107
+ # genuinely hanging child: capture3's internal wait still blocks on
108
+ # Process.waitpid for the child regardless of the raised Timeout::Error.
109
+ # Spawn directly and kill on timeout instead, so the 10s bound is real.
110
+ pid = Process.spawn({ "RUBYOPT" => nil }, seam, "--cwd", cwd, "--summary", target_summary,
111
+ "--session", sid, "--day", today,
112
+ [:out, :err] => File::NULL)
113
+ begin
114
+ Timeout.timeout(10) { Process.wait(pid) }
115
+ rescue Timeout::Error
116
+ begin
117
+ Process.kill("KILL", pid)
118
+ rescue StandardError
119
+ nil
120
+ end
121
+ begin
122
+ Process.wait(pid)
123
+ rescue StandardError
124
+ nil
125
+ end
126
+ end
127
+ rescue StandardError
128
+ nil
129
+ end
130
+ end
131
+ end
132
+ end
133
+ rescue StandardError
134
+ nil
135
+ end
136
+ end
137
+
138
+ # --- (d) heartbeat, guarded on the pointer (spec D9) -----------------------
139
+ # Session start writes the `current` pointer; with no pointer at all, session
140
+ # start never ran for this session (a `-p` print session, a resumed
141
+ # background job, an unregistered SessionStart hook), so neither the
142
+ # per-session dir nor its heartbeat is created here -- guarding only the
143
+ # write would still leave an empty directory that heartbeat_age's mtime
144
+ # fallback reports as an orphan just the same.
145
+ begin
146
+ store ||= File.join(plastic_home, "store")
147
+ sid ||= SessionLedger.short_session_id(nil, session_id)
148
+ if File.exist?(SessionLedger.pointer_path(store, sid))
149
+ SessionLedger.ensure_tmp_root(store)
150
+ FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store, sid))
151
+ File.write(SessionLedger.heartbeat_path(store, sid), "#{Time.now.utc.iso8601}\n")
152
+ end
153
+ rescue StandardError
154
+ nil
155
+ end
156
+
157
+ exit 0
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # hook-savepoint (intent 311): the PreCompact hook body. Reads the harness
6
+ # payload from stdin (session_id, cwd), takes the Plastic home from argv,
7
+ # writes this session's hand-off for the pointer's day, and prints one
8
+ # message: the fixed text, plus the written file's path when a session
9
+ # resolved (both harnesses relay the same launcher and the Codex pin feeds
10
+ # both sides the same payload, so the two stay equal). Always exits 0,
11
+ # always prints the message.
12
+ #
13
+ # Usage: hook-savepoint <plastic_home> (stdin: the hook JSON)
14
+
15
+ require "json"
16
+ require_relative "lib/session_ledger"
17
+ require_relative "lib/handoff"
18
+
19
+ MESSAGE = "PLASTIC SAVEPOINT - context is being compacted. The hand-off for this " \
20
+ "session is written in today's day ledger " \
21
+ "(store/.sessions/<day>/handoff--<session>.md). " \
22
+ "After compaction say continue to resume from it."
23
+
24
+ written = nil
25
+ begin
26
+ plastic_home = ARGV[0].to_s
27
+ raw = $stdin.tty? ? "" : $stdin.read.to_s
28
+ payload = raw.strip.empty? ? {} : JSON.parse(raw)
29
+ payload = {} unless payload.is_a?(Hash)
30
+ session_id = payload["session_id"].to_s
31
+
32
+ unless plastic_home.empty? || session_id.strip.empty?
33
+ store = File.join(File.expand_path(plastic_home), "store")
34
+ session = SessionLedger.short_session_id(session_id, nil)
35
+ day = Handoff.day_for(store, session, today: SessionLedger.day_id)
36
+ written = Handoff.write(store: store, day: day, session: session, trigger: "precompact",
37
+ templates: File.expand_path("../templates", __dir__))
38
+ end
39
+ rescue StandardError, JSON::ParserError
40
+ nil
41
+ end
42
+
43
+ message = written ? "#{MESSAGE} This session's file: #{written}" : MESSAGE
44
+ puts JSON.generate("systemMessage" => message)
45
+ exit 0