@zalom/plastic 1.14.1 → 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 (189) 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/close +5 -0
  10. package/hooks/hooks.json +9 -49
  11. package/hooks/record +4 -0
  12. package/package.json +3 -2
  13. package/scripts/agent-report +8 -9
  14. package/scripts/append-ledger +195 -0
  15. package/scripts/codex-hook +78 -90
  16. package/scripts/dashboard.rb +4 -4
  17. package/scripts/doctor.rb +106 -43
  18. package/scripts/end-intent +77 -76
  19. package/scripts/exec-worktree +11 -20
  20. package/scripts/file-session-intent +61 -0
  21. package/scripts/hook-capture +242 -0
  22. package/scripts/hook-close +34 -0
  23. package/scripts/hook-record +149 -0
  24. package/scripts/hook-session-start +95 -12
  25. package/scripts/install.rb +8 -6
  26. package/scripts/lib/agent_models.rb +1 -7
  27. package/scripts/lib/arm.rb +283 -0
  28. package/scripts/lib/backfill_intent.rb +316 -0
  29. package/scripts/lib/bridge.rb +24 -1597
  30. package/scripts/lib/doctor_core.rb +17 -88
  31. package/scripts/lib/doctor_session_ledger.rb +106 -0
  32. package/scripts/lib/exec_worktree.rb +14 -60
  33. package/scripts/lib/harness_text.rb +5 -4
  34. package/scripts/lib/hook_registry.rb +44 -107
  35. package/scripts/lib/insights.rb +3 -4
  36. package/scripts/lib/installer_core.rb +19 -30
  37. package/scripts/lib/lock.rb +6 -26
  38. package/scripts/lib/outcome_guard.rb +7 -5
  39. package/scripts/lib/project_validator.rb +30 -1
  40. package/scripts/lib/roadmap_queue.rb +3 -3
  41. package/scripts/lib/roadmap_savepoint.rb +1 -1
  42. package/scripts/lib/rule_catalog.rb +6 -3
  43. package/scripts/lib/savepoint.rb +320 -0
  44. package/scripts/lib/scaffold_intent.rb +13 -179
  45. package/scripts/lib/session_backfill.rb +184 -0
  46. package/scripts/lib/session_close.rb +84 -0
  47. package/scripts/lib/session_git.rb +469 -0
  48. package/scripts/lib/session_ledger.rb +497 -0
  49. package/scripts/lib/store_discovery.rb +1 -1
  50. package/scripts/lib/store_provisioning.rb +1 -1
  51. package/scripts/maintenance-run +7 -7
  52. package/scripts/new-intent +63 -4
  53. package/scripts/plastic-lock +94 -41
  54. package/scripts/promote-session-item +150 -0
  55. package/scripts/rebuild-graph +1 -1
  56. package/scripts/roadmap-next +2 -2
  57. package/scripts/rollback.rb +42 -3
  58. package/scripts/scaffold-intent +37 -63
  59. package/scripts/session-commit +116 -0
  60. package/scripts/spawn-preamble +5 -5
  61. package/scripts/update.rb +7 -3
  62. package/skills/agent-advisor/SKILL.md +3 -3
  63. package/skills/agent-advisor/references/advisor-protocol.md +20 -20
  64. package/skills/auto/SKILL.md +218 -317
  65. package/skills/auto/evals/evals.json +10 -10
  66. package/skills/auto/references/agent-architecture.md +102 -90
  67. package/skills/auto/references/agent-report-contract.md +4 -21
  68. package/skills/auto/references/end-tail.md +47 -53
  69. package/skills/auto/references/human-report-contract.md +8 -8
  70. package/skills/conventions/SKILL.md +1 -3
  71. package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
  72. package/skills/conventions/references/locks-and-worktrees.md +47 -55
  73. package/skills/conventions/references/maintenance-and-revisions.md +12 -12
  74. package/skills/dashboard/SKILL.md +1 -1
  75. package/skills/direct/SKILL.md +66 -0
  76. package/skills/direct/references/request-signals.md +59 -0
  77. package/skills/doctor/SKILL.md +58 -11
  78. package/skills/doctor/report.md +3 -1
  79. package/skills/install/SKILL.md +11 -9
  80. package/skills/intent-continuing/SKILL.md +131 -89
  81. package/skills/intent-continuing/references/boarding-matrix.md +34 -0
  82. package/skills/intent-continuing/references/context-management.md +2 -2
  83. package/skills/intent-creating/SKILL.md +2 -2
  84. package/skills/intent-creating/references/lifecycle.md +1 -1
  85. package/skills/intent-ending/SKILL.md +28 -37
  86. package/skills/intent-ending/evals/evals.json +4 -4
  87. package/skills/intent-executing/SKILL.md +12 -49
  88. package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
  89. package/skills/intent-speccing/SKILL.md +108 -51
  90. package/skills/intent-speccing/evals/evals.json +5 -5
  91. package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
  92. package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
  93. package/skills/project-creating/SKILL.md +1 -1
  94. package/skills/releasing/SKILL.md +7 -8
  95. package/skills/tutorial/SKILL.md +2 -2
  96. package/skills/tutorial/references/track-1-guided.md +16 -18
  97. package/skills/tutorial/references/track-2-auto.md +12 -13
  98. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
  99. package/templates/agents.md +4 -7
  100. package/templates/project.yml +7 -0
  101. package/templates/roadmap.md +1 -1
  102. package/templates/savepoint.md +2 -2
  103. package/templates/session-intent.md +29 -0
  104. package/agents/plastic-brainstorming.md +0 -48
  105. package/agents/plastic-future-intent-researcher.md +0 -35
  106. package/agents/plastic-intent-curator.md +0 -47
  107. package/agents/plastic-intent-discovery.md +0 -43
  108. package/agents/plastic-planner.md +0 -56
  109. package/agents/plastic-spec-specialist.md +0 -53
  110. package/hooks/auto-arm +0 -5
  111. package/hooks/bash-gate +0 -3
  112. package/hooks/continue +0 -31
  113. package/hooks/edit-gates +0 -3
  114. package/hooks/future-intent-check +0 -25
  115. package/hooks/gate-check +0 -12
  116. package/hooks/power-tools +0 -8
  117. package/scripts/hook-auto-arm +0 -51
  118. package/scripts/hook-bash-gate +0 -78
  119. package/scripts/hook-code-gate +0 -38
  120. package/scripts/hook-continue +0 -48
  121. package/scripts/hook-create-gate +0 -51
  122. package/scripts/hook-edit-gates +0 -58
  123. package/scripts/hook-future-intent-check +0 -90
  124. package/scripts/hook-gate-check +0 -169
  125. package/scripts/hook-links-gate +0 -54
  126. package/scripts/hook-lock-gate +0 -47
  127. package/scripts/hook-power-tools +0 -38
  128. package/scripts/hook-savepoint-pre +0 -32
  129. package/scripts/lib/codex_edit_gates.rb +0 -138
  130. package/scripts/lib/edit_gates.rb +0 -398
  131. package/scripts/lib/links_gate.rb +0 -140
  132. package/scripts/lib/qmd_hook.rb +0 -41
  133. package/scripts/lib/spec_header.rb +0 -83
  134. package/scripts/lib/start_intent.rb +0 -296
  135. package/scripts/start-intent +0 -89
  136. package/skills/_active-intent-gate.md +0 -26
  137. package/skills/auto/references/tiers.md +0 -100
  138. package/skills/continuing/SKILL.md +0 -34
  139. package/skills/continuing/evals/evals.json +0 -91
  140. package/skills/conventions/references/gates-and-enforcement.md +0 -53
  141. package/skills/conventions/references/tiers-and-dispatch.md +0 -135
  142. package/skills/doctor/references/gates-stuck-detection.md +0 -43
  143. package/skills/intent-brainstorming/SKILL.md +0 -118
  144. package/skills/intent-brainstorming/evals/evals.json +0 -67
  145. package/skills/intent-continuing/evals/evals.json +0 -145
  146. package/skills/intent-discovering/SKILL.md +0 -56
  147. package/skills/intent-grilling/SKILL.md +0 -108
  148. package/skills/intent-linking/SKILL.md +0 -128
  149. package/skills/intent-linking/evals/evals.json +0 -22
  150. package/skills/intent-linking/references/zettelkasten.md +0 -45
  151. package/skills/intent-locking/SKILL.md +0 -64
  152. package/skills/intent-planning/SKILL.md +0 -145
  153. package/skills/intent-planning/evals/evals.json +0 -81
  154. package/skills/intent-planning/references/plan-format.md +0 -130
  155. package/skills/intent-researching/SKILL.md +0 -123
  156. package/skills/intent-researching/evals/evals.json +0 -22
  157. package/skills/intent-savepoint/SKILL.md +0 -83
  158. package/skills/intent-savepoint/references/context-management.md +0 -32
  159. package/skills/intent-starting/SKILL.md +0 -151
  160. package/skills/intent-starting/evals/evals.json +0 -117
  161. package/skills/intent-starting/references/boarding-matrix.md +0 -35
  162. package/skills/project-continuing/SKILL.md +0 -119
  163. package/skills/project-continuing/evals/evals.json +0 -100
  164. package/skills/roadmap-continuing/SKILL.md +0 -89
  165. package/skills/roadmap-continuing/evals/evals.json +0 -82
  166. package/skills/skill-creating/SKILL.md +0 -75
  167. package/skills/skill-creating/evals/evals.json +0 -108
  168. package/skills/skill-creating/references/agents.md +0 -168
  169. package/skills/skill-creating/references/defaults-first.md +0 -23
  170. package/skills/skill-creating/references/evals.md +0 -41
  171. package/skills/skill-creating/references/hooks.md +0 -251
  172. package/skills/skill-creating/references/progressive-disclosure.md +0 -176
  173. package/skills/skill-creating/references/scripts.md +0 -166
  174. package/skills/skill-creating/references/skills.md +0 -169
  175. package/skills/skill-creating/scripts/scaffold.rb +0 -313
  176. package/skills/skill-evaluating/SKILL.md +0 -141
  177. package/skills/skill-evaluating/assets/eval-template.json +0 -12
  178. package/skills/skill-evaluating/evals/evals.json +0 -75
  179. package/skills/skill-evaluating/references/convention-checks.md +0 -76
  180. package/skills/skill-evaluating/references/eval-methodology.md +0 -154
  181. package/skills/store-curating/SKILL.md +0 -64
  182. package/skills/store-curating/evals/evals.json +0 -38
  183. package/skills/store-indexing/SKILL.md +0 -93
  184. package/skills/store-indexing/evals/evals.json +0 -22
  185. package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
  186. package/skills/store-provisioning/SKILL.md +0 -55
  187. /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
  188. /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
  189. /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,44 +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,
37
- # plus a bounded timeout: Codex invokes hooks synchronously, so a launcher
38
- # that never releases this dispatcher's pipes would hang the session.
39
- # hooks/check-update used to leak them by backgrounding an npm network call
40
- # with the inherited descriptors (fixed at the root in intent 289 by
41
- # redirecting that subshell to /dev/null); the bound stays for any other
42
- # launcher that misbehaves, and capture_launcher keeps the timeout path silent.
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.
43
40
  require "json"
44
41
  require "open3"
45
42
  require "rbconfig"
@@ -52,11 +49,11 @@ require "timeout"
52
49
  # caller that merges the dispatcher's own stdout and stderr.
53
50
  $stdout.sync = true
54
51
 
55
- 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
56
53
  STATE_TIMEOUT = 5
57
- SHELL_HOOKS = %w[bash-gate].freeze
54
+ DETACHED_HOOKS = %w[close].freeze
58
55
 
59
- # One capture path for both relayed-launcher branches (intent 289). The 5 s bound covers a
56
+ # One capture path for the relayed-launcher branch (intent 289). The 5 s bound covers a
60
57
  # launcher that leaks THIS process's stdout and stderr into a longer-lived background child
61
58
  # (hooks/check-update did exactly that before 289): it does not bound a launcher that simply
62
59
  # never exits in the foreground, since Open3.popen3's ensure joins the child's wait thread on
@@ -80,6 +77,28 @@ def capture_launcher(env, *argv, raw:, cwd:)
80
77
  end
81
78
  end
82
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
101
+
83
102
  gate = ARGV[0].to_s
84
103
  raw = ($stdin.read rescue nil)
85
104
  exit 0 if raw.nil? || raw.strip.empty?
@@ -93,73 +112,42 @@ if STATE_HOOKS.include?(gate)
93
112
  cwd = payload["cwd"].to_s
94
113
  cwd = Dir.pwd if cwd.empty? || !Dir.exist?(cwd)
95
114
  env = { "CLAUDE_CODE_SESSION_ID" => (session.empty? ? nil : session) }
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
118
+ end
96
119
  out, err, status = capture_launcher(env.merge("RUBYOPT" => nil), launcher, raw: raw, cwd: cwd)
97
120
  print out unless out.to_s.empty?
98
121
  warn err unless err.to_s.empty?
99
122
  exit(status ? status.exitstatus : 0)
100
123
  end
101
124
 
102
- if SHELL_HOOKS.include?(gate)
103
- # bash-gate (intent 203): a shell command has no apply_patch diff envelope, so
104
- # this branch execs the SAME scripts/hook-bash-gate file Claude already runs,
105
- # unmodified, and relays its exit code and stderr. It must never fall through
106
- # to ApplyPatchEnvelope.parse below (see header comment).
107
- cwd = payload["cwd"].to_s
108
- cwd = Dir.pwd if cwd.empty? || !Dir.exist?(cwd)
109
- env = { "CLAUDE_CODE_SESSION_ID" => (session.empty? ? nil : session) }
110
- script = File.join(__dir__, "hook-#{gate}")
111
- argv = [script]
112
- out, err, status = capture_launcher(env.merge("RUBYOPT" => nil), RbConfig.ruby, *argv, raw: raw, cwd: cwd)
113
- print out unless out.to_s.empty?
114
- warn err unless err.to_s.empty?
115
- exit(status ? status.exitstatus : 0)
116
- end
117
-
118
125
  require_relative "lib/apply_patch_envelope"
119
126
 
120
127
  command = payload.dig("tool_input", "command") || payload.dig("tool_params", "command")
121
- 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)
122
129
  ops = ApplyPatchEnvelope.parse(command)
123
- exit 0 if ops.empty? # fail-open: nothing parseable to gate
130
+ exit 0 if ops.empty? # fail-open: nothing parseable to record
124
131
 
125
132
  CORES = __dir__ # ~/.plastic/scripts
126
- def run_core(name, *argv)
127
- out = IO.popen([{ "RUBYOPT" => nil }, RbConfig.ruby, File.join(CORES, name), *argv], "r", err: [:child, :out], &:read)
128
- [out, $?.exitstatus]
129
- end
130
133
 
131
134
  case gate
132
- when "edit-gates"
133
- # The ONE registered PreToolUse command on the apply_patch matcher (intent
134
- # 251). Five gates, in-process, in Claude's evaluation order, first deny wins,
135
- # each deny shape reproduced exactly: stderr plus exit 2 for code-gate,
136
- # links-gate and create-gate; stdout permissionDecision JSON plus exit 0 for
137
- # lock-gate. Fail-open at both levels, per gate inside EditGates.dispatch and
138
- # again at this file's own top level.
139
- begin
140
- require_relative "lib/codex_edit_gates"
141
- exit CodexEditGates.dispatch(ops: ops, payload: payload)
142
- rescue StandardError, ScriptError => e
143
- # Dispatcher-internal failure: never impersonate a deny. ScriptError (a
144
- # sibling of StandardError, not a subclass) covers LoadError: a future
145
- # manifest gap (a lib file required here but not distributed, exactly the
146
- # bug the full suite caught during this intent's own delivery) must fail
147
- # open with a gate decision, never exit 1 with no decision at all.
148
- $stderr.puts "plastic codex edit-gates error: #{e.message}"
149
- exit 0
150
- end
151
-
152
- when "gate-check"
153
- 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
154
143
  ops.each do |o|
155
- out, code = run_core("hook-gate-check", o.path, session)
156
- if code == 2
157
- print out # {"decision":"block",...}
158
- 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
159
149
  end
160
- last_allow = out unless out.to_s.strip.empty?
161
150
  end
162
- print last_allow if last_allow
163
151
  exit 0
164
152
 
165
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 =