@zalom/plastic 1.14.0 → 2.0.0-alpha.1

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 (190) 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/config_asks.yml +38 -0
  8. package/hooks/capture +4 -0
  9. package/hooks/check-update +12 -3
  10. package/hooks/close +5 -0
  11. package/hooks/hooks.json +9 -49
  12. package/hooks/record +4 -0
  13. package/package.json +3 -2
  14. package/scripts/agent-report +8 -9
  15. package/scripts/append-ledger +195 -0
  16. package/scripts/codex-hook +101 -95
  17. package/scripts/dashboard.rb +4 -4
  18. package/scripts/doctor.rb +106 -43
  19. package/scripts/end-intent +77 -76
  20. package/scripts/exec-worktree +11 -20
  21. package/scripts/file-session-intent +61 -0
  22. package/scripts/hook-capture +242 -0
  23. package/scripts/hook-close +34 -0
  24. package/scripts/hook-record +149 -0
  25. package/scripts/hook-session-start +95 -12
  26. package/scripts/install.rb +8 -6
  27. package/scripts/lib/agent_models.rb +1 -7
  28. package/scripts/lib/arm.rb +283 -0
  29. package/scripts/lib/backfill_intent.rb +316 -0
  30. package/scripts/lib/bridge.rb +24 -1597
  31. package/scripts/lib/doctor_core.rb +17 -88
  32. package/scripts/lib/doctor_session_ledger.rb +106 -0
  33. package/scripts/lib/exec_worktree.rb +14 -60
  34. package/scripts/lib/harness_text.rb +5 -4
  35. package/scripts/lib/hook_registry.rb +44 -107
  36. package/scripts/lib/insights.rb +3 -4
  37. package/scripts/lib/installer_core.rb +19 -30
  38. package/scripts/lib/lock.rb +6 -26
  39. package/scripts/lib/outcome_guard.rb +7 -5
  40. package/scripts/lib/project_validator.rb +30 -1
  41. package/scripts/lib/roadmap_queue.rb +3 -3
  42. package/scripts/lib/roadmap_savepoint.rb +1 -1
  43. package/scripts/lib/rule_catalog.rb +6 -3
  44. package/scripts/lib/savepoint.rb +320 -0
  45. package/scripts/lib/scaffold_intent.rb +13 -179
  46. package/scripts/lib/session_backfill.rb +184 -0
  47. package/scripts/lib/session_close.rb +84 -0
  48. package/scripts/lib/session_git.rb +469 -0
  49. package/scripts/lib/session_ledger.rb +497 -0
  50. package/scripts/lib/store_discovery.rb +1 -1
  51. package/scripts/lib/store_provisioning.rb +1 -1
  52. package/scripts/maintenance-run +7 -7
  53. package/scripts/new-intent +63 -4
  54. package/scripts/plastic-lock +94 -41
  55. package/scripts/promote-session-item +150 -0
  56. package/scripts/rebuild-graph +1 -1
  57. package/scripts/roadmap-next +2 -2
  58. package/scripts/rollback.rb +42 -3
  59. package/scripts/scaffold-intent +37 -63
  60. package/scripts/session-commit +116 -0
  61. package/scripts/spawn-preamble +5 -5
  62. package/scripts/update.rb +7 -3
  63. package/skills/agent-advisor/SKILL.md +3 -3
  64. package/skills/agent-advisor/references/advisor-protocol.md +20 -20
  65. package/skills/auto/SKILL.md +218 -317
  66. package/skills/auto/evals/evals.json +10 -10
  67. package/skills/auto/references/agent-architecture.md +102 -90
  68. package/skills/auto/references/agent-report-contract.md +4 -21
  69. package/skills/auto/references/end-tail.md +47 -53
  70. package/skills/auto/references/human-report-contract.md +8 -8
  71. package/skills/conventions/SKILL.md +1 -3
  72. package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
  73. package/skills/conventions/references/locks-and-worktrees.md +47 -55
  74. package/skills/conventions/references/maintenance-and-revisions.md +12 -12
  75. package/skills/dashboard/SKILL.md +1 -1
  76. package/skills/direct/SKILL.md +66 -0
  77. package/skills/direct/references/request-signals.md +59 -0
  78. package/skills/doctor/SKILL.md +58 -11
  79. package/skills/doctor/report.md +3 -1
  80. package/skills/install/SKILL.md +11 -9
  81. package/skills/intent-continuing/SKILL.md +131 -89
  82. package/skills/intent-continuing/references/boarding-matrix.md +34 -0
  83. package/skills/intent-continuing/references/context-management.md +2 -2
  84. package/skills/intent-creating/SKILL.md +2 -2
  85. package/skills/intent-creating/references/lifecycle.md +1 -1
  86. package/skills/intent-ending/SKILL.md +28 -37
  87. package/skills/intent-ending/evals/evals.json +4 -4
  88. package/skills/intent-executing/SKILL.md +12 -49
  89. package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
  90. package/skills/intent-speccing/SKILL.md +108 -51
  91. package/skills/intent-speccing/evals/evals.json +5 -5
  92. package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
  93. package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
  94. package/skills/project-creating/SKILL.md +1 -1
  95. package/skills/releasing/SKILL.md +7 -8
  96. package/skills/tutorial/SKILL.md +2 -2
  97. package/skills/tutorial/references/track-1-guided.md +16 -18
  98. package/skills/tutorial/references/track-2-auto.md +12 -13
  99. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
  100. package/templates/agents.md +4 -7
  101. package/templates/project.yml +7 -0
  102. package/templates/roadmap.md +1 -1
  103. package/templates/savepoint.md +2 -2
  104. package/templates/session-intent.md +29 -0
  105. package/agents/plastic-brainstorming.md +0 -48
  106. package/agents/plastic-future-intent-researcher.md +0 -35
  107. package/agents/plastic-intent-curator.md +0 -47
  108. package/agents/plastic-intent-discovery.md +0 -43
  109. package/agents/plastic-planner.md +0 -56
  110. package/agents/plastic-spec-specialist.md +0 -53
  111. package/hooks/auto-arm +0 -5
  112. package/hooks/bash-gate +0 -3
  113. package/hooks/continue +0 -31
  114. package/hooks/edit-gates +0 -3
  115. package/hooks/future-intent-check +0 -25
  116. package/hooks/gate-check +0 -12
  117. package/hooks/power-tools +0 -8
  118. package/scripts/hook-auto-arm +0 -51
  119. package/scripts/hook-bash-gate +0 -78
  120. package/scripts/hook-code-gate +0 -38
  121. package/scripts/hook-continue +0 -48
  122. package/scripts/hook-create-gate +0 -51
  123. package/scripts/hook-edit-gates +0 -58
  124. package/scripts/hook-future-intent-check +0 -90
  125. package/scripts/hook-gate-check +0 -169
  126. package/scripts/hook-links-gate +0 -54
  127. package/scripts/hook-lock-gate +0 -47
  128. package/scripts/hook-power-tools +0 -38
  129. package/scripts/hook-savepoint-pre +0 -32
  130. package/scripts/lib/codex_edit_gates.rb +0 -138
  131. package/scripts/lib/edit_gates.rb +0 -398
  132. package/scripts/lib/links_gate.rb +0 -140
  133. package/scripts/lib/qmd_hook.rb +0 -41
  134. package/scripts/lib/spec_header.rb +0 -83
  135. package/scripts/lib/start_intent.rb +0 -296
  136. package/scripts/start-intent +0 -89
  137. package/skills/_active-intent-gate.md +0 -26
  138. package/skills/auto/references/tiers.md +0 -100
  139. package/skills/continuing/SKILL.md +0 -34
  140. package/skills/continuing/evals/evals.json +0 -91
  141. package/skills/conventions/references/gates-and-enforcement.md +0 -53
  142. package/skills/conventions/references/tiers-and-dispatch.md +0 -135
  143. package/skills/doctor/references/gates-stuck-detection.md +0 -43
  144. package/skills/intent-brainstorming/SKILL.md +0 -118
  145. package/skills/intent-brainstorming/evals/evals.json +0 -67
  146. package/skills/intent-continuing/evals/evals.json +0 -145
  147. package/skills/intent-discovering/SKILL.md +0 -56
  148. package/skills/intent-grilling/SKILL.md +0 -108
  149. package/skills/intent-linking/SKILL.md +0 -128
  150. package/skills/intent-linking/evals/evals.json +0 -22
  151. package/skills/intent-linking/references/zettelkasten.md +0 -45
  152. package/skills/intent-locking/SKILL.md +0 -64
  153. package/skills/intent-planning/SKILL.md +0 -145
  154. package/skills/intent-planning/evals/evals.json +0 -81
  155. package/skills/intent-planning/references/plan-format.md +0 -130
  156. package/skills/intent-researching/SKILL.md +0 -123
  157. package/skills/intent-researching/evals/evals.json +0 -22
  158. package/skills/intent-savepoint/SKILL.md +0 -83
  159. package/skills/intent-savepoint/references/context-management.md +0 -32
  160. package/skills/intent-starting/SKILL.md +0 -151
  161. package/skills/intent-starting/evals/evals.json +0 -117
  162. package/skills/intent-starting/references/boarding-matrix.md +0 -35
  163. package/skills/project-continuing/SKILL.md +0 -119
  164. package/skills/project-continuing/evals/evals.json +0 -100
  165. package/skills/roadmap-continuing/SKILL.md +0 -89
  166. package/skills/roadmap-continuing/evals/evals.json +0 -82
  167. package/skills/skill-creating/SKILL.md +0 -75
  168. package/skills/skill-creating/evals/evals.json +0 -108
  169. package/skills/skill-creating/references/agents.md +0 -168
  170. package/skills/skill-creating/references/defaults-first.md +0 -23
  171. package/skills/skill-creating/references/evals.md +0 -41
  172. package/skills/skill-creating/references/hooks.md +0 -251
  173. package/skills/skill-creating/references/progressive-disclosure.md +0 -176
  174. package/skills/skill-creating/references/scripts.md +0 -166
  175. package/skills/skill-creating/references/skills.md +0 -169
  176. package/skills/skill-creating/scripts/scaffold.rb +0 -313
  177. package/skills/skill-evaluating/SKILL.md +0 -141
  178. package/skills/skill-evaluating/assets/eval-template.json +0 -12
  179. package/skills/skill-evaluating/evals/evals.json +0 -75
  180. package/skills/skill-evaluating/references/convention-checks.md +0 -76
  181. package/skills/skill-evaluating/references/eval-methodology.md +0 -154
  182. package/skills/store-curating/SKILL.md +0 -64
  183. package/skills/store-curating/evals/evals.json +0 -38
  184. package/skills/store-indexing/SKILL.md +0 -93
  185. package/skills/store-indexing/evals/evals.json +0 -22
  186. package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
  187. package/skills/store-provisioning/SKILL.md +0 -55
  188. /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
  189. /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
  190. /package/skills/{intent-brainstorming → intent-speccing}/references/design-principles.md +0 -0
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # append-ledger (intent 297) - the only writer of a session day ledger's
6
+ # shared files. Every verb either appends a line to checklist.md or
7
+ # savepoint.md, or flips one checklist line's state marker byte, always
8
+ # under an exclusive lock on the target file itself (spec D6, D7, D8). Every
9
+ # invocation scaffolds the day itself through SessionLedger.open_day (cheap
10
+ # and idempotent), so a capture that crosses midnight never fails and never
11
+ # needs a second process (spec D12).
12
+ #
13
+ # Usage:
14
+ # append-ledger pending <summary> [common flags]
15
+ # append-ledger item <summary> [common flags]
16
+ # append-ledger promote [--match <substring>] [--savepoint] [common flags]
17
+ # append-ledger tick [--match <substring>] [--savepoint] [common flags]
18
+ # append-ledger savepoint --event Item|Done|Note <summary> [common flags]
19
+ #
20
+ # Common flags:
21
+ # --store <dir> defaults to $PLASTIC_HOME/store ($PLASTIC_HOME defaults to ~/.plastic)
22
+ # --day <YYYYMMDD> defaults to SessionLedger.day_id (today, local wall clock)
23
+ # --session <id> defaults to $CLAUDE_CODE_SESSION_ID, then "local"
24
+ # --project <slug> defaults to the project_slug derived from --cwd
25
+ # --cwd <dir> defaults to Dir.pwd
26
+ # --templates <dir> defaults to the templates/ dir shipped next to this script
27
+ #
28
+ # Exit codes:
29
+ # 0 - done
30
+ # 2 - usage error, or promote/tick found nothing to address (no write happens)
31
+ # 3 - promote/tick could not take an exclusive lock (filesystem without flock support)
32
+
33
+ require_relative "lib/session_ledger"
34
+
35
+ VERBS = %w[pending item promote tick savepoint].freeze
36
+
37
+ def usage_abort(message)
38
+ warn "append-ledger: #{message}"
39
+ exit 2
40
+ end
41
+
42
+ # The value token that follows a flag at argv[i]. Aborts with exit 2 rather
43
+ # than silently returning nil when the flag is the last token (a trailing
44
+ # `--store` with nothing after it must not fall back to a default as if the
45
+ # flag had never been given).
46
+ def next_value!(argv, i, flag)
47
+ usage_abort("#{flag} requires a value") if i + 1 >= argv.length
48
+ argv[i + 1]
49
+ end
50
+
51
+ def parse_args(argv)
52
+ opts = {
53
+ match: nil, savepoint: false, event: nil,
54
+ store: nil, day: nil, session: nil, project: nil, cwd: nil, templates: nil,
55
+ }
56
+ positional = []
57
+ i = 0
58
+ while i < argv.length
59
+ arg = argv[i]
60
+ case arg
61
+ when "--match" then opts[:match] = next_value!(argv, i, arg); i += 1
62
+ when "--event" then opts[:event] = next_value!(argv, i, arg); i += 1
63
+ when "--store" then opts[:store] = next_value!(argv, i, arg); i += 1
64
+ when "--day" then opts[:day] = next_value!(argv, i, arg); i += 1
65
+ when "--session" then opts[:session] = next_value!(argv, i, arg); i += 1
66
+ when "--project" then opts[:project] = next_value!(argv, i, arg); i += 1
67
+ when "--cwd" then opts[:cwd] = next_value!(argv, i, arg); i += 1
68
+ when "--templates" then opts[:templates] = next_value!(argv, i, arg); i += 1
69
+ when "--savepoint" then opts[:savepoint] = true
70
+ else
71
+ usage_abort("unknown flag #{arg.inspect}") if arg.start_with?("--")
72
+
73
+ positional << arg
74
+ end
75
+ i += 1
76
+ end
77
+
78
+ opts[:verb] = positional.shift
79
+ opts[:summary] = positional.shift
80
+ opts
81
+ end
82
+
83
+ def expand(path)
84
+ File.expand_path(path.to_s.sub(/\A~/, Dir.home))
85
+ end
86
+
87
+ # Sibling of this script's dir. Works in-repo (<repo>/scripts/append-ledger
88
+ # -> <repo>/templates) and installed (~/.plastic/scripts/append-ledger ->
89
+ # ~/.plastic/templates when present).
90
+ def default_templates_dir
91
+ File.expand_path("../templates", __dir__)
92
+ end
93
+
94
+ def sanitized_summary(raw)
95
+ usage_abort("a summary is required") if raw.nil?
96
+
97
+ clean = SessionLedger.sanitize_summary(raw)
98
+ usage_abort("summary is empty after sanitizing") if clean.empty?
99
+ clean
100
+ end
101
+
102
+ def append_checklist_line(state, store, day, session, project, summary)
103
+ clean = sanitized_summary(summary)
104
+ line = SessionLedger.checklist_line(state, session, project, clean)
105
+ path = SessionLedger.checklist_path(store, day)
106
+ SessionLedger.append_line(path, line, header: SessionLedger.checklist_header(day))
107
+ end
108
+
109
+ # set_state identifies the target line AND flips it inside one LOCK_EX hold,
110
+ # returning the flipped line's own summary (or nil when nothing matched).
111
+ # There is deliberately no separate identify-then-flip step here: a prior
112
+ # version located the target under its own released lock, then set_state
113
+ # re-located it under a fresh lock, and the two scans could disagree under
114
+ # concurrency, naming the wrong line in the --savepoint entry below. Using
115
+ # set_state's return value is what keeps identify-and-flip atomic.
116
+ def run_transition(from, to, event, store, day, session, project, opts)
117
+ path = SessionLedger.checklist_path(store, day)
118
+
119
+ target_summary =
120
+ begin
121
+ SessionLedger.set_state(path, from: from, to: to, session: session, match: opts[:match])
122
+ rescue SessionLedger::LockUnavailableError => e
123
+ warn "append-ledger: #{e.message}"
124
+ exit 3
125
+ end
126
+
127
+ if target_summary.nil?
128
+ warn "append-ledger: nothing to address"
129
+ exit 2
130
+ end
131
+
132
+ return unless opts[:savepoint]
133
+
134
+ line = SessionLedger.savepoint_line(event, session, project, target_summary, now: Time.now)
135
+ SessionLedger.append_line(SessionLedger.savepoint_path(store, day), line, header: nil)
136
+ end
137
+
138
+ def run_savepoint(store, day, session, project, opts)
139
+ unless SessionLedger::EVENTS.include?(opts[:event])
140
+ usage_abort("--event must be one of #{SessionLedger::EVENTS.join(", ")}, got #{opts[:event].inspect}")
141
+ end
142
+
143
+ clean = sanitized_summary(opts[:summary])
144
+ line = SessionLedger.savepoint_line(opts[:event], session, project, clean, now: Time.now)
145
+ SessionLedger.append_line(SessionLedger.savepoint_path(store, day), line, header: nil)
146
+ end
147
+
148
+ def main(argv)
149
+ opts = parse_args(argv)
150
+ usage_abort("verb must be one of #{VERBS.join(", ")}, got #{opts[:verb].inspect}") unless VERBS.include?(opts[:verb])
151
+
152
+ plastic_home = expand(ENV.fetch("PLASTIC_HOME", "~/.plastic"))
153
+ store = opts[:store] ? expand(opts[:store]) : File.join(plastic_home, "store")
154
+ templates = opts[:templates] ? expand(opts[:templates]) : default_templates_dir
155
+ usage_abort("templates dir not found: #{templates}") unless Dir.exist?(templates)
156
+ cwd = opts[:cwd] ? expand(opts[:cwd]) : Dir.pwd
157
+
158
+ day = opts[:day] || SessionLedger.day_id
159
+ unless SessionLedger.valid_day_id?(day)
160
+ usage_abort("--day must be eight digits parsing as a real date, got #{day.inspect}")
161
+ end
162
+
163
+ if opts[:project] && !SessionLedger::SLUG_RE.match?(opts[:project])
164
+ usage_abort("--project must match [a-z0-9-]+, got #{opts[:project].inspect}")
165
+ end
166
+
167
+ session = SessionLedger.short_session_id(opts[:session], ENV["CLAUDE_CODE_SESSION_ID"])
168
+ project = opts[:project] || SessionLedger.project_slug(cwd, plastic_home: plastic_home)
169
+
170
+ # Always call open_day: it is idempotent (File::CREAT | File::EXCL decides
171
+ # create vs join) and cheap, so this never depends on Dir.exist?(day_dir)
172
+ # as a proxy for "does the day file exist". A guard keyed on the DIRECTORY
173
+ # missed the case of a day dir that exists without its <day>.md (a crashed
174
+ # scaffold, or a template-read failure): that guard would see the
175
+ # directory, skip open_day forever, and every append would land in a day
176
+ # dir with no intent file at all.
177
+ SessionLedger.open_day(store: store, day: day, templates: templates, author: session)
178
+
179
+ case opts[:verb]
180
+ when "pending"
181
+ append_checklist_line(:pending, store, day, session, project, opts[:summary])
182
+ when "item"
183
+ append_checklist_line(:open, store, day, session, project, opts[:summary])
184
+ when "promote"
185
+ run_transition(:pending, :open, "Item", store, day, session, project, opts)
186
+ when "tick"
187
+ run_transition(:open, :done, "Done", store, day, session, project, opts)
188
+ when "savepoint"
189
+ run_savepoint(store, day, session, project, opts)
190
+ end
191
+
192
+ exit 0
193
+ end
194
+
195
+ main(ARGV) if $PROGRAM_NAME == __FILE__
@@ -2,42 +2,41 @@
2
2
  # encoding: UTF-8
3
3
  # frozen_string_literal: true
4
4
  #
5
- # Usage: codex-hook <gate>
6
- # file-mutation gates: edit-gates | gate-check
7
- # live-state hooks (intent 199): session-start | check-update | continue |
8
- # future-intent-check | auto-arm | power-tools | savepoint
9
- # shell-tool gate (intent 203): bash-gate
5
+ # Usage: codex-hook <hook>
6
+ # file-mutation hook: record (PostToolUse on apply_patch)
7
+ # live-state hooks (intent 199, capture/record collapse by 298):
8
+ # session-start | check-update | capture | savepoint
9
+ # session-end hook (intent 309): close, handed off detached
10
10
  #
11
- # The Codex input adapter (intent 102, extended by 199, 203, and collapsed by
12
- # 251). Reads a Codex hook stdin payload once. Three shapes exist and the gate
13
- # name alone selects which path runs (Codex's own hooks.json registration
14
- # guarantees they never cross): the apply_patch path reads session_id at top
15
- # level and the apply_patch command in tool_input.command [guide Part 4],
16
- # parses the diff envelope once via ApplyPatchEnvelope, and runs all five gates
17
- # (savepoint-pre, lock-gate, code-gate, links-gate, create-gate) IN-PROCESS
18
- # through scripts/lib/codex_edit_gates.rb, which drives the same
19
- # scripts/lib/edit_gates.rb functions Claude's merged dispatcher drives
20
- # (mirroring intent 244). This replaced five registered PreToolUse commands and
21
- # three nested run_core children with one process. The live-state hooks carry
22
- # no tool_input at all (they are not tool calls); the dispatcher execs the SAME
23
- # launcher file Claude already runs for that hook (hooks/<name>), which is
24
- # already harness-agnostic (resolves ~/.plastic off $HOME, reads only the
25
- # common stdin fields the guide confirms Codex shares with Claude for these
26
- # three events), so the body is reused, not rewritten (D3). The shell-tool gate
27
- # (bash-gate) reports tool_name: "Bash" with the command in tool_input.command,
28
- # but MUST NOT be routed through ApplyPatchEnvelope.parse below: a plain shell
29
- # command has no *** Begin Patch/*** End Patch envelope, so it would parse to
30
- # an empty op list and hit this file's own `exit 0 if ops.empty?` line,
31
- # silently allowing every Bash call and reopening the exact hole intent 203
32
- # closes. So it execs the SAME scripts/hook-bash-gate file Claude already runs,
33
- # unmodified, the identical "drive the body, relay its output" pattern used for
34
- # the live-state hooks. The one adaptation shared by both the live-state and
35
- # shell-tool paths is threading the payload's session_id into
36
- # CLAUDE_CODE_SESSION_ID, since Codex's own process env never carries it, plus
37
- # a bounded timeout: hooks/check-update backgrounds a real npm network call
38
- # without redirecting its output away from the inherited stdout/stderr pipes,
39
- # and Codex invokes hooks synchronously, so without a bound a slow network call
40
- # could hold this dispatcher's pipe open past the launcher's own (fast) exit.
11
+ # The Codex input adapter (intent 102, extended by 199, collapsed by 251, cut to
12
+ # the record hook by 302). Reads a Codex hook stdin payload once. Two shapes
13
+ # exist and the hook name alone selects which path runs (Codex's own hooks.json
14
+ # registration guarantees they never cross): the apply_patch path reads
15
+ # session_id at top level and the apply_patch command in tool_input.command
16
+ # [guide Part 4] and parses the diff envelope once via ApplyPatchEnvelope; the
17
+ # live-state hooks carry no tool_input at all (they are not tool calls), so the
18
+ # dispatcher execs the SAME launcher file Claude already runs for that hook
19
+ # (hooks/<name>), which is already harness-agnostic (resolves ~/.plastic off
20
+ # $HOME, reads only the common stdin fields the guide confirms Codex shares with
21
+ # Claude for these events), so the body is reused, not rewritten (D3). The one
22
+ # adaptation on the live-state path is threading the payload's session_id into
23
+ # CLAUDE_CODE_SESSION_ID, since Codex's own process env never carries it, plus a
24
+ # bounded timeout: Codex invokes hooks synchronously, so a launcher that never
25
+ # releases this dispatcher's pipes would hang the session. hooks/check-update
26
+ # used to leak them by backgrounding an npm network call with the inherited
27
+ # descriptors (fixed at the root in intent 289 by redirecting that subshell to
28
+ # /dev/null); the bound stays for any other launcher that misbehaves, and
29
+ # capture_launcher keeps the timeout path silent.
30
+ #
31
+ # The PreToolUse edit-path gates and the Bash-matcher shell gate were removed in
32
+ # 2.0 (intent 302), and the power-tools reminder in intent 309. An older
33
+ # ~/.codex/hooks.json that still names one of them reaches the fail-open `else`
34
+ # below: exit 0, no output, no decision.
35
+ #
36
+ # SessionEnd (intent 309): Codex kills a SessionEnd hook after 3 seconds, so `close`
37
+ # is never relayed synchronously. The dispatcher hands the payload to hooks/close in a
38
+ # detached child (own process group, stdin from a pipe, stdout and stderr to /dev/null)
39
+ # and exits at once; the close hook's own detached filer does the slow part.
41
40
  require "json"
42
41
  require "open3"
43
42
  require "rbconfig"
@@ -50,9 +49,55 @@ require "timeout"
50
49
  # caller that merges the dispatcher's own stdout and stderr.
51
50
  $stdout.sync = true
52
51
 
53
- STATE_HOOKS = %w[session-start check-update continue future-intent-check auto-arm power-tools savepoint].freeze
52
+ STATE_HOOKS = %w[session-start check-update capture savepoint close].freeze
54
53
  STATE_TIMEOUT = 5
55
- SHELL_HOOKS = %w[bash-gate].freeze
54
+ DETACHED_HOOKS = %w[close].freeze
55
+
56
+ # One capture path for the relayed-launcher branch (intent 289). The 5 s bound covers a
57
+ # launcher that leaks THIS process's stdout and stderr into a longer-lived background child
58
+ # (hooks/check-update did exactly that before 289): it does not bound a launcher that simply
59
+ # never exits in the foreground, since Open3.popen3's ensure joins the child's wait thread on
60
+ # the way out regardless of Timeout. What is added here is silence on the timeout path. A
61
+ # leaked-pipe launcher leaves Open3's reader threads blocked in IO#read; when Timeout unwinds
62
+ # and popen3's ensure closes those pipes underneath them, both threads raise IOError and Ruby
63
+ # prints a backtrace to real stderr, which any caller merging our stdout and stderr then reads
64
+ # as hook output. Thread#report_on_exception is seeded from the class default AT THREAD
65
+ # CREATION and capture3 creates its readers inside this block (open3.rb:664-665), so
66
+ # clearing the default here is enough. Restored in ensure: nothing else in this process,
67
+ # including a genuinely unexpected exception later, loses its report.
68
+ def capture_launcher(env, *argv, raw:, cwd:)
69
+ previous = Thread.report_on_exception
70
+ Thread.report_on_exception = false
71
+ begin
72
+ Timeout.timeout(STATE_TIMEOUT) { Open3.capture3(env, *argv, stdin_data: raw, chdir: cwd) }
73
+ rescue StandardError
74
+ ["", "", nil] # fail open: launcher missing, unexecutable, crashed, or too slow
75
+ ensure
76
+ Thread.report_on_exception = previous
77
+ end
78
+ end
79
+
80
+ # Hand a launcher its payload and return without waiting (intent 309). The child gets its
81
+ # own process group so the harness's teardown of this dispatcher never reaches it, reads
82
+ # the payload from a pipe this process fills without blocking and closes, and writes
83
+ # nowhere. Every failure (a missing or unexecutable launcher, a closed pipe) is rescued
84
+ # silently, so nothing ever reaches Codex's stderr from this path.
85
+ def detach_launcher(env, launcher, raw:, cwd:)
86
+ reader, writer = IO.pipe
87
+ pid = Process.spawn(env, launcher, in: reader, out: File::NULL, err: File::NULL,
88
+ chdir: cwd, pgroup: true)
89
+ reader.close
90
+ begin
91
+ writer.write_nonblock(raw) # a hook payload fits the pipe buffer; a larger one is truncated
92
+ rescue IO::WaitWritable, SystemCallError
93
+ nil
94
+ ensure
95
+ writer.close
96
+ end
97
+ Process.detach(pid)
98
+ rescue StandardError
99
+ nil # fail open: a close that cannot start is a close the next-day sweep files
100
+ end
56
101
 
57
102
  gate = ARGV[0].to_s
58
103
  raw = ($stdin.read rescue nil)
@@ -67,31 +112,11 @@ if STATE_HOOKS.include?(gate)
67
112
  cwd = payload["cwd"].to_s
68
113
  cwd = Dir.pwd if cwd.empty? || !Dir.exist?(cwd)
69
114
  env = { "CLAUDE_CODE_SESSION_ID" => (session.empty? ? nil : session) }
70
- out, err, status = begin
71
- Timeout.timeout(STATE_TIMEOUT) { Open3.capture3(env.merge("RUBYOPT" => nil), launcher, stdin_data: raw, chdir: cwd) }
72
- rescue StandardError
73
- ["", "", nil] # fail open: launcher missing, unexecutable, crashed, or too slow
74
- end
75
- print out unless out.to_s.empty?
76
- warn err unless err.to_s.empty?
77
- exit(status ? status.exitstatus : 0)
78
- end
79
-
80
- if SHELL_HOOKS.include?(gate)
81
- # bash-gate (intent 203): a shell command has no apply_patch diff envelope, so
82
- # this branch execs the SAME scripts/hook-bash-gate file Claude already runs,
83
- # unmodified, and relays its exit code and stderr. It must never fall through
84
- # to ApplyPatchEnvelope.parse below (see header comment).
85
- cwd = payload["cwd"].to_s
86
- cwd = Dir.pwd if cwd.empty? || !Dir.exist?(cwd)
87
- env = { "CLAUDE_CODE_SESSION_ID" => (session.empty? ? nil : session) }
88
- script = File.join(__dir__, "hook-#{gate}")
89
- argv = [script]
90
- out, err, status = begin
91
- Timeout.timeout(STATE_TIMEOUT) { Open3.capture3(env.merge("RUBYOPT" => nil), RbConfig.ruby, *argv, stdin_data: raw, chdir: cwd) }
92
- rescue StandardError
93
- ["", "", nil] # fail open: script missing, unexecutable, crashed, or too slow
115
+ if DETACHED_HOOKS.include?(gate)
116
+ detach_launcher(env.merge("RUBYOPT" => nil), launcher, raw: raw, cwd: cwd) if File.file?(launcher)
117
+ exit 0
94
118
  end
119
+ out, err, status = capture_launcher(env.merge("RUBYOPT" => nil), launcher, raw: raw, cwd: cwd)
95
120
  print out unless out.to_s.empty?
96
121
  warn err unless err.to_s.empty?
97
122
  exit(status ? status.exitstatus : 0)
@@ -100,48 +125,29 @@ end
100
125
  require_relative "lib/apply_patch_envelope"
101
126
 
102
127
  command = payload.dig("tool_input", "command") || payload.dig("tool_params", "command")
103
- exit 0 if command.nil? # fail-open: no tool_input at all (unrecognized gate name or non-tool-call payload)
128
+ exit 0 if command.nil? # fail-open: no tool_input at all (unrecognized hook name or non-tool-call payload)
104
129
  ops = ApplyPatchEnvelope.parse(command)
105
- exit 0 if ops.empty? # fail-open: nothing parseable to gate
130
+ exit 0 if ops.empty? # fail-open: nothing parseable to record
106
131
 
107
132
  CORES = __dir__ # ~/.plastic/scripts
108
- def run_core(name, *argv)
109
- out = IO.popen([{ "RUBYOPT" => nil }, RbConfig.ruby, File.join(CORES, name), *argv], "r", err: [:child, :out], &:read)
110
- [out, $?.exitstatus]
111
- end
112
133
 
113
134
  case gate
114
- when "edit-gates"
115
- # The ONE registered PreToolUse command on the apply_patch matcher (intent
116
- # 251). Five gates, in-process, in Claude's evaluation order, first deny wins,
117
- # each deny shape reproduced exactly: stderr plus exit 2 for code-gate,
118
- # links-gate and create-gate; stdout permissionDecision JSON plus exit 0 for
119
- # lock-gate. Fail-open at both levels, per gate inside EditGates.dispatch and
120
- # again at this file's own top level.
121
- begin
122
- require_relative "lib/codex_edit_gates"
123
- exit CodexEditGates.dispatch(ops: ops, payload: payload)
124
- rescue StandardError, ScriptError => e
125
- # Dispatcher-internal failure: never impersonate a deny. ScriptError (a
126
- # sibling of StandardError, not a subclass) covers LoadError: a future
127
- # manifest gap (a lib file required here but not distributed, exactly the
128
- # bug the full suite caught during this intent's own delivery) must fail
129
- # open with a gate decision, never exit 1 with no decision at all.
130
- $stderr.puts "plastic codex edit-gates error: #{e.message}"
131
- exit 0
132
- end
133
-
134
- when "gate-check"
135
- last_allow = nil
135
+ when "record"
136
+ # hook-record (intent 298) reads one stdin JSON payload; it takes no ARGV. One
137
+ # apply_patch call can bundle several file operations, so this synthesizes one
138
+ # Claude-shaped PostToolUse payload per op and pipes it to hook-record in-process,
139
+ # mirroring the "drive the body" pattern used for the live-state hooks. record
140
+ # never blocks and never prints anything (D3), so there is nothing to relay: every
141
+ # op's failure is independently rescued and the dispatcher always exits 0.
142
+ cwd_val = payload["cwd"].to_s
136
143
  ops.each do |o|
137
- out, code = run_core("hook-gate-check", o.path, session)
138
- if code == 2
139
- print out # {"decision":"block",...}
140
- exit 2
144
+ synth = JSON.generate("session_id" => session, "tool_input" => { "file_path" => o.path }, "cwd" => cwd_val)
145
+ begin
146
+ Open3.capture3({ "RUBYOPT" => nil }, RbConfig.ruby, File.join(CORES, "hook-record"), stdin_data: synth)
147
+ rescue StandardError
148
+ nil
141
149
  end
142
- last_allow = out unless out.to_s.strip.empty?
143
150
  end
144
- print last_allow if last_allow
145
151
  exit 0
146
152
 
147
153
  else
@@ -25,7 +25,7 @@ require "json"
25
25
  require "yaml"
26
26
  require "date"
27
27
  require_relative "doctor"
28
- require_relative "lib/bridge"
28
+ require_relative "lib/savepoint"
29
29
  require_relative "lib/lock"
30
30
 
31
31
  PLASTIC_HOME = ENV.fetch("PLASTIC_HOME") { File.join(Dir.home, ".plastic") }
@@ -36,7 +36,7 @@ def today
36
36
  end
37
37
 
38
38
  # A generic "about a month" threshold, not tuned to any one store's item count.
39
- # Deliberately independent from plastic-project-continuing's separate stale_threshold_days
39
+ # Deliberately independent from plastic-intent-continuing's separate stale_threshold_days
40
40
  # config: that one is a proactive boot-time triage nudge, this is a board annotation.
41
41
  # Unifying the two is a follow-up, not this intent.
42
42
  STALE_DAYS = 30
@@ -163,7 +163,7 @@ end
163
163
 
164
164
  # True iff the savepoint ledger's last non-blank line shows real post-birth
165
165
  # activity, not just the one-line birth stamp every intent gets at creation
166
- # (scripts/new-intent's Bridge.append_savepoint call, stage "What"). Reads the
166
+ # (scripts/new-intent's Savepoint.append_savepoint call, stage "What"). Reads the
167
167
  # last line, extracts the stage token (second whitespace-separated field, same
168
168
  # ledger shape last_accessed_at already parses), and treats any stage other
169
169
  # than "What" as progress. Returns false when the file is missing/empty.
@@ -192,7 +192,7 @@ def parse_intent(store_info, dir_name, status_index)
192
192
  # Sentinel-aware presence for lifecycle files (intent 60b): a scaffolded
193
193
  # placeholder spec/plan/checklist/outcome reads as absent, so a freshly
194
194
  # scaffolded intent reports What/Why and is never marked completed/advanced.
195
- real = ->(f) { Bridge.stage_file_present?(File.join(dir, f)) }
195
+ real = ->(f) { Savepoint.stage_file_present?(File.join(dir, f)) }
196
196
  body = File.exist?(md) ? File.read(md) : ""
197
197
 
198
198
  status =