@zalom/plastic 1.14.1 → 2.0.0-alpha.2

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 (200) 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 +9 -49
  13. package/hooks/record +4 -0
  14. package/hooks/savepoint +5 -5
  15. package/package.json +3 -2
  16. package/scripts/agent-report +8 -9
  17. package/scripts/append-ledger +208 -0
  18. package/scripts/codex-hook +78 -90
  19. package/scripts/dashboard.rb +4 -4
  20. package/scripts/day-summary +53 -0
  21. package/scripts/doctor.rb +106 -43
  22. package/scripts/end-intent +77 -76
  23. package/scripts/exec-worktree +11 -20
  24. package/scripts/file-session-intent +61 -0
  25. package/scripts/hook-capture +242 -0
  26. package/scripts/hook-close +36 -0
  27. package/scripts/hook-record +149 -0
  28. package/scripts/hook-savepoint +45 -0
  29. package/scripts/hook-session-start +106 -12
  30. package/scripts/install.rb +8 -6
  31. package/scripts/lib/agent_models.rb +1 -7
  32. package/scripts/lib/arm.rb +283 -0
  33. package/scripts/lib/backfill_intent.rb +316 -0
  34. package/scripts/lib/bridge.rb +24 -1597
  35. package/scripts/lib/compact_instructions.rb +56 -0
  36. package/scripts/lib/day_summary.rb +206 -0
  37. package/scripts/lib/doctor_core.rb +65 -88
  38. package/scripts/lib/doctor_session_ledger.rb +106 -0
  39. package/scripts/lib/exec_worktree.rb +14 -60
  40. package/scripts/lib/handoff.rb +184 -0
  41. package/scripts/lib/harness_text.rb +5 -4
  42. package/scripts/lib/hook_registry.rb +44 -107
  43. package/scripts/lib/insights.rb +3 -4
  44. package/scripts/lib/installer_core.rb +104 -40
  45. package/scripts/lib/lock.rb +6 -26
  46. package/scripts/lib/outcome_guard.rb +7 -5
  47. package/scripts/lib/project_validator.rb +30 -1
  48. package/scripts/lib/roadmap_queue.rb +3 -3
  49. package/scripts/lib/roadmap_savepoint.rb +1 -1
  50. package/scripts/lib/rule_catalog.rb +6 -3
  51. package/scripts/lib/savepoint.rb +320 -0
  52. package/scripts/lib/scaffold_intent.rb +13 -179
  53. package/scripts/lib/session_backfill.rb +184 -0
  54. package/scripts/lib/session_close.rb +104 -0
  55. package/scripts/lib/session_git.rb +469 -0
  56. package/scripts/lib/session_ledger.rb +497 -0
  57. package/scripts/lib/store_discovery.rb +1 -1
  58. package/scripts/lib/store_provisioning.rb +1 -1
  59. package/scripts/maintenance-run +7 -7
  60. package/scripts/new-intent +63 -4
  61. package/scripts/plastic-lock +94 -41
  62. package/scripts/promote-session-item +150 -0
  63. package/scripts/read-config +3 -0
  64. package/scripts/rebuild-graph +1 -1
  65. package/scripts/roadmap-next +2 -2
  66. package/scripts/rollback.rb +48 -3
  67. package/scripts/scaffold-intent +37 -63
  68. package/scripts/session-commit +116 -0
  69. package/scripts/spawn-preamble +5 -5
  70. package/scripts/update.rb +7 -3
  71. package/scripts/write-handoff +60 -0
  72. package/skills/agent-advisor/SKILL.md +3 -3
  73. package/skills/agent-advisor/references/advisor-protocol.md +20 -20
  74. package/skills/auto/SKILL.md +218 -317
  75. package/skills/auto/evals/evals.json +10 -10
  76. package/skills/auto/references/agent-architecture.md +102 -90
  77. package/skills/auto/references/agent-report-contract.md +4 -21
  78. package/skills/auto/references/end-tail.md +47 -53
  79. package/skills/auto/references/human-report-contract.md +8 -8
  80. package/skills/conventions/SKILL.md +1 -3
  81. package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
  82. package/skills/conventions/references/locks-and-worktrees.md +47 -55
  83. package/skills/conventions/references/maintenance-and-revisions.md +12 -12
  84. package/skills/dashboard/SKILL.md +1 -1
  85. package/skills/direct/SKILL.md +66 -0
  86. package/skills/direct/references/request-signals.md +59 -0
  87. package/skills/doctor/SKILL.md +58 -11
  88. package/skills/doctor/report.md +3 -1
  89. package/skills/install/SKILL.md +11 -9
  90. package/skills/intent-continuing/SKILL.md +132 -89
  91. package/skills/intent-continuing/references/boarding-matrix.md +34 -0
  92. package/skills/intent-continuing/references/context-management.md +2 -2
  93. package/skills/intent-creating/SKILL.md +2 -2
  94. package/skills/intent-creating/references/lifecycle.md +1 -1
  95. package/skills/intent-ending/SKILL.md +28 -37
  96. package/skills/intent-ending/evals/evals.json +4 -4
  97. package/skills/intent-executing/SKILL.md +12 -49
  98. package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
  99. package/skills/intent-speccing/SKILL.md +108 -51
  100. package/skills/intent-speccing/evals/evals.json +5 -5
  101. package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
  102. package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
  103. package/skills/project-creating/SKILL.md +1 -1
  104. package/skills/releasing/SKILL.md +7 -8
  105. package/skills/tutorial/SKILL.md +2 -2
  106. package/skills/tutorial/references/track-1-guided.md +16 -18
  107. package/skills/tutorial/references/track-2-auto.md +12 -13
  108. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
  109. package/templates/agents.md +4 -7
  110. package/templates/config.yml +5 -0
  111. package/templates/project.yml +7 -0
  112. package/templates/roadmap.md +1 -1
  113. package/templates/savepoint.md +2 -2
  114. package/templates/session-intent.md +29 -0
  115. package/agents/plastic-brainstorming.md +0 -48
  116. package/agents/plastic-future-intent-researcher.md +0 -35
  117. package/agents/plastic-intent-curator.md +0 -47
  118. package/agents/plastic-intent-discovery.md +0 -43
  119. package/agents/plastic-planner.md +0 -56
  120. package/agents/plastic-spec-specialist.md +0 -53
  121. package/hooks/auto-arm +0 -5
  122. package/hooks/bash-gate +0 -3
  123. package/hooks/continue +0 -31
  124. package/hooks/edit-gates +0 -3
  125. package/hooks/future-intent-check +0 -25
  126. package/hooks/gate-check +0 -12
  127. package/hooks/power-tools +0 -8
  128. package/scripts/hook-auto-arm +0 -51
  129. package/scripts/hook-bash-gate +0 -78
  130. package/scripts/hook-code-gate +0 -38
  131. package/scripts/hook-continue +0 -48
  132. package/scripts/hook-create-gate +0 -51
  133. package/scripts/hook-edit-gates +0 -58
  134. package/scripts/hook-future-intent-check +0 -90
  135. package/scripts/hook-gate-check +0 -169
  136. package/scripts/hook-links-gate +0 -54
  137. package/scripts/hook-lock-gate +0 -47
  138. package/scripts/hook-power-tools +0 -38
  139. package/scripts/hook-savepoint-pre +0 -32
  140. package/scripts/lib/codex_edit_gates.rb +0 -138
  141. package/scripts/lib/edit_gates.rb +0 -398
  142. package/scripts/lib/links_gate.rb +0 -140
  143. package/scripts/lib/qmd_hook.rb +0 -41
  144. package/scripts/lib/spec_header.rb +0 -83
  145. package/scripts/lib/start_intent.rb +0 -296
  146. package/scripts/start-intent +0 -89
  147. package/skills/_active-intent-gate.md +0 -26
  148. package/skills/auto/references/tiers.md +0 -100
  149. package/skills/continuing/SKILL.md +0 -34
  150. package/skills/continuing/evals/evals.json +0 -91
  151. package/skills/conventions/references/gates-and-enforcement.md +0 -53
  152. package/skills/conventions/references/tiers-and-dispatch.md +0 -135
  153. package/skills/doctor/references/gates-stuck-detection.md +0 -43
  154. package/skills/intent-brainstorming/SKILL.md +0 -118
  155. package/skills/intent-brainstorming/evals/evals.json +0 -67
  156. package/skills/intent-continuing/evals/evals.json +0 -145
  157. package/skills/intent-discovering/SKILL.md +0 -56
  158. package/skills/intent-grilling/SKILL.md +0 -108
  159. package/skills/intent-linking/SKILL.md +0 -128
  160. package/skills/intent-linking/evals/evals.json +0 -22
  161. package/skills/intent-linking/references/zettelkasten.md +0 -45
  162. package/skills/intent-locking/SKILL.md +0 -64
  163. package/skills/intent-planning/SKILL.md +0 -145
  164. package/skills/intent-planning/evals/evals.json +0 -81
  165. package/skills/intent-planning/references/plan-format.md +0 -130
  166. package/skills/intent-researching/SKILL.md +0 -123
  167. package/skills/intent-researching/evals/evals.json +0 -22
  168. package/skills/intent-savepoint/SKILL.md +0 -83
  169. package/skills/intent-savepoint/references/context-management.md +0 -32
  170. package/skills/intent-starting/SKILL.md +0 -151
  171. package/skills/intent-starting/evals/evals.json +0 -117
  172. package/skills/intent-starting/references/boarding-matrix.md +0 -35
  173. package/skills/project-continuing/SKILL.md +0 -119
  174. package/skills/project-continuing/evals/evals.json +0 -100
  175. package/skills/roadmap-continuing/SKILL.md +0 -89
  176. package/skills/roadmap-continuing/evals/evals.json +0 -82
  177. package/skills/skill-creating/SKILL.md +0 -75
  178. package/skills/skill-creating/evals/evals.json +0 -108
  179. package/skills/skill-creating/references/agents.md +0 -168
  180. package/skills/skill-creating/references/defaults-first.md +0 -23
  181. package/skills/skill-creating/references/evals.md +0 -41
  182. package/skills/skill-creating/references/hooks.md +0 -251
  183. package/skills/skill-creating/references/progressive-disclosure.md +0 -176
  184. package/skills/skill-creating/references/scripts.md +0 -166
  185. package/skills/skill-creating/references/skills.md +0 -169
  186. package/skills/skill-creating/scripts/scaffold.rb +0 -313
  187. package/skills/skill-evaluating/SKILL.md +0 -141
  188. package/skills/skill-evaluating/assets/eval-template.json +0 -12
  189. package/skills/skill-evaluating/evals/evals.json +0 -75
  190. package/skills/skill-evaluating/references/convention-checks.md +0 -76
  191. package/skills/skill-evaluating/references/eval-methodology.md +0 -154
  192. package/skills/store-curating/SKILL.md +0 -64
  193. package/skills/store-curating/evals/evals.json +0 -38
  194. package/skills/store-indexing/SKILL.md +0 -93
  195. package/skills/store-indexing/evals/evals.json +0 -22
  196. package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
  197. package/skills/store-provisioning/SKILL.md +0 -55
  198. /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
  199. /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
  200. /package/skills/{intent-brainstorming → intent-speccing}/references/design-principles.md +0 -0
@@ -3,12 +3,13 @@
3
3
 
4
4
  require "open3"
5
5
  require_relative "bridge"
6
+ require_relative "arm"
6
7
  require_relative "lock"
7
8
  require_relative "worktree"
8
9
  require_relative "scaffold_intent"
9
10
 
10
11
  # ExecWorktree - all logic for `scripts/exec-worktree` (intent 213, group 2). Finish an
11
- # intent's code worktree: an order precondition, then `Worktree.finish` (delivered merges
12
+ # intent's code worktree: the dirty-worktree guard, then `Worktree.finish` (delivered merges
12
13
  # and removes, abandoned removes only).
13
14
  #
14
15
  # THIS IS THE HIGHEST-RISK SURFACE IN THIS ACTION SET because it composes worktree
@@ -21,22 +22,13 @@ require_relative "scaffold_intent"
21
22
  # either of the Lock module's release or takeover operations (that unguarded
22
23
  # read-modify-write gap is parked intent 254's territory).
23
24
  #
24
- # THE ORDER-PRECONDITION TRAP (spec D7, binding). `Bridge.code_gate_decision` returns nil
25
- # UNCONDITIONALLY when the bridge is guided (`build["auto"] != true`), so as a precondition
26
- # it is VACUOUS in guided mode. This module calls that predicate exactly as it exists,
27
- # unmodified, and never copies its "reached How" body into a second predicate (the 200/204
28
- # duplication failure pattern). Instead it states plainly, in the printed output, that on a
29
- # guided bridge the precondition is advisory only, and that the hook layer
30
- # (`Bridge.code_gate_decision`, wired through `scripts/lib/edit_gates.rb`) remains the
31
- # actual enforcement point.
32
- #
33
25
  # Pure and dependency-injected: never calls `exit` or `abort`, never reads `ARGV` directly.
34
26
  # Seams: `runner:` (git, defaults to `Worktree::ShellRunner.new`; also used AFTER finish, for
35
27
  # a delivered disposition, to verify from committed git state that the merge actually landed,
36
28
  # since `Worktree.finish`'s return value is discarded by design and its underlying merge is
37
29
  # fail-open, see the merge-verification note below), `finisher:` (defaults to
38
30
  # `Worktree.method(:finish)`), `status_checker:` (defaults to a `git status --porcelain`
39
- # lambda), `gate:` (defaults to `Bridge.method(:code_gate_decision)`). Every seam exists so
31
+ # lambda). Every seam exists so
40
32
  # tests run with no real git, no real worktree removal, and no real bridge file outside a
41
33
  # tmpdir.
42
34
  #
@@ -58,21 +50,10 @@ module ExecWorktree
58
50
 
59
51
  EXIT_OK = 0
60
52
  EXIT_USAGE = 1
61
- EXIT_PRECONDITION = 2
53
+ # exit 2 was the order precondition; retired in 2.0 (intent 302) and never reused
62
54
  EXIT_NOT_CLEAN = 3
63
55
  EXIT_UNRESOLVED = 4
64
56
 
65
- PROBE_FILENAME = "exec-worktree-precondition-probe"
66
-
67
- # The advisory line, verbatim (spec D7): printed whenever the order precondition
68
- # actually ran (delivered disposition, a code worktree present), on BOTH the pass and
69
- # the fail path, so no reader mistakes this script for the enforcement point.
70
- ADVISORY_LINE =
71
- "This precondition is a friendly early error, not the enforcement point. The hook " \
72
- "layer (scripts/lib/bridge.rb code_gate_decision, wired through " \
73
- "scripts/lib/edit_gates.rb) remains the actual gate. On a guided bridge this " \
74
- "precondition is advisory only."
75
-
76
57
  # --- seams (DI defaults) ----------------------------------------------------------
77
58
 
78
59
  # Real `git status --porcelain` against the code worktree, returning the same
@@ -119,8 +100,7 @@ module ExecWorktree
119
100
  false
120
101
  end
121
102
 
122
- # Session resolution order (mirrors scripts/end-intent:418-426, not
123
- # scripts/start-intent's no-fallback order): explicit --session, else
103
+ # Session resolution order (mirrors scripts/end-intent:418-426): explicit --session, else
124
104
  # CLAUDE_CODE_SESSION_ID (passed in as env_session, never read from ENV here), else
125
105
  # the existing delivery.lock's own recorded owner_session when non-blank, else nil.
126
106
  # The lock-owner fallback is correct here: this is a teardown step on an intent this
@@ -134,7 +114,7 @@ module ExecWorktree
134
114
  Bridge.blank?(owner) ? nil : owner
135
115
  end
136
116
 
137
- # --- result builders (mirror StartIntent's exit_code/stdout/stderr shape) --------
117
+ # --- result builders (the exit_code/stdout/stderr shape the thin CLIs share) --------
138
118
 
139
119
  def usage_result(message)
140
120
  { exit_code: EXIT_USAGE, stderr: ["exec-worktree: #{message}"], stdout: [] }
@@ -156,15 +136,9 @@ module ExecWorktree
156
136
  "recorded owner are all blank); refusing rather than guessing."
157
137
  end
158
138
 
159
- def no_bridge_message(intent_dir, session, id)
160
- "exec-worktree: no bridge resolved for session #{session.inspect} and intent " \
161
- "#{id.inspect} (#{File.basename(intent_dir)}); any worktree this intent provisioned " \
162
- "was NOT removed. Run /plastic-doctor to check for an orphaned worktree."
163
- end
164
-
165
139
  def nothing_provisioned_message(intent_dir)
166
- "exec-worktree: #{File.basename(intent_dir)} has no code worktree recorded on its " \
167
- "bridge (precondition: skipped, no code worktree); nothing was provisioned, nothing " \
140
+ "exec-worktree: #{File.basename(intent_dir)} has no code worktree on disk " \
141
+ "(projects.yml and the intent id resolve none); nothing was provisioned, nothing " \
168
142
  "to finish."
169
143
  end
170
144
 
@@ -195,7 +169,7 @@ module ExecWorktree
195
169
 
196
170
  # --- report (printed only when the run reaches a normal finish) ------------------
197
171
 
198
- def build_report(intent_dir:, disposition:, precondition_status:, worktree_code:, branch:,
172
+ def build_report(intent_dir:, disposition:, worktree_code:, branch:,
199
173
  target_branch:, removed:)
200
174
  # This is only ever reached for "delivered" AFTER `run` has already confirmed, by
201
175
  # reading committed git state (merged_into_current_branch?), that `branch` IS an
@@ -215,7 +189,6 @@ module ExecWorktree
215
189
  [
216
190
  "exec-worktree: #{File.basename(intent_dir)}",
217
191
  " disposition: #{disposition}",
218
- " precondition: #{precondition_status}",
219
192
  " worktree: #{worktree_code}",
220
193
  " branch: #{branch}",
221
194
  " merge: #{merge_line}",
@@ -228,8 +201,7 @@ module ExecWorktree
228
201
  def run(store:, id:, home:, disposition:, session:, env_session:,
229
202
  runner: Worktree::ShellRunner.new,
230
203
  finisher: Worktree.method(:finish),
231
- status_checker: default_status_checker,
232
- gate: Bridge.method(:code_gate_decision))
204
+ status_checker: default_status_checker)
233
205
  return usage_result("--store is required") if Bridge.blank?(store)
234
206
  return usage_result("--id is required") if Bridge.blank?(id)
235
207
  return usage_result("--home is required") if Bridge.blank?(home)
@@ -252,9 +224,7 @@ module ExecWorktree
252
224
  return deny_result(EXIT_UNRESOLVED, no_session_message(intent_dir))
253
225
  end
254
226
 
255
- bridge_data = Bridge.blank?(key_session) ? nil : Bridge.read(key_session, intent_id: id)
256
- return ok_result(no_bridge_message(intent_dir, key_session, id)) if bridge_data.nil?
257
-
227
+ bridge_data = Arm.bridge_hash(intent_dir: intent_dir, home: normalized_home)
258
228
  worktree_code = bridge_data.dig("worktree", "code")
259
229
  return ok_result(nothing_provisioned_message(intent_dir)) if Bridge.blank?(worktree_code)
260
230
 
@@ -264,22 +234,8 @@ module ExecWorktree
264
234
  # both `repo` and `branch` to work with.
265
235
  repo = repo_from_worktree_code(worktree_code)
266
236
 
267
- precondition_ran = false
268
- precondition_status = "not run (abandoned)"
269
-
270
237
  if disposition == "delivered"
271
- precondition_ran = true
272
-
273
- # Step 1: the order precondition. Call Bridge.code_gate_decision AS-IS (never
274
- # relaxed, never duplicated). See the module doc's "order-precondition trap".
275
- probe_path = File.join(worktree_code, PROBE_FILENAME)
276
- reason = gate.call(bridge_data, probe_path, home: normalized_home)
277
- if reason
278
- return deny_result(EXIT_PRECONDITION, [reason, ADVISORY_LINE])
279
- end
280
- precondition_status = bridge_data.dig("build", "auto") == true ? "passed" : "advisory (guided bridge)"
281
-
282
- # Step 2: the dirty-worktree guard, delivered only. Fail CLOSED when the check
238
+ # Step 1: the dirty-worktree guard, delivered only. Fail CLOSED when the check
283
239
  # itself fails or raises: removal force-removes on a plain failure, which would
284
240
  # destroy uncommitted work an inconclusive check could not rule out.
285
241
  out, err, status = status_checker.call(worktree_code)
@@ -290,7 +246,7 @@ module ExecWorktree
290
246
  end
291
247
  end
292
248
 
293
- # Step 3: finish. Worktree.finish is fail-open and never raises: on a merge conflict it
249
+ # Step 2: finish. Worktree.finish is fail-open and never raises: on a merge conflict it
294
250
  # aborts the merge, warns, and STILL removes the worktree (Worktree.merge_branch,
295
251
  # scripts/lib/worktree.rb:246-258). So a failed merge is invisible both in this call's
296
252
  # return value (discarded, as before) and in whether the worktree directory still
@@ -315,11 +271,9 @@ module ExecWorktree
315
271
  end
316
272
 
317
273
  report = build_report(intent_dir: intent_dir, disposition: disposition,
318
- precondition_status: precondition_status,
319
274
  worktree_code: worktree_code, branch: branch,
320
275
  target_branch: target_branch, removed: removed)
321
276
 
322
- stdout = precondition_ran ? [ADVISORY_LINE, report] : [report]
323
- ok_result(stdout)
277
+ ok_result([report])
324
278
  end
325
279
  end
@@ -0,0 +1,184 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # Handoff (intent 311): one session's hand-off, a pure rendering of its share
5
+ # of a day ledger (checklist.md and savepoint.md), written into the day
6
+ # directory as handoff--<session>.md at every tick, at PreCompact, and at
7
+ # close. Derived and regenerable: every write renders in full, so a lost or
8
+ # stale copy costs nothing. No environment reads; every path is injected.
9
+
10
+ require "fileutils"
11
+ require_relative "session_ledger"
12
+
13
+ module Handoff
14
+ module_function
15
+
16
+ TRIGGERS = %w[tick precompact close].freeze
17
+ BUDGET = 6144
18
+ OPEN_CAP = 20
19
+ DONE_CAP = 10
20
+ RECENT_CAP = 10
21
+ OTHERS_CAP = 10
22
+ # A ledger summary may run to 200 characters; a hand-off line shows the
23
+ # first 80, so the caps above are reachable inside the byte budget.
24
+ SUMMARY_MAX = 80
25
+ OPEN_STATES = %i[open pending].freeze
26
+ # Trimmed first when the budget is exceeded; Open is the last to shrink.
27
+ TRIM_ORDER = %i[others recent done open].freeze
28
+ RESUME = "Say continue; the day summary at boot and this file carry the state."
29
+
30
+ SAVEPOINT_TAIL_RE = /\A\[([^\]]*)\] \[([^\]]*)\] (.*)\z/m
31
+ private_constant :SAVEPOINT_TAIL_RE
32
+
33
+ def path_for(store, day, session)
34
+ File.join(SessionLedger.day_dir(store, day), "handoff--#{session}.md")
35
+ end
36
+
37
+ # The day this session's hand-off belongs to: the pointer's day id when
38
+ # the pointer holds one, else today (no pointer, or a pointer naming an
39
+ # intent), the same fallback SessionClose uses for the drop at close.
40
+ def day_for(store, session, today:)
41
+ path = SessionLedger.pointer_path(store, session)
42
+ return today unless File.exist?(path)
43
+
44
+ value = File.read(path).strip
45
+ SessionLedger.valid_day_id?(value) ? value : today
46
+ end
47
+
48
+ def clip(summary)
49
+ text = summary.to_s
50
+ text.length > SUMMARY_MAX ? "#{text[0, SUMMARY_MAX]}..." : text
51
+ end
52
+
53
+ # --- readers ---------------------------------------------------------------------
54
+
55
+ def read_items(store, day)
56
+ SessionLedger.read_locked(SessionLedger.checklist_path(store, day))
57
+ .each_line.filter_map { |l| SessionLedger.parse_checklist_line(l) }
58
+ end
59
+
60
+ # Parsed savepoint lines, file order: {time:, event:, session:, project:,
61
+ # summary:}. A line that does not follow the ledger grammar is skipped.
62
+ def read_savepoint(store, day)
63
+ SessionLedger.read_locked(SessionLedger.savepoint_path(store, day)).each_line.filter_map do |raw|
64
+ line = raw.chomp.scrub
65
+ next if line.empty?
66
+
67
+ time, event, rest = line.split(/\s{2,}/, 3)
68
+ next unless time && event && rest
69
+
70
+ match = SAVEPOINT_TAIL_RE.match(rest)
71
+ next unless match
72
+
73
+ { time: time, event: event, session: match[1], project: match[2], summary: match[3] }
74
+ end
75
+ end
76
+
77
+ # --- rendering, pure -----------------------------------------------------------
78
+
79
+ def render(store:, day:, session:, trigger:, now: Time.now)
80
+ raise ArgumentError, "unknown trigger: #{trigger.inspect}" unless TRIGGERS.include?(trigger)
81
+
82
+ items = read_items(store, day)
83
+ mine = items.select { |i| i[:session] == session }
84
+ lists = {
85
+ open: mine.select { |i| OPEN_STATES.include?(i[:state]) }.map { |i| item_line(i) },
86
+ done: mine.select { |i| i[:state] == :done }.map { |i| item_line(i) },
87
+ recent: read_savepoint(store, day).select { |e| e[:session] == session }.map { |e| recent_line(e) },
88
+ others: others_lines(items, session),
89
+ }
90
+ hidden = Hash.new(0)
91
+ cap!(lists, hidden, :open, OPEN_CAP)
92
+ cap!(lists, hidden, :done, DONE_CAP)
93
+ cap!(lists, hidden, :recent, RECENT_CAP)
94
+ if lists[:others].size > OTHERS_CAP
95
+ hidden[:others] += lists[:others].size - OTHERS_CAP
96
+ lists[:others] = lists[:others].first(OTHERS_CAP)
97
+ end
98
+
99
+ header = [
100
+ "# Hand-off: session #{session}, #{day}",
101
+ "",
102
+ "Written #{now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')} at #{trigger}",
103
+ "",
104
+ ]
105
+ loop do
106
+ text = compose(header, lists, hidden)
107
+ return text if text.bytesize <= BUDGET
108
+
109
+ key = TRIM_ORDER.find { |k| !lists[k].empty? }
110
+ return text unless key
111
+
112
+ # Open, Done, and Recent keep their newest entries; Others has no order.
113
+ key == :others ? lists[key].pop : lists[key].shift
114
+ hidden[key] += 1
115
+ end
116
+ end
117
+
118
+ # Keeps the newest `cap` lines (the file is chronological) and counts the rest.
119
+ def cap!(lists, hidden, key, cap)
120
+ return unless lists[key].size > cap
121
+
122
+ hidden[key] += lists[key].size - cap
123
+ lists[key] = lists[key].last(cap)
124
+ end
125
+
126
+ def compose(header, lists, hidden)
127
+ sections = [
128
+ section("Open", lists[:open], hidden[:open]),
129
+ section("Done", lists[:done], hidden[:done]),
130
+ section("Recent", lists[:recent], hidden[:recent]),
131
+ section("Others today", lists[:others], hidden[:others]),
132
+ "## Resume\n#{RESUME}\n",
133
+ ]
134
+ (header + sections.compact).join("\n")
135
+ end
136
+
137
+ def section(title, lines, hidden)
138
+ return nil if lines.empty? && hidden.zero?
139
+
140
+ body = lines.dup
141
+ body << "(+#{hidden} more)" if hidden.positive?
142
+ "## #{title}\n#{body.join("\n")}\n"
143
+ end
144
+
145
+ def item_line(item)
146
+ "- [#{item[:project]}] #{clip(item[:summary])}"
147
+ end
148
+
149
+ def recent_line(event)
150
+ "- #{event[:time][11, 5]}Z #{event[:event]} #{clip(event[:summary])}"
151
+ end
152
+
153
+ def others_lines(items, session)
154
+ items.reject { |i| i[:session] == session }
155
+ .group_by { |i| i[:session] }
156
+ .sort
157
+ .map do |sid, theirs|
158
+ open = theirs.count { |i| OPEN_STATES.include?(i[:state]) }
159
+ done = theirs.count { |i| i[:state] == :done }
160
+ "- #{sid}: #{open} open, #{done} done"
161
+ end
162
+ end
163
+
164
+ # --- writing -------------------------------------------------------------------
165
+
166
+ # Opens the day first (a tick after midnight never fails), renders, and
167
+ # writes through a per-process temp file and rename, so a crash leaves no
168
+ # partial hand-off and two writers for one session (a tick overlapping a
169
+ # PreCompact) never share a temp name. Returns the path. With
170
+ # `templates: nil` the day is not scaffolded, only its directory ensured.
171
+ def write(store:, day:, session:, trigger:, templates:, now: Time.now)
172
+ if templates
173
+ SessionLedger.open_day(store: store, day: day, templates: templates, author: session)
174
+ else
175
+ FileUtils.mkdir_p(SessionLedger.day_dir(store, day))
176
+ end
177
+ text = render(store: store, day: day, session: session, trigger: trigger, now: now)
178
+ target = path_for(store, day, session)
179
+ tmp = File.join(File.dirname(target), ".handoff-#{session}-#{Process.pid}-#{Thread.current.object_id}.tmp")
180
+ File.write(tmp, text)
181
+ File.rename(tmp, target)
182
+ target
183
+ end
184
+ end
@@ -19,9 +19,10 @@ module HarnessText
19
19
  # form the install_claude hook rewrite already writes.
20
20
  PLUGIN_ROOT_CODEX = "$HOME/.plastic"
21
21
 
22
- # Spec D3: these files document Claude's placeholder convention TO SKILL AUTHORS.
23
- # Rewriting the token there turns true sentences false, so the file is exempt by path.
24
- PLUGIN_ROOT_EXEMPT = ["skill-creating/references/hooks.md"].freeze
22
+ # Spec D3: files that document Claude's placeholder convention TO SKILL AUTHORS are
23
+ # exempt by path. The skill-authoring reference left the installed tree in 2.0
24
+ # (intent 304), so the list is empty; the seam stays for the next such file.
25
+ PLUGIN_ROOT_EXEMPT = [].freeze
25
26
 
26
27
  # Spec D4: an explicit root table, not one regex. The ~/.claude lines in the skill
27
28
  # corpus carry four different meanings; only these three have an exact Codex
@@ -46,7 +47,7 @@ module HarnessText
46
47
  # "~/.claude/hooks/plastic-session-start");
47
48
  # the name alternation, built from the real skills/ directory listing, rejects
48
49
  # anything that is not an installed skill ("/plastic-lock" is a CLI script,
49
- # "/plastic-intent-curator" is an agent role).
50
+ # "/plastic-enforcer" is an agent role).
50
51
  # Longest name first so /plastic-intent-continuing cannot match "continuing".
51
52
  def rewrite_skill_prefix(text, skill_names, prefix)
52
53
  return text if skill_names.nil? || skill_names.empty?
@@ -10,9 +10,8 @@
10
10
  module HookRegistry
11
11
  module_function
12
12
 
13
- # MCP tools that MUTATE files. Every write/lock/create gate must match them,
14
- # or symbolic edits bypass the whole gate layer (the universal MCP-edit
15
- # bypass found in 108's gate inventory).
13
+ # MCP tools that MUTATE files. The record hook must match them, or a symbolic
14
+ # edit never reaches the savepoint ledger or the day ledger.
16
15
  SERENA_EDIT_TOOLS = %w[
17
16
  mcp__serena__replace_content
18
17
  mcp__serena__replace_symbol_body
@@ -24,40 +23,6 @@ module HookRegistry
24
23
 
25
24
  WRITE_MATCHER = (%w[Write Edit NotebookEdit] + SERENA_EDIT_TOOLS).join("|")
26
25
 
27
- # Per-gate tool applicability for the merged edit-path dispatcher (intent 244,
28
- # spec D-d/D-l). Registration collapses to ONE PreToolUse hook on WRITE_MATCHER
29
- # (a strict superset of the three former matcher groups), and this table is what
30
- # keeps each gate's own coverage exactly what it was: scripts/hook-edit-gates
31
- # reads tool_name off the payload and skips any gate whose list excludes it.
32
- # Key order IS the evaluation order (spec D-a): savepoint-pre first because it
33
- # never denies and its ledger append must stay unconditional; lock-gate leads
34
- # the deniers because holding the delivery lock is the precondition the other
35
- # rules assume. A test pins this table against today's three matcher groups, so
36
- # no gate's coverage can widen or narrow silently.
37
- GATE_TOOLS = {
38
- "savepoint-pre" => %w[Write Edit].freeze,
39
- "lock-gate" => (%w[Write Edit NotebookEdit] + SERENA_EDIT_TOOLS).freeze,
40
- "code-gate" => (%w[Write Edit NotebookEdit] + SERENA_EDIT_TOOLS).freeze,
41
- "links-gate" => %w[Write Edit].freeze,
42
- "create-gate" => (%w[Write Edit] + SERENA_EDIT_TOOLS).freeze,
43
- }.freeze
44
-
45
- # Per-gate tool applicability for the merged CODEX edit-path dispatcher (intent
46
- # 251, spec D1). Codex reports exactly one file-mutation tool name, apply_patch
47
- # (intent 181 F4), so every value is the same single-entry list. The table is
48
- # NOT decoration: EditGates.tool_applies? builds a regex from these values, and
49
- # reusing GATE_TOOLS here would match none of them against "apply_patch" and
50
- # silently skip all five gates. Key order IS the evaluation order and is the
51
- # SAME order GATE_TOOLS uses (spec D5), so the two harnesses evaluate the five
52
- # gates in one order, not two.
53
- CODEX_GATE_TOOLS = {
54
- "savepoint-pre" => %w[apply_patch].freeze,
55
- "lock-gate" => %w[apply_patch].freeze,
56
- "code-gate" => %w[apply_patch].freeze,
57
- "links-gate" => %w[apply_patch].freeze,
58
- "create-gate" => %w[apply_patch].freeze,
59
- }.freeze
60
-
61
26
  # event => ordered list of { "matcher" =>, "hooks" => [{ "name" =>, "status" => }] }
62
27
  # The name is the hooks/<name> launcher; the flat install ships it as
63
28
  # ~/.claude/hooks/plastic-<name>.
@@ -74,75 +39,54 @@ module HookRegistry
74
39
  { "name" => "savepoint", "status" => "Saving Plastic intent state..." },
75
40
  ] },
76
41
  ],
77
- "PreToolUse" => [
78
- # ONE registered edit-path hook (intent 244, spec D-d). WRITE_MATCHER is a
79
- # strict superset of the three matcher groups this replaces; per-gate tool
80
- # applicability lives in GATE_TOOLS above and is read by
81
- # scripts/hook-edit-gates, so no gate's coverage widened or narrowed.
42
+ "PostToolUse" => [
82
43
  { "matcher" => WRITE_MATCHER, "hooks" => [
83
- { "name" => "edit-gates", "status" => "Checking Plastic gates..." },
84
- ] },
85
- { "matcher" => "Bash", "hooks" => [
86
- { "name" => "bash-gate", "status" => "Checking lifecycle gate..." },
44
+ { "name" => "record", "status" => "Recording Plastic session state..." },
87
45
  ] },
88
46
  ],
89
- "PostToolUse" => [
90
- { "matcher" => "Write|Edit", "hooks" => [
91
- { "name" => "gate-check", "status" => "Checking lifecycle gates..." },
47
+ "SessionEnd" => [
48
+ { "matcher" => "", "hooks" => [
49
+ { "name" => "close", "status" => "Closing the Plastic session..." },
92
50
  ] },
93
51
  ],
94
52
  "UserPromptSubmit" => [
95
53
  { "matcher" => "", "hooks" => [
96
- { "name" => "continue", "status" => "Checking for continue..." },
97
- { "name" => "future-intent-check", "status" => "Checking future intents..." },
98
- { "name" => "auto-arm", "status" => "Checking auto mode..." },
99
- { "name" => "power-tools", "status" => "Checking power tools..." },
54
+ { "name" => "capture", "status" => "Capturing prompt into the session ledger..." },
100
55
  ] },
101
56
  ],
102
57
  }
103
58
  end
104
59
 
105
60
  # Codex registration (~/.codex/hooks.json, intent 102). Derived from `events`:
106
- # the file-mutation PreToolUse gate/savepoint hooks collapse from Claude's
107
- # multi-tool matchers onto Codex's single apply_patch tool (181 F4: apply_patch
108
- # is Codex's sole file-mutation tool; tool_name always reports apply_patch), plus
109
- # the PostToolUse gate-check. Command invokes the codex-hook dispatcher with the
110
- # gate name. Guide-settled shape [guide Part 3]: top-level {"hooks":{<Event>:
111
- # [{"matcher","hooks":[{"type":"command","command","statusMessage"}]}]}},
112
- # identical to Claude's shape, string command. Single source of truth (108 D7):
113
- # any drift from `events` is a bug, pinned by test.
114
- #
115
- # Codex's apply_patch matcher carries ONE dispatcher command, edit-gates,
116
- # which runs all five gates in-process through scripts/lib/codex_edit_gates.rb
117
- # (intent 251), mirroring what intent 244 did for Claude. Five separately
118
- # registered commands used to cost eight OS processes per edit (five
119
- # top-level plus three nested run_core children); one dispatcher process
120
- # reaches the same five gate decisions.
121
- CODEX_PRE_HOOKS = %w[edit-gates].freeze
122
- CODEX_POST_HOOKS = %w[gate-check].freeze
123
-
124
- # Codex's shell-tool gate hole (intent 203): bash-gate (denies a shell write to
125
- # project code before How) belongs on the Bash matcher, and ONLY Bash: the
126
- # official Codex hooks doc's PreToolUse event catalog enumerates exactly Bash,
127
- # apply_patch, and MCP tool calls, and neither it nor the two prior Codex
128
- # research passes (198's official-docs research, 181's deep research)
129
- # documents a discrete Read, Grep, or Glob tool name (D3). Registering a tool
130
- # name Codex never reports would be dead weight that looks alive, the exact
131
- # defect this intent exists to fix.
132
- CODEX_BASH_HOOKS = %w[bash-gate].freeze
133
-
134
- # Live-state events registered WHOLE (intent 199), unlike CODEX_PRE_HOOKS/
135
- # CODEX_POST_HOOKS above: Codex's SessionStart/UserPromptSubmit/PreCompact already
136
- # match Claude's shape exactly, one matcher group each ("", no tool to collapse
137
- # onto), so every hook `events` lists under these three events projects straight
138
- # through with no allowlist to keep in sync. A hook added to any of them on the
139
- # Claude side registers for Codex automatically.
61
+ # the PostToolUse record hook collapses from Claude's multi-tool matcher onto
62
+ # Codex's single apply_patch tool (181 F4: apply_patch is Codex's sole
63
+ # file-mutation tool; tool_name always reports apply_patch), and the live-state
64
+ # events project through whole. Since intent 302 there is no PreToolUse group at
65
+ # all: the edit-path gates are gone on both harnesses. Command invokes the
66
+ # codex-hook dispatcher with the hook name. Guide-settled shape [guide Part 3]:
67
+ # top-level {"hooks":{<Event>: [{"matcher","hooks":[{"type":"command","command",
68
+ # "statusMessage"}]}]}}, identical to Claude's shape, string command. Single
69
+ # source of truth (108 D7): any drift from `events` is a bug, pinned by test.
70
+ CODEX_POST_HOOKS = %w[record].freeze
71
+
72
+ # Live-state events registered WHOLE (intent 199): Codex's SessionStart/
73
+ # UserPromptSubmit/PreCompact already match Claude's shape exactly, one matcher
74
+ # group each ("", no tool to collapse onto), so every hook `events` lists under
75
+ # these three events projects straight through with no allowlist to keep in
76
+ # sync. A hook added to any of them on the Claude side registers for Codex
77
+ # automatically.
140
78
  CODEX_LIVE_STATE_EVENTS = %w[SessionStart UserPromptSubmit PreCompact].freeze
141
79
 
80
+ # SessionEnd on Codex (intent 309): projected from its own constant, never as a fourth
81
+ # live-state event, because the dispatch differs. Codex kills a SessionEnd hook after
82
+ # 3 seconds, so scripts/codex-hook hands `close` to its launcher detached and returns at
83
+ # once instead of the synchronous relay the live-state hooks get. Codex ships both Stop
84
+ # and SessionEnd (codex-rs/hooks at rust-v0.149.1); the design uses SessionEnd on both
85
+ # harnesses. Like STATE_HOOKS, the dispatcher's list is hand-kept and cross-checked by
86
+ # test/hook_registry_test.rb.
87
+ CODEX_SESSION_END_HOOKS = %w[close].freeze
88
+
142
89
  def codex_hooks_json(dispatcher_path:)
143
- # Every Codex hook name is now a name `events` itself carries (intent 251,
144
- # spec D9): edit-gates is Claude's own PreToolUse hook name, so there is
145
- # nothing left to merge in from a separate status table.
146
90
  status_by_name = events.values.flatten.flat_map { |g| g["hooks"] }
147
91
  .each_with_object({}) { |h, m| m[h["name"]] = h["status"] }
148
92
  cmd = ->(name) {
@@ -150,27 +94,19 @@ module HookRegistry
150
94
  "command" => "\"#{dispatcher_path}\" #{name}",
151
95
  "statusMessage" => status_by_name[name].to_s }
152
96
  }
153
- # Preserve the order these hook names appear across the PreToolUse groups in `events`.
154
- pre_order = events["PreToolUse"].flat_map { |g| g["hooks"].map { |h| h["name"] } }
155
- # The Codex apply_patch matcher carries the same edit-gates name Claude's own
156
- # PreToolUse group carries (intent 251, spec D9), so the intersection with
157
- # pre_order validates the Codex gate name against the single source of truth,
158
- # `events`, rather than against a table of Claude tool applicability.
159
- pre = (CODEX_PRE_HOOKS & pre_order).map { |n| cmd.call(n) }
160
- bash = (pre_order & CODEX_BASH_HOOKS).map { |n| cmd.call(n) }
161
- post = CODEX_POST_HOOKS.map { |n| cmd.call(n) }
97
+ # Validate the Codex name against the single source of truth, `events`.
98
+ post_order = events["PostToolUse"].flat_map { |g| g["hooks"].map { |h| h["name"] } }
99
+ post = (CODEX_POST_HOOKS & post_order).map { |n| cmd.call(n) }
162
100
 
163
101
  result = {
164
- "PreToolUse" => [
165
- { "matcher" => "apply_patch", "hooks" => pre },
166
- { "matcher" => "Bash", "hooks" => bash },
167
- ],
168
102
  "PostToolUse" => [{ "matcher" => "apply_patch", "hooks" => post }],
169
103
  }
170
104
  CODEX_LIVE_STATE_EVENTS.each do |event|
171
105
  names = events[event].flat_map { |g| g["hooks"].map { |h| h["name"] } }
172
106
  result[event] = [{ "matcher" => "", "hooks" => names.map { |n| cmd.call(n) } }]
173
107
  end
108
+ end_order = events["SessionEnd"].flat_map { |g| g["hooks"].map { |h| h["name"] } }
109
+ result["SessionEnd"] = [{ "matcher" => "", "hooks" => (CODEX_SESSION_END_HOOKS & end_order).map { |n| cmd.call(n) } }]
174
110
  result
175
111
  end
176
112
 
@@ -179,8 +115,7 @@ module HookRegistry
179
115
  # installed as ~/.claude/hooks/plastic-<name>. The single derivation doctor's
180
116
  # hooks_exist/hooks_executable/hooks_no_orphans checks read from, so a
181
117
  # hand-kept list of launchers can never drift out of step with `events`
182
- # again (the gap that let 8 of 15 launchers, all the enforcement gates, go
183
- # unchecked).
118
+ # again (the gap that once let 8 of 15 launchers go unchecked).
184
119
  def claude_launcher_names
185
120
  events.values.flatten.flat_map { |g| g["hooks"].map { |h| h["name"] } }
186
121
  .uniq.sort.map { |name| "plastic-#{name}" }
@@ -204,8 +139,10 @@ module HookRegistry
204
139
  # hooks_exist demands be present on disk, so a retired name there makes a correct
205
140
  # install report missing launchers.
206
141
  RETIRED_HOOK_NAMES = %w[
207
- code-gate create-gate links-gate lock-gate savepoint-pre
142
+ edit-gates bash-gate code-gate create-gate links-gate lock-gate savepoint-pre
208
143
  qmd-search retrieval-gate model-instructions opus-manual
144
+ continue future-intent-check auto-arm gate-check
145
+ power-tools
209
146
  ].freeze
210
147
 
211
148
  RETIRED_CLAUDE_LAUNCHERS = RETIRED_HOOK_NAMES.map { |n| "plastic-#{n}" }.freeze
@@ -226,7 +163,7 @@ module HookRegistry
226
163
  live = CODEX_LIVE_STATE_EVENTS.flat_map do |event|
227
164
  events[event].flat_map { |g| g["hooks"].map { |h| h["name"] } }
228
165
  end
229
- (CODEX_PRE_HOOKS + CODEX_POST_HOOKS + CODEX_BASH_HOOKS + live).uniq.sort
166
+ (CODEX_POST_HOOKS + live + CODEX_SESSION_END_HOOKS).uniq.sort
230
167
  end
231
168
 
232
169
  def codex_purgeable_hook_names
@@ -7,14 +7,13 @@
7
7
  #
8
8
  # Every entry leads with a fixed, machine-parseable prefix
9
9
  # `{utc-iso8601} · {stage} · {author}` where the timestamp is `now.utc.iso8601`,
10
- # the SAME convention `Bridge.append_savepoint_line` uses, so the store has one
10
+ # the SAME convention `Savepoint.append_savepoint_line` uses, so the store has one
11
11
  # timestamp convention across both ledgers. Entries are append-only, newest at
12
12
  # the BOTTOM (the [[34]] ordering law): the per-entry prefix is not prepending
13
13
  # the entry, and existing entries are never reordered.
14
14
 
15
15
  require "time"
16
- require_relative "bridge"
17
-
16
+ require_relative "savepoint"
18
17
  module Insights
19
18
  HEADING = "## Insights"
20
19
  # The middle dot (U+00B7) with a single space on each side separates the
@@ -35,7 +34,7 @@ module Insights
35
34
  end
36
35
 
37
36
  entry = "#{prefix} — #{text}"
38
- path = Bridge.intent_file(intent_dir)
37
+ path = Savepoint.intent_file(intent_dir)
39
38
  File.write(path, with_entry(File.exist?(path) ? File.read(path) : "", entry))
40
39
  entry
41
40
  end