@zalom/plastic 1.8.0 → 1.10.0

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 (127) hide show
  1. package/PLASTIC.md +94 -556
  2. package/README.md +18 -6
  3. package/agents/plastic-enforcer.md +3 -2
  4. package/agents/plastic-intent-curator.md +2 -2
  5. package/agents/plastic-intent-discovery.md +7 -0
  6. package/bin/plastic.js +17 -8
  7. package/deprecations.yml +10 -2
  8. package/hooks/auto-arm +2 -2
  9. package/hooks/bash-gate +1 -1
  10. package/hooks/check-update +1 -1
  11. package/hooks/continue +2 -2
  12. package/hooks/edit-gates +3 -0
  13. package/hooks/future-intent-check +2 -2
  14. package/hooks/gate-check +3 -3
  15. package/hooks/hooks.json +4 -44
  16. package/hooks/power-tools +8 -0
  17. package/hooks/session-start +1 -1
  18. package/package.json +1 -2
  19. package/scripts/codex-hook +57 -118
  20. package/scripts/doctor.rb +146 -1012
  21. package/scripts/exec-worktree +103 -0
  22. package/scripts/hash-intent +1 -1
  23. package/scripts/hook-bash-gate +19 -0
  24. package/scripts/hook-code-gate +16 -28
  25. package/scripts/hook-continue +2 -2
  26. package/scripts/hook-create-gate +13 -57
  27. package/scripts/hook-edit-gates +58 -0
  28. package/scripts/hook-gate-check +17 -0
  29. package/scripts/hook-links-gate +11 -31
  30. package/scripts/hook-lock-gate +19 -60
  31. package/scripts/hook-power-tools +38 -0
  32. package/scripts/hook-savepoint-pre +11 -11
  33. package/scripts/hook-session-start +21 -15
  34. package/scripts/lib/apply_patch_envelope.rb +46 -13
  35. package/scripts/lib/bridge.rb +85 -17
  36. package/scripts/lib/codex_edit_gates.rb +138 -0
  37. package/scripts/lib/doctor_core.rb +1087 -0
  38. package/scripts/lib/edit_gates.rb +398 -0
  39. package/scripts/lib/exec_worktree.rb +325 -0
  40. package/scripts/lib/harness_text.rb +57 -0
  41. package/scripts/lib/hook_registry.rb +64 -28
  42. package/scripts/lib/installer_core.rb +166 -24
  43. package/scripts/lib/lock.rb +196 -47
  44. package/scripts/lib/qmd_hook.rb +21 -41
  45. package/scripts/lib/qmd_sync.rb +0 -15
  46. package/scripts/lib/revisions_writer.rb +1 -1
  47. package/scripts/lib/ruby_probe.rb +60 -0
  48. package/scripts/lib/scaffold_intent.rb +392 -0
  49. package/scripts/lib/spec_header.rb +83 -0
  50. package/scripts/lib/start_intent.rb +296 -0
  51. package/scripts/lib/verify_intent.rb +262 -0
  52. package/scripts/lib/worktree.rb +15 -1
  53. package/scripts/link-suggest +1 -1
  54. package/scripts/maintenance-run +5 -5
  55. package/scripts/migrate-to-global +2 -2
  56. package/scripts/restore-intent-v1 +17 -12
  57. package/scripts/scaffold-intent +120 -0
  58. package/scripts/start-intent +89 -0
  59. package/scripts/verify-intent +73 -0
  60. package/skills/agent-advisor/SKILL.md +5 -5
  61. package/skills/auto/SKILL.md +52 -32
  62. package/skills/auto/evals/evals.json +2 -2
  63. package/skills/auto/references/agent-architecture.md +1 -1
  64. package/skills/auto/references/agent-report-contract.md +1 -1
  65. package/skills/auto/references/human-report-contract.md +22 -3
  66. package/skills/auto/references/tiers.md +24 -2
  67. package/skills/conventions/SKILL.md +31 -0
  68. package/skills/conventions/references/completion-and-done.md +43 -0
  69. package/skills/conventions/references/gates-and-enforcement.md +39 -0
  70. package/skills/conventions/references/knowledge-graph.md +47 -0
  71. package/skills/conventions/references/lifecycle-and-savepoints.md +11 -0
  72. package/skills/conventions/references/locks-and-worktrees.md +113 -0
  73. package/skills/conventions/references/maintenance-and-revisions.md +170 -0
  74. package/skills/conventions/references/roadmaps.md +44 -0
  75. package/skills/conventions/references/tiers-and-dispatch.md +135 -0
  76. package/skills/dashboard/SKILL.md +1 -1
  77. package/skills/doctor/SKILL.md +15 -6
  78. package/skills/doctor/references/gates-stuck-detection.md +13 -8
  79. package/skills/doctor/report.md +1 -1
  80. package/skills/install/SKILL.md +1 -1
  81. package/skills/intent-brainstorming/SKILL.md +4 -2
  82. package/skills/intent-continuing/SKILL.md +4 -0
  83. package/skills/intent-creating/SKILL.md +13 -8
  84. package/skills/intent-creating/references/lifecycle.md +1 -1
  85. package/skills/intent-discovering/SKILL.md +10 -3
  86. package/skills/intent-ending/SKILL.md +12 -7
  87. package/skills/intent-executing/SKILL.md +37 -19
  88. package/skills/intent-grilling/SKILL.md +5 -3
  89. package/skills/intent-linking/SKILL.md +4 -0
  90. package/skills/intent-locking/SKILL.md +4 -0
  91. package/skills/intent-planning/SKILL.md +14 -3
  92. package/skills/intent-researching/SKILL.md +0 -2
  93. package/skills/intent-savepoint/SKILL.md +4 -0
  94. package/skills/intent-speccing/SKILL.md +4 -0
  95. package/skills/intent-starting/SKILL.md +20 -2
  96. package/skills/project-creating/SKILL.md +0 -2
  97. package/skills/project-creating/references/project-scaffolding.md +3 -3
  98. package/skills/releasing/SKILL.md +1 -1
  99. package/skills/releasing/references/promotion-and-tagging.md +14 -8
  100. package/skills/releasing/references/release-lines.md +1 -1
  101. package/skills/roadmap/SKILL.md +4 -0
  102. package/skills/roadmap-continuing/SKILL.md +4 -0
  103. package/skills/skill-creating/SKILL.md +8 -2
  104. package/skills/skill-creating/references/defaults-first.md +23 -0
  105. package/skills/skill-creating/references/hooks.md +4 -1
  106. package/skills/store-curating/SKILL.md +8 -0
  107. package/skills/store-indexing/SKILL.md +16 -5
  108. package/skills/store-indexing/references/zettelkasten-linking.md +1 -1
  109. package/skills/tutorial/references/track-1-guided.md +2 -2
  110. package/skills/tutorial/references/track-2-auto.md +10 -8
  111. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +1 -1
  112. package/skills/uninstall/SKILL.md +6 -9
  113. package/templates/agents.md +12 -12
  114. package/templates/config.yml +6 -7
  115. package/templates/index.md +6 -3
  116. package/templates/spec.md +1 -1
  117. package/PLASTIC-reference.md +0 -138
  118. package/hooks/code-gate +0 -27
  119. package/hooks/create-gate +0 -3
  120. package/hooks/links-gate +0 -3
  121. package/hooks/lock-gate +0 -21
  122. package/hooks/qmd-search +0 -8
  123. package/hooks/retrieval-gate +0 -10
  124. package/hooks/savepoint-pre +0 -10
  125. package/scripts/hook-qmd-search +0 -44
  126. package/scripts/hook-retrieval-gate +0 -148
  127. package/scripts/lib/retrieval_gate.rb +0 -211
@@ -0,0 +1,398 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # EditGates (intent 244): the five Claude PreToolUse edit-path gates as plain
5
+ # functions over one parsed payload. Three callers share these functions so the
6
+ # harnesses cannot drift: scripts/hook-edit-gates (the merged Claude dispatcher),
7
+ # scripts/lib/codex_edit_gates.rb (the merged Codex dispatcher, intent 251), and
8
+ # the retained scripts/hook-code-gate / hook-lock-gate / hook-savepoint-pre /
9
+ # hook-links-gate / hook-create-gate CLI wrappers, which no longer have a
10
+ # production caller on either harness and remain as the per-gate isolation
11
+ # surface for the hook test suite.
12
+ #
13
+ # A gate returns nil to allow, or a Deny. Two deny shapes coexist deliberately
14
+ # (spec D-b): :stderr (stderr lines plus exit 2, code-gate / links-gate /
15
+ # create-gate) and :json (stdout JSON plus exit 0, lock-gate, which reserves a
16
+ # non-zero exit for hook-internal errors). Nothing here normalizes them.
17
+
18
+ require "json"
19
+ require "time"
20
+ require_relative "hook_registry"
21
+ require_relative "bridge"
22
+ require_relative "lock"
23
+ require_relative "links_gate"
24
+ require_relative "intent_validator"
25
+
26
+ module EditGates
27
+ module_function
28
+
29
+ ALLOW = nil
30
+
31
+ Deny = Struct.new(:shape, :lines, :stdout, keyword_init: true)
32
+
33
+ # Everything the five gates read out of one PreToolUse payload (spec D-g).
34
+ # gate_path - code-gate and lock-gate: file_path || notebook_path ||
35
+ # relative_path, absolutized against tool_input.project_root ||
36
+ # payload.cwd when relative. Exactly what the old Bash shims
37
+ # computed with their `ruby -rjson -e` one-liners.
38
+ # file_path - savepoint-pre, links-gate, create-gate: tool_input.file_path
39
+ # only, raw. Preserving this narrower extraction is what keeps
40
+ # create-gate's dormant Serena gap byte-for-byte intact.
41
+ # content - the RAW tool_input.content (nil when the key is absent, ""
42
+ # when it is explicitly empty). Kept unmixed with new_string so
43
+ # links-gate and create-gate's `!content.nil?` branch keeps its
44
+ # exact original meaning; code-gate computes its own combined
45
+ # content-or-new_string-or-"" value locally, mirroring what its
46
+ # old Bash shim computed independently for ARGV[2].
47
+ Context = Struct.new(:tool_name, :session, :gate_path, :file_path, :savepoint_path,
48
+ :content, :old_string, :new_string, :replace_all, :harness,
49
+ keyword_init: true) do
50
+ # links-gate and create-gate branch on content.nil? (key absent) vs content
51
+ # present (even ""). This is the raw field already, kept as a named reader
52
+ # so both gates read intent, not a struct field, at the call site.
53
+ def content_or_nil
54
+ content
55
+ end
56
+ end
57
+
58
+ # Per-key tool_input/tool_params fallback (post-review fix, intent 244): old
59
+ # links-gate and create-gate read
60
+ # `payload.dig("tool_input", key) || payload.dig("tool_params", key)`, each
61
+ # key falling back independently, NOT the whole-object pick `input =
62
+ # tool_input || tool_params` used for gate_path/content/etc below. This
63
+ # matters for a payload shaped tool_input: {} plus
64
+ # tool_params: { file_path: X }: the whole-object read stops at the (empty)
65
+ # tool_input and never sees X; the per-key read still finds it. `prefer`
66
+ # flips which object wins when BOTH carry the key, since savepoint-pre alone
67
+ # read tool_params FIRST (see savepoint_path below).
68
+ def per_key_field(payload, key, prefer: "tool_input")
69
+ other = prefer == "tool_input" ? "tool_params" : "tool_input"
70
+ a = payload[prefer]
71
+ b = payload[other]
72
+ (a.is_a?(Hash) ? a[key] : nil) || (b.is_a?(Hash) ? b[key] : nil)
73
+ end
74
+
75
+ def context_from(payload, env: ENV)
76
+ input = payload["tool_input"] || payload["tool_params"] || {}
77
+ input = {} unless input.is_a?(Hash)
78
+
79
+ raw = input["file_path"] || input["notebook_path"] || input["relative_path"] || ""
80
+ if !raw.empty? && !raw.start_with?("/")
81
+ root = input["project_root"] || payload["cwd"] || ""
82
+ raw = File.join(root, raw) unless root.empty?
83
+ end
84
+
85
+ session = payload["session_id"]
86
+ session = env["CLAUDE_CODE_SESSION_ID"] if session.to_s.empty?
87
+
88
+ Context.new(
89
+ tool_name: payload["tool_name"],
90
+ session: (session unless session.to_s.empty?),
91
+ gate_path: raw,
92
+ file_path: per_key_field(payload, "file_path", prefer: "tool_input"),
93
+ # savepoint-pre's own historical precedence, inverted from links-gate /
94
+ # create-gate above: old hooks/savepoint-pre read
95
+ # `dig("tool_params","file_path") || dig("tool_input","file_path")`.
96
+ savepoint_path: per_key_field(payload, "file_path", prefer: "tool_params"),
97
+ content: input["content"],
98
+ old_string: input["old_string"],
99
+ new_string: input["new_string"],
100
+ replace_all: input["replace_all"],
101
+ harness: "claude",
102
+ )
103
+ end
104
+
105
+ # --- savepoint-pre (intent 81): never denies; appends the `started` ledger line.
106
+ def savepoint_pre(ctx)
107
+ # ctx.savepoint_path carries context_from's tool_params-first read (fix 3);
108
+ # falls back to ctx.file_path for the thin CLI wrapper (hook-savepoint-pre),
109
+ # which only ever sets file_path directly and never populates savepoint_path.
110
+ path = ctx.savepoint_path || ctx.file_path
111
+ return ALLOW if path.nil? || path.empty?
112
+
113
+ abs = File.expand_path(path)
114
+ intent_dir = Bridge.intent_dir_for(abs)
115
+ return ALLOW unless intent_dir
116
+
117
+ begin
118
+ Bridge.append_started_savepoint(intent_dir, abs)
119
+ rescue StandardError
120
+ # best-effort; the ledger is rebuildable and the post line still lands
121
+ end
122
+ ALLOW
123
+ end
124
+
125
+ # --- lock-gate (intent 96, 111): fail-CLOSED delivery lock plus the artifact claim.
126
+ def lock_gate(ctx)
127
+ file_path = ctx.gate_path
128
+ return ALLOW if file_path.nil? || file_path.empty?
129
+
130
+ session = ctx.session
131
+ harness = ctx.harness
132
+
133
+ bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
134
+ reason = Bridge.lock_gate_decision(bridge_data, file_path, session: session, harness: harness)
135
+
136
+ unless reason
137
+ begin
138
+ dir = Bridge.intent_dir_for(file_path)
139
+ Lock.heartbeat(dir, session: session) if dir && !session.to_s.empty?
140
+ rescue StandardError
141
+ # ignore
142
+ end
143
+
144
+ begin
145
+ dir = Bridge.intent_dir_for(file_path)
146
+ artifact = File.basename(file_path) if dir
147
+ if dir && artifact
148
+ claim_reason = Claim.claim_gate_reason(dir, artifact, session: session, harness: harness)
149
+ return deny_json(claim_reason) if claim_reason
150
+
151
+ if Claim.fail_open?(dir, artifact)
152
+ $stderr.puts "plastic claim gate: unresolvable claim on #{artifact} in " \
153
+ "intent #{Bridge.intent_id_from_dir(dir)} yielded; write " \
154
+ "proceeds (see /plastic-lock status)"
155
+ end
156
+
157
+ Claim.heartbeat(dir, artifact, session: session) rescue nil
158
+ end
159
+ rescue StandardError
160
+ # ignore: a claim-gate bug must never block a write (fail open)
161
+ end
162
+
163
+ return ALLOW
164
+ end
165
+
166
+ deny_json(reason)
167
+ end
168
+
169
+ def deny_json(reason)
170
+ Deny.new(shape: :json, stdout: JSON.generate(
171
+ "hookSpecificOutput" => {
172
+ "hookEventName" => "PreToolUse",
173
+ "permissionDecision" => "deny",
174
+ "permissionDecisionReason" => reason,
175
+ }
176
+ ))
177
+ end
178
+
179
+ # --- code-gate (intents 27, 73c2, 150): stage rule OR worktree rule, first match wins.
180
+ def code_gate(ctx)
181
+ file_path = ctx.gate_path
182
+ return ALLOW if file_path.nil? || file_path.empty?
183
+
184
+ session = ctx.session
185
+ # Auditable escape (intent 150): mirrors the old Bash shim's independent
186
+ # ti["content"] || ti["new_string"] || "" computation for ARGV[2]. Kept
187
+ # local to code-gate; links-gate and create-gate never read it.
188
+ new_content = ctx.content || ctx.new_string || ""
189
+
190
+ if new_content && Bridge::PLASTIC_OK_RE.match?(new_content.chomp)
191
+ log_escape(session, file_path)
192
+ return ALLOW
193
+ end
194
+
195
+ bridge_data = Bridge.discover_bridge(session: session, cwd: file_path, edited_path: file_path)
196
+ return ALLOW unless bridge_data
197
+
198
+ reason = Bridge.code_gate_decision(bridge_data, file_path) ||
199
+ Bridge.worktree_gate_decision(bridge_data, file_path, current_session: session)
200
+ return ALLOW unless reason
201
+
202
+ Deny.new(shape: :stderr, lines: ["PLASTIC GATE — #{reason}"])
203
+ end
204
+
205
+ def log_escape(session, file_path)
206
+ require "fileutils"
207
+ log = File.join(Dir.home, ".plastic", ".cache", "gate-escapes.log")
208
+ FileUtils.mkdir_p(File.dirname(log))
209
+ File.open(log, "a") do |io|
210
+ io.puts("#{Time.now.utc.iso8601}\t#{session}\t#{file_path}")
211
+ end
212
+ rescue StandardError
213
+ # the escape still applies; logging is best-effort
214
+ end
215
+
216
+ # --- block log (intent 229): one TSV line per block, six tab-separated fields
217
+ # in a fixed order. Mirrors log_escape above exactly: best-effort, wrapped in
218
+ # rescue StandardError, so a logging failure can never change what a gate
219
+ # decided and can never raise. `path` is the injected seam a test uses to
220
+ # force the write to fail without eval, ENV, or a global.
221
+ def block_log_path
222
+ File.join(Dir.home, ".plastic", ".cache", "gate-blocks.log")
223
+ end
224
+
225
+ def log_block(gate:, session:, intent:, subject:, rule:, path: nil)
226
+ require "fileutils"
227
+ log = path || block_log_path
228
+ FileUtils.mkdir_p(File.dirname(log))
229
+ File.open(log, "a") do |io|
230
+ io.puts([Time.now.utc.iso8601, gate, session.to_s, intent.to_s,
231
+ collapse_ws(subject), collapse_ws(rule)].join("\t"))
232
+ end
233
+ rescue StandardError
234
+ # the block still applies; logging is best-effort
235
+ end
236
+
237
+ def collapse_ws(value)
238
+ value.to_s.gsub(/\s+/, " ").strip
239
+ end
240
+
241
+ # The reason text a Deny carries, per shape. Rescues to "" so a malformed
242
+ # outcome costs one field, never the line and never the decision.
243
+ def deny_reason(outcome)
244
+ if outcome.shape == :json
245
+ JSON.parse(outcome.stdout.to_s).dig("hookSpecificOutput", "permissionDecisionReason").to_s
246
+ else
247
+ Array(outcome.lines).join(" ")
248
+ end
249
+ rescue StandardError
250
+ ""
251
+ end
252
+
253
+ # "" when the path is not inside an intent dir, keeping the column count fixed.
254
+ def block_intent_id(path)
255
+ dir = Bridge.intent_dir_for(path.to_s)
256
+ dir ? Bridge.intent_id_from_dir(dir).to_s : ""
257
+ rescue StandardError
258
+ ""
259
+ end
260
+
261
+ # --- links-gate (intent 192): the write-time belt for the ## Links contract.
262
+ def links_gate(ctx)
263
+ path = ctx.file_path
264
+ return ALLOW if path.to_s.strip.empty?
265
+
266
+ abs = File.expand_path(path)
267
+ return ALLOW unless LinksGate.intent_file?(abs)
268
+
269
+ content = ctx.content_or_nil
270
+ old_string = ctx.old_string
271
+
272
+ before_content = File.exist?(abs) ? File.read(abs) : ""
273
+
274
+ after_content =
275
+ if !content.nil?
276
+ content
277
+ elsif !old_string.nil?
278
+ return ALLOW unless before_content.include?(old_string)
279
+ ctx.replace_all ? before_content.gsub(old_string, ctx.new_string.to_s)
280
+ : before_content.sub(old_string, ctx.new_string.to_s)
281
+ else
282
+ return ALLOW # pathless mutation (no visible proposal); cannot judge, allow
283
+ end
284
+
285
+ plastic_home = ENV.fetch("PLASTIC_HOME") { File.join(Dir.home, ".plastic") }
286
+
287
+ reason = LinksGate.decision(file_path: abs, before_content: before_content,
288
+ after_content: after_content, plastic_home: plastic_home)
289
+ return ALLOW unless reason
290
+
291
+ Deny.new(shape: :stderr, lines: [reason])
292
+ end
293
+
294
+ # --- create-gate (intents 60b, 108 D7): validate the PROPOSED intent file content.
295
+ def create_gate(ctx)
296
+ path = ctx.file_path
297
+ return ALLOW if path.nil? || path.to_s.strip.empty?
298
+
299
+ abs = File.expand_path(path)
300
+ dir = File.dirname(abs)
301
+ is_intent_file = dir.match?(%r{/store/[^/]+--[^/]+\z}) &&
302
+ File.basename(abs) == "#{File.basename(dir)}.md"
303
+ return ALLOW unless is_intent_file
304
+
305
+ content = ctx.content_or_nil
306
+ old_string = ctx.old_string
307
+
308
+ result =
309
+ if !content.nil?
310
+ IntentValidator.validate_content(content)
311
+ elsif !old_string.nil?
312
+ unless File.exist?(abs)
313
+ return Deny.new(shape: :stderr, lines: [
314
+ "PLASTIC CREATE GATE — #{File.basename(abs)} does not exist; " \
315
+ "create intents via new-intent / plastic-intent-creating.",
316
+ ])
317
+ end
318
+ current = File.read(abs)
319
+ return ALLOW unless current.include?(old_string)
320
+ simulated = ctx.replace_all ? current.gsub(old_string, ctx.new_string.to_s)
321
+ : current.sub(old_string, ctx.new_string.to_s)
322
+ IntentValidator.validate_content(simulated)
323
+ else
324
+ unless File.exist?(abs)
325
+ return Deny.new(shape: :stderr, lines: [
326
+ "PLASTIC CREATE GATE — #{File.basename(abs)}: cannot read proposed " \
327
+ "content and no file exists; refusing to allow an unvalidated intent write.",
328
+ ])
329
+ end
330
+ IntentValidator.validate_content(File.read(abs))
331
+ end
332
+
333
+ return ALLOW if result[:ok]
334
+
335
+ lines = ["PLASTIC CREATE GATE — #{File.basename(abs)} is not a valid intent:"]
336
+ result[:errors].each { |e| lines << " #{e}" }
337
+ lines << "Create intents via new-intent / plastic-intent-creating; do not hand-author them."
338
+ Deny.new(shape: :stderr, lines: lines)
339
+ end
340
+
341
+ # Applicability (post-review fixes 1+2, intent 244): reproduces the Claude
342
+ # harness's OWN matcher semantics, not the dispatcher's earlier string
343
+ # equality. Two corrections:
344
+ # - the harness matches an UNANCHORED REGEX (`"Write|Edit"` matches
345
+ # "NotebookEdit" by substring), so applicability must too, or a gate
346
+ # whose table entry never listed NotebookEdit explicitly stops firing on
347
+ # it even though the old per-gate matcher group DID fire (a coverage
348
+ # narrowing the byte-identical-behavior bar exists to catch);
349
+ # - a missing/unrecognized tool_name means "the harness matched, but did
350
+ # not name the tool", not "not our tool": the five old wrappers never
351
+ # read tool_name at all, so they ALL ran on such a payload. Skipping
352
+ # every gate here would be a coverage WEAKENING (the gate-weakening
353
+ # direction is the dangerous one; see intent 203).
354
+ def tool_applies?(tool_name, tools)
355
+ return true if tool_name.to_s.empty?
356
+ Regexp.new(tools.join("|")).match?(tool_name.to_s)
357
+ end
358
+
359
+ # --- the ordered evaluation (spec D-a, D-i) -------------------------------
360
+ # `route` is injected so a test can supply a raising gate and prove isolation
361
+ # without eval, an ENV seam, or a global. `gate_tools` is injected for the same
362
+ # reason and defaults to the single source of truth.
363
+ # Returns the process exit code.
364
+ def dispatch(ctx:, route:, gate_tools: HookRegistry::GATE_TOOLS, out: $stdout, err: $stderr,
365
+ block_log: nil)
366
+ gate_tools.each do |gate, tools|
367
+ next unless tool_applies?(ctx.tool_name, tools)
368
+
369
+ begin
370
+ outcome = route.call(gate, ctx)
371
+ next unless outcome
372
+
373
+ # intent 229: one block-log line per deny, both shapes, one call site.
374
+ # Every helper here rescues internally, so nothing new can reach this
375
+ # method's rescue and turn a logging problem into a changed decision.
376
+ subject = ctx.gate_path.to_s.empty? ? ctx.file_path.to_s : ctx.gate_path.to_s
377
+ log_block(gate: gate, session: ctx.session, intent: block_intent_id(subject),
378
+ subject: subject, rule: deny_reason(outcome), path: block_log)
379
+
380
+ if outcome.shape == :json
381
+ out.print outcome.stdout
382
+ return 0
383
+ else
384
+ outcome.lines.each { |line| err.puts line }
385
+ return 2
386
+ end
387
+ rescue StandardError => e
388
+ # Fix 7: outcome.shape / out.print / err.puts now live INSIDE the same
389
+ # rescue as route.call, so a malformed outcome or a stream write
390
+ # failure (e.g. EPIPE) is isolated exactly like a raising gate, and
391
+ # never aborts the gates still to come.
392
+ err.puts "plastic #{gate} error: #{e.message}"
393
+ next
394
+ end
395
+ end
396
+ 0
397
+ end
398
+ end