@zalom/plastic 1.7.1 → 1.9.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.
- package/PLASTIC.md +75 -535
- package/README.md +3 -2
- package/agents/plastic-intent-curator.md +2 -2
- package/deprecations.yml +10 -2
- package/hooks/{links-gate → edit-gates} +1 -1
- package/hooks/hooks.json +4 -44
- package/hooks/power-tools +8 -0
- package/package.json +1 -2
- package/scripts/codex-hook +17 -22
- package/scripts/doctor.rb +688 -186
- package/scripts/end-intent +65 -22
- package/scripts/hook-code-gate +13 -28
- package/scripts/hook-create-gate +10 -57
- package/scripts/hook-edit-gates +58 -0
- package/scripts/hook-links-gate +8 -31
- package/scripts/hook-lock-gate +13 -58
- package/scripts/hook-power-tools +38 -0
- package/scripts/hook-savepoint-pre +8 -11
- package/scripts/lib/bridge.rb +2 -2
- package/scripts/lib/edit_gates.rb +342 -0
- package/scripts/lib/hook_registry.rb +61 -29
- package/scripts/lib/installer_core.rb +100 -18
- package/scripts/lib/outcome_guard.rb +38 -0
- package/scripts/lib/qmd_hook.rb +21 -41
- package/scripts/lib/qmd_sync.rb +0 -15
- package/scripts/lib/revisions_writer.rb +1 -1
- package/scripts/maintenance-run +56 -2
- package/scripts/restore-intent-v1 +16 -11
- package/skills/auto/SKILL.md +15 -4
- package/skills/auto/evals/evals.json +2 -2
- package/skills/conventions/SKILL.md +31 -0
- package/skills/conventions/references/completion-and-done.md +40 -0
- package/skills/conventions/references/gates-and-enforcement.md +23 -0
- package/skills/conventions/references/knowledge-graph.md +47 -0
- package/skills/conventions/references/lifecycle-and-savepoints.md +11 -0
- package/skills/conventions/references/locks-and-worktrees.md +113 -0
- package/skills/conventions/references/maintenance-and-revisions.md +170 -0
- package/skills/conventions/references/roadmaps.md +44 -0
- package/skills/conventions/references/tiers-and-dispatch.md +134 -0
- package/skills/doctor/SKILL.md +59 -19
- package/skills/intent-brainstorming/SKILL.md +4 -0
- package/skills/intent-continuing/SKILL.md +4 -0
- package/skills/intent-creating/SKILL.md +7 -2
- package/skills/intent-ending/SKILL.md +20 -6
- package/skills/intent-executing/SKILL.md +10 -0
- package/skills/intent-linking/SKILL.md +4 -0
- package/skills/intent-locking/SKILL.md +4 -0
- package/skills/intent-planning/SKILL.md +7 -0
- package/skills/intent-savepoint/SKILL.md +4 -0
- package/skills/intent-speccing/SKILL.md +4 -0
- package/skills/intent-starting/SKILL.md +10 -0
- package/skills/project-creating/references/project-scaffolding.md +2 -2
- package/skills/roadmap/SKILL.md +4 -0
- package/skills/roadmap-continuing/SKILL.md +4 -0
- package/skills/skill-creating/SKILL.md +3 -0
- package/skills/skill-creating/references/defaults-first.md +23 -0
- package/skills/skill-creating/references/hooks.md +4 -1
- package/skills/store-curating/SKILL.md +8 -0
- package/skills/store-indexing/SKILL.md +8 -0
- package/skills/tutorial/references/track-2-auto.md +2 -3
- package/templates/agents.md +8 -0
- package/PLASTIC-reference.md +0 -138
- package/hooks/code-gate +0 -27
- package/hooks/create-gate +0 -3
- package/hooks/lock-gate +0 -21
- package/hooks/qmd-search +0 -8
- package/hooks/retrieval-gate +0 -10
- package/hooks/savepoint-pre +0 -10
- package/scripts/hook-qmd-search +0 -44
- package/scripts/hook-retrieval-gate +0 -148
- package/scripts/lib/legacy_bookend_amnesty.rb +0 -35
- package/scripts/lib/retrieval_gate.rb +0 -211
|
@@ -0,0 +1,342 @@
|
|
|
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. Two callers share these functions so the
|
|
6
|
+
# harnesses cannot drift: scripts/hook-edit-gates (the merged Claude dispatcher)
|
|
7
|
+
# and the retained scripts/hook-code-gate / hook-lock-gate / hook-savepoint-pre
|
|
8
|
+
# CLI wrappers that scripts/codex-hook still popens per file op.
|
|
9
|
+
#
|
|
10
|
+
# A gate returns nil to allow, or a Deny. Two deny shapes coexist deliberately
|
|
11
|
+
# (spec D-b): :stderr (stderr lines plus exit 2, code-gate / links-gate /
|
|
12
|
+
# create-gate) and :json (stdout JSON plus exit 0, lock-gate, which reserves a
|
|
13
|
+
# non-zero exit for hook-internal errors). Nothing here normalizes them.
|
|
14
|
+
|
|
15
|
+
require "json"
|
|
16
|
+
require "time"
|
|
17
|
+
require_relative "hook_registry"
|
|
18
|
+
require_relative "bridge"
|
|
19
|
+
require_relative "lock"
|
|
20
|
+
require_relative "links_gate"
|
|
21
|
+
require_relative "intent_validator"
|
|
22
|
+
|
|
23
|
+
module EditGates
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
ALLOW = nil
|
|
27
|
+
|
|
28
|
+
Deny = Struct.new(:shape, :lines, :stdout, keyword_init: true)
|
|
29
|
+
|
|
30
|
+
# Everything the five gates read out of one PreToolUse payload (spec D-g).
|
|
31
|
+
# gate_path - code-gate and lock-gate: file_path || notebook_path ||
|
|
32
|
+
# relative_path, absolutized against tool_input.project_root ||
|
|
33
|
+
# payload.cwd when relative. Exactly what the old Bash shims
|
|
34
|
+
# computed with their `ruby -rjson -e` one-liners.
|
|
35
|
+
# file_path - savepoint-pre, links-gate, create-gate: tool_input.file_path
|
|
36
|
+
# only, raw. Preserving this narrower extraction is what keeps
|
|
37
|
+
# create-gate's dormant Serena gap byte-for-byte intact.
|
|
38
|
+
# content - the RAW tool_input.content (nil when the key is absent, ""
|
|
39
|
+
# when it is explicitly empty). Kept unmixed with new_string so
|
|
40
|
+
# links-gate and create-gate's `!content.nil?` branch keeps its
|
|
41
|
+
# exact original meaning; code-gate computes its own combined
|
|
42
|
+
# content-or-new_string-or-"" value locally, mirroring what its
|
|
43
|
+
# old Bash shim computed independently for ARGV[2].
|
|
44
|
+
Context = Struct.new(:tool_name, :session, :gate_path, :file_path, :savepoint_path,
|
|
45
|
+
:content, :old_string, :new_string, :replace_all, :harness,
|
|
46
|
+
keyword_init: true) do
|
|
47
|
+
# links-gate and create-gate branch on content.nil? (key absent) vs content
|
|
48
|
+
# present (even ""). This is the raw field already, kept as a named reader
|
|
49
|
+
# so both gates read intent, not a struct field, at the call site.
|
|
50
|
+
def content_or_nil
|
|
51
|
+
content
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Per-key tool_input/tool_params fallback (post-review fix, intent 244): old
|
|
56
|
+
# links-gate and create-gate read
|
|
57
|
+
# `payload.dig("tool_input", key) || payload.dig("tool_params", key)`, each
|
|
58
|
+
# key falling back independently, NOT the whole-object pick `input =
|
|
59
|
+
# tool_input || tool_params` used for gate_path/content/etc below. This
|
|
60
|
+
# matters for a payload shaped tool_input: {} plus
|
|
61
|
+
# tool_params: { file_path: X }: the whole-object read stops at the (empty)
|
|
62
|
+
# tool_input and never sees X; the per-key read still finds it. `prefer`
|
|
63
|
+
# flips which object wins when BOTH carry the key, since savepoint-pre alone
|
|
64
|
+
# read tool_params FIRST (see savepoint_path below).
|
|
65
|
+
def per_key_field(payload, key, prefer: "tool_input")
|
|
66
|
+
other = prefer == "tool_input" ? "tool_params" : "tool_input"
|
|
67
|
+
a = payload[prefer]
|
|
68
|
+
b = payload[other]
|
|
69
|
+
(a.is_a?(Hash) ? a[key] : nil) || (b.is_a?(Hash) ? b[key] : nil)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def context_from(payload, env: ENV)
|
|
73
|
+
input = payload["tool_input"] || payload["tool_params"] || {}
|
|
74
|
+
input = {} unless input.is_a?(Hash)
|
|
75
|
+
|
|
76
|
+
raw = input["file_path"] || input["notebook_path"] || input["relative_path"] || ""
|
|
77
|
+
if !raw.empty? && !raw.start_with?("/")
|
|
78
|
+
root = input["project_root"] || payload["cwd"] || ""
|
|
79
|
+
raw = File.join(root, raw) unless root.empty?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
session = payload["session_id"]
|
|
83
|
+
session = env["CLAUDE_CODE_SESSION_ID"] if session.to_s.empty?
|
|
84
|
+
|
|
85
|
+
Context.new(
|
|
86
|
+
tool_name: payload["tool_name"],
|
|
87
|
+
session: (session unless session.to_s.empty?),
|
|
88
|
+
gate_path: raw,
|
|
89
|
+
file_path: per_key_field(payload, "file_path", prefer: "tool_input"),
|
|
90
|
+
# savepoint-pre's own historical precedence, inverted from links-gate /
|
|
91
|
+
# create-gate above: old hooks/savepoint-pre read
|
|
92
|
+
# `dig("tool_params","file_path") || dig("tool_input","file_path")`.
|
|
93
|
+
savepoint_path: per_key_field(payload, "file_path", prefer: "tool_params"),
|
|
94
|
+
content: input["content"],
|
|
95
|
+
old_string: input["old_string"],
|
|
96
|
+
new_string: input["new_string"],
|
|
97
|
+
replace_all: input["replace_all"],
|
|
98
|
+
harness: "claude",
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# --- savepoint-pre (intent 81): never denies; appends the `started` ledger line.
|
|
103
|
+
def savepoint_pre(ctx)
|
|
104
|
+
# ctx.savepoint_path carries context_from's tool_params-first read (fix 3);
|
|
105
|
+
# falls back to ctx.file_path for the thin CLI wrapper (hook-savepoint-pre),
|
|
106
|
+
# which only ever sets file_path directly and never populates savepoint_path.
|
|
107
|
+
path = ctx.savepoint_path || ctx.file_path
|
|
108
|
+
return ALLOW if path.nil? || path.empty?
|
|
109
|
+
|
|
110
|
+
abs = File.expand_path(path)
|
|
111
|
+
intent_dir = Bridge.intent_dir_for(abs)
|
|
112
|
+
return ALLOW unless intent_dir
|
|
113
|
+
|
|
114
|
+
begin
|
|
115
|
+
Bridge.append_started_savepoint(intent_dir, abs)
|
|
116
|
+
rescue StandardError
|
|
117
|
+
# best-effort; the ledger is rebuildable and the post line still lands
|
|
118
|
+
end
|
|
119
|
+
ALLOW
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# --- lock-gate (intent 96, 111): fail-CLOSED delivery lock plus the artifact claim.
|
|
123
|
+
def lock_gate(ctx)
|
|
124
|
+
file_path = ctx.gate_path
|
|
125
|
+
return ALLOW if file_path.nil? || file_path.empty?
|
|
126
|
+
|
|
127
|
+
session = ctx.session
|
|
128
|
+
harness = ctx.harness
|
|
129
|
+
|
|
130
|
+
bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
|
|
131
|
+
reason = Bridge.lock_gate_decision(bridge_data, file_path, session: session, harness: harness)
|
|
132
|
+
|
|
133
|
+
unless reason
|
|
134
|
+
begin
|
|
135
|
+
dir = Bridge.intent_dir_for(file_path)
|
|
136
|
+
Lock.heartbeat(dir, session: session) if dir && !session.to_s.empty?
|
|
137
|
+
rescue StandardError
|
|
138
|
+
# ignore
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
begin
|
|
142
|
+
dir = Bridge.intent_dir_for(file_path)
|
|
143
|
+
artifact = File.basename(file_path) if dir
|
|
144
|
+
if dir && artifact
|
|
145
|
+
claim_reason = Claim.claim_gate_reason(dir, artifact, session: session, harness: harness)
|
|
146
|
+
return deny_json(claim_reason) if claim_reason
|
|
147
|
+
|
|
148
|
+
if Claim.fail_open?(dir, artifact)
|
|
149
|
+
$stderr.puts "plastic claim gate: unresolvable claim on #{artifact} in " \
|
|
150
|
+
"intent #{Bridge.intent_id_from_dir(dir)} yielded; write " \
|
|
151
|
+
"proceeds (see /plastic-lock status)"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
Claim.heartbeat(dir, artifact, session: session) rescue nil
|
|
155
|
+
end
|
|
156
|
+
rescue StandardError
|
|
157
|
+
# ignore: a claim-gate bug must never block a write (fail open)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
return ALLOW
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
deny_json(reason)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def deny_json(reason)
|
|
167
|
+
Deny.new(shape: :json, stdout: JSON.generate(
|
|
168
|
+
"hookSpecificOutput" => {
|
|
169
|
+
"hookEventName" => "PreToolUse",
|
|
170
|
+
"permissionDecision" => "deny",
|
|
171
|
+
"permissionDecisionReason" => reason,
|
|
172
|
+
}
|
|
173
|
+
))
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# --- code-gate (intents 27, 73c2, 150): stage rule OR worktree rule, first match wins.
|
|
177
|
+
def code_gate(ctx)
|
|
178
|
+
file_path = ctx.gate_path
|
|
179
|
+
return ALLOW if file_path.nil? || file_path.empty?
|
|
180
|
+
|
|
181
|
+
session = ctx.session
|
|
182
|
+
# Auditable escape (intent 150): mirrors the old Bash shim's independent
|
|
183
|
+
# ti["content"] || ti["new_string"] || "" computation for ARGV[2]. Kept
|
|
184
|
+
# local to code-gate; links-gate and create-gate never read it.
|
|
185
|
+
new_content = ctx.content || ctx.new_string || ""
|
|
186
|
+
|
|
187
|
+
if new_content && Bridge::PLASTIC_OK_RE.match?(new_content.chomp)
|
|
188
|
+
log_escape(session, file_path)
|
|
189
|
+
return ALLOW
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
bridge_data = Bridge.discover_bridge(session: session, cwd: file_path, edited_path: file_path)
|
|
193
|
+
return ALLOW unless bridge_data
|
|
194
|
+
|
|
195
|
+
reason = Bridge.code_gate_decision(bridge_data, file_path) ||
|
|
196
|
+
Bridge.worktree_gate_decision(bridge_data, file_path, current_session: session)
|
|
197
|
+
return ALLOW unless reason
|
|
198
|
+
|
|
199
|
+
Deny.new(shape: :stderr, lines: ["PLASTIC GATE — #{reason}"])
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def log_escape(session, file_path)
|
|
203
|
+
require "fileutils"
|
|
204
|
+
log = File.join(Dir.home, ".plastic", ".cache", "gate-escapes.log")
|
|
205
|
+
FileUtils.mkdir_p(File.dirname(log))
|
|
206
|
+
File.open(log, "a") do |io|
|
|
207
|
+
io.puts("#{Time.now.utc.iso8601}\t#{session}\t#{file_path}")
|
|
208
|
+
end
|
|
209
|
+
rescue StandardError
|
|
210
|
+
# the escape still applies; logging is best-effort
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# --- links-gate (intent 192): the write-time belt for the ## Links contract.
|
|
214
|
+
def links_gate(ctx)
|
|
215
|
+
path = ctx.file_path
|
|
216
|
+
return ALLOW if path.to_s.strip.empty?
|
|
217
|
+
|
|
218
|
+
abs = File.expand_path(path)
|
|
219
|
+
return ALLOW unless LinksGate.intent_file?(abs)
|
|
220
|
+
|
|
221
|
+
content = ctx.content_or_nil
|
|
222
|
+
old_string = ctx.old_string
|
|
223
|
+
|
|
224
|
+
before_content = File.exist?(abs) ? File.read(abs) : ""
|
|
225
|
+
|
|
226
|
+
after_content =
|
|
227
|
+
if !content.nil?
|
|
228
|
+
content
|
|
229
|
+
elsif !old_string.nil?
|
|
230
|
+
return ALLOW unless before_content.include?(old_string)
|
|
231
|
+
ctx.replace_all ? before_content.gsub(old_string, ctx.new_string.to_s)
|
|
232
|
+
: before_content.sub(old_string, ctx.new_string.to_s)
|
|
233
|
+
else
|
|
234
|
+
return ALLOW # pathless mutation (no visible proposal); cannot judge, allow
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
plastic_home = ENV.fetch("PLASTIC_HOME") { File.join(Dir.home, ".plastic") }
|
|
238
|
+
|
|
239
|
+
reason = LinksGate.decision(file_path: abs, before_content: before_content,
|
|
240
|
+
after_content: after_content, plastic_home: plastic_home)
|
|
241
|
+
return ALLOW unless reason
|
|
242
|
+
|
|
243
|
+
Deny.new(shape: :stderr, lines: [reason])
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# --- create-gate (intents 60b, 108 D7): validate the PROPOSED intent file content.
|
|
247
|
+
def create_gate(ctx)
|
|
248
|
+
path = ctx.file_path
|
|
249
|
+
return ALLOW if path.nil? || path.to_s.strip.empty?
|
|
250
|
+
|
|
251
|
+
abs = File.expand_path(path)
|
|
252
|
+
dir = File.dirname(abs)
|
|
253
|
+
is_intent_file = dir.match?(%r{/store/[^/]+--[^/]+\z}) &&
|
|
254
|
+
File.basename(abs) == "#{File.basename(dir)}.md"
|
|
255
|
+
return ALLOW unless is_intent_file
|
|
256
|
+
|
|
257
|
+
content = ctx.content_or_nil
|
|
258
|
+
old_string = ctx.old_string
|
|
259
|
+
|
|
260
|
+
result =
|
|
261
|
+
if !content.nil?
|
|
262
|
+
IntentValidator.validate_content(content)
|
|
263
|
+
elsif !old_string.nil?
|
|
264
|
+
unless File.exist?(abs)
|
|
265
|
+
return Deny.new(shape: :stderr, lines: [
|
|
266
|
+
"PLASTIC CREATE GATE — #{File.basename(abs)} does not exist; " \
|
|
267
|
+
"create intents via new-intent / plastic-intent-creating.",
|
|
268
|
+
])
|
|
269
|
+
end
|
|
270
|
+
current = File.read(abs)
|
|
271
|
+
return ALLOW unless current.include?(old_string)
|
|
272
|
+
simulated = ctx.replace_all ? current.gsub(old_string, ctx.new_string.to_s)
|
|
273
|
+
: current.sub(old_string, ctx.new_string.to_s)
|
|
274
|
+
IntentValidator.validate_content(simulated)
|
|
275
|
+
else
|
|
276
|
+
unless File.exist?(abs)
|
|
277
|
+
return Deny.new(shape: :stderr, lines: [
|
|
278
|
+
"PLASTIC CREATE GATE — #{File.basename(abs)}: cannot read proposed " \
|
|
279
|
+
"content and no file exists; refusing to allow an unvalidated intent write.",
|
|
280
|
+
])
|
|
281
|
+
end
|
|
282
|
+
IntentValidator.validate_content(File.read(abs))
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
return ALLOW if result[:ok]
|
|
286
|
+
|
|
287
|
+
lines = ["PLASTIC CREATE GATE — #{File.basename(abs)} is not a valid intent:"]
|
|
288
|
+
result[:errors].each { |e| lines << " #{e}" }
|
|
289
|
+
lines << "Create intents via new-intent / plastic-intent-creating; do not hand-author them."
|
|
290
|
+
Deny.new(shape: :stderr, lines: lines)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Applicability (post-review fixes 1+2, intent 244): reproduces the Claude
|
|
294
|
+
# harness's OWN matcher semantics, not the dispatcher's earlier string
|
|
295
|
+
# equality. Two corrections:
|
|
296
|
+
# - the harness matches an UNANCHORED REGEX (`"Write|Edit"` matches
|
|
297
|
+
# "NotebookEdit" by substring), so applicability must too, or a gate
|
|
298
|
+
# whose table entry never listed NotebookEdit explicitly stops firing on
|
|
299
|
+
# it even though the old per-gate matcher group DID fire (a coverage
|
|
300
|
+
# narrowing the byte-identical-behavior bar exists to catch);
|
|
301
|
+
# - a missing/unrecognized tool_name means "the harness matched, but did
|
|
302
|
+
# not name the tool", not "not our tool": the five old wrappers never
|
|
303
|
+
# read tool_name at all, so they ALL ran on such a payload. Skipping
|
|
304
|
+
# every gate here would be a coverage WEAKENING (the gate-weakening
|
|
305
|
+
# direction is the dangerous one; see intent 203).
|
|
306
|
+
def tool_applies?(tool_name, tools)
|
|
307
|
+
return true if tool_name.to_s.empty?
|
|
308
|
+
Regexp.new(tools.join("|")).match?(tool_name.to_s)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# --- the ordered evaluation (spec D-a, D-i) -------------------------------
|
|
312
|
+
# `route` is injected so a test can supply a raising gate and prove isolation
|
|
313
|
+
# without eval, an ENV seam, or a global. `gate_tools` is injected for the same
|
|
314
|
+
# reason and defaults to the single source of truth.
|
|
315
|
+
# Returns the process exit code.
|
|
316
|
+
def dispatch(ctx:, route:, gate_tools: HookRegistry::GATE_TOOLS, out: $stdout, err: $stderr)
|
|
317
|
+
gate_tools.each do |gate, tools|
|
|
318
|
+
next unless tool_applies?(ctx.tool_name, tools)
|
|
319
|
+
|
|
320
|
+
begin
|
|
321
|
+
outcome = route.call(gate, ctx)
|
|
322
|
+
next unless outcome
|
|
323
|
+
|
|
324
|
+
if outcome.shape == :json
|
|
325
|
+
out.print outcome.stdout
|
|
326
|
+
return 0
|
|
327
|
+
else
|
|
328
|
+
outcome.lines.each { |line| err.puts line }
|
|
329
|
+
return 2
|
|
330
|
+
end
|
|
331
|
+
rescue StandardError => e
|
|
332
|
+
# Fix 7: outcome.shape / out.print / err.puts now live INSIDE the same
|
|
333
|
+
# rescue as route.call, so a malformed outcome or a stream write
|
|
334
|
+
# failure (e.g. EPIPE) is isolated exactly like a raising gate, and
|
|
335
|
+
# never aborts the gates still to come.
|
|
336
|
+
err.puts "plastic #{gate} error: #{e.message}"
|
|
337
|
+
next
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
0
|
|
341
|
+
end
|
|
342
|
+
end
|
|
@@ -23,7 +23,37 @@ module HookRegistry
|
|
|
23
23
|
].freeze
|
|
24
24
|
|
|
25
25
|
WRITE_MATCHER = (%w[Write Edit NotebookEdit] + SERENA_EDIT_TOOLS).join("|")
|
|
26
|
-
|
|
26
|
+
|
|
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 statusMessage, kept as its own source because these five names stop
|
|
46
|
+
# appearing in `events` once registration collapses, while Codex still
|
|
47
|
+
# registers all five per-gate entries and must keep emitting today's exact
|
|
48
|
+
# statusMessage strings (intent 244, spec D-e). codex_hooks_json merges this
|
|
49
|
+
# under the names it derives from `events`.
|
|
50
|
+
GATE_STATUS = {
|
|
51
|
+
"code-gate" => "Checking lifecycle gate...",
|
|
52
|
+
"lock-gate" => "Checking lock gate...",
|
|
53
|
+
"savepoint-pre" => "Recording stage start...",
|
|
54
|
+
"links-gate" => "Checking Links gate...",
|
|
55
|
+
"create-gate" => "Checking create gate...",
|
|
56
|
+
}.freeze
|
|
27
57
|
|
|
28
58
|
# event => ordered list of { "matcher" =>, "hooks" => [{ "name" =>, "status" => }] }
|
|
29
59
|
# The name is the hooks/<name> launcher; the flat install ships it as
|
|
@@ -42,23 +72,16 @@ module HookRegistry
|
|
|
42
72
|
] },
|
|
43
73
|
],
|
|
44
74
|
"PreToolUse" => [
|
|
75
|
+
# ONE registered edit-path hook (intent 244, spec D-d). WRITE_MATCHER is a
|
|
76
|
+
# strict superset of the three matcher groups this replaces; per-gate tool
|
|
77
|
+
# applicability lives in GATE_TOOLS above and is read by
|
|
78
|
+
# scripts/hook-edit-gates, so no gate's coverage widened or narrowed.
|
|
45
79
|
{ "matcher" => WRITE_MATCHER, "hooks" => [
|
|
46
|
-
{ "name" => "
|
|
47
|
-
{ "name" => "lock-gate", "status" => "Checking lock gate..." },
|
|
48
|
-
] },
|
|
49
|
-
{ "matcher" => "Write|Edit", "hooks" => [
|
|
50
|
-
{ "name" => "savepoint-pre", "status" => "Recording stage start..." },
|
|
51
|
-
{ "name" => "links-gate", "status" => "Checking Links gate..." },
|
|
52
|
-
] },
|
|
53
|
-
{ "matcher" => CREATE_MATCHER, "hooks" => [
|
|
54
|
-
{ "name" => "create-gate", "status" => "Checking create gate..." },
|
|
80
|
+
{ "name" => "edit-gates", "status" => "Checking Plastic gates..." },
|
|
55
81
|
] },
|
|
56
82
|
{ "matcher" => "Bash", "hooks" => [
|
|
57
83
|
{ "name" => "bash-gate", "status" => "Checking lifecycle gate..." },
|
|
58
84
|
] },
|
|
59
|
-
{ "matcher" => "Bash|Read|Grep|Glob", "hooks" => [
|
|
60
|
-
{ "name" => "retrieval-gate", "status" => "Checking retrieval gate..." },
|
|
61
|
-
] },
|
|
62
85
|
],
|
|
63
86
|
"PostToolUse" => [
|
|
64
87
|
{ "matcher" => "Write|Edit", "hooks" => [
|
|
@@ -70,7 +93,7 @@ module HookRegistry
|
|
|
70
93
|
{ "name" => "continue", "status" => "Checking for continue..." },
|
|
71
94
|
{ "name" => "future-intent-check", "status" => "Checking future intents..." },
|
|
72
95
|
{ "name" => "auto-arm", "status" => "Checking auto mode..." },
|
|
73
|
-
{ "name" => "
|
|
96
|
+
{ "name" => "power-tools", "status" => "Checking power tools..." },
|
|
74
97
|
] },
|
|
75
98
|
],
|
|
76
99
|
}
|
|
@@ -89,16 +112,14 @@ module HookRegistry
|
|
|
89
112
|
CODEX_POST_HOOKS = %w[gate-check].freeze
|
|
90
113
|
|
|
91
114
|
# Codex's shell-tool gate hole (intent 203): bash-gate (denies a shell write to
|
|
92
|
-
# project code before How)
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
|
|
100
|
-
# intent exists to fix.
|
|
101
|
-
CODEX_BASH_HOOKS = %w[bash-gate retrieval-gate].freeze
|
|
115
|
+
# project code before How) belongs on the Bash matcher, and ONLY Bash: the
|
|
116
|
+
# official Codex hooks doc's PreToolUse event catalog enumerates exactly Bash,
|
|
117
|
+
# apply_patch, and MCP tool calls, and neither it nor the two prior Codex
|
|
118
|
+
# research passes (198's official-docs research, 181's deep research)
|
|
119
|
+
# documents a discrete Read, Grep, or Glob tool name (D3). Registering a tool
|
|
120
|
+
# name Codex never reports would be dead weight that looks alive, the exact
|
|
121
|
+
# defect this intent exists to fix.
|
|
122
|
+
CODEX_BASH_HOOKS = %w[bash-gate].freeze
|
|
102
123
|
|
|
103
124
|
# Live-state events registered WHOLE (intent 199), unlike CODEX_PRE_HOOKS/
|
|
104
125
|
# CODEX_POST_HOOKS above: Codex's SessionStart/UserPromptSubmit/PreCompact already
|
|
@@ -109,10 +130,16 @@ module HookRegistry
|
|
|
109
130
|
CODEX_LIVE_STATE_EVENTS = %w[SessionStart UserPromptSubmit PreCompact].freeze
|
|
110
131
|
|
|
111
132
|
def codex_hooks_json(dispatcher_path:)
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
|
|
115
|
-
|
|
133
|
+
# GATE_STATUS first, then `events`, so `events` still wins for every name it
|
|
134
|
+
# carries. The five edit-path gate names left `events` when Claude's
|
|
135
|
+
# registration collapsed to one hook (intent 244), but Codex still registers
|
|
136
|
+
# all five separately and must keep emitting today's exact statusMessage
|
|
137
|
+
# strings; without this merge every Codex statusMessage would silently become
|
|
138
|
+
# "" and doctor's command-only diff would never notice.
|
|
139
|
+
status_by_name = GATE_STATUS.merge(
|
|
140
|
+
events.values.flatten.flat_map { |g| g["hooks"] }
|
|
141
|
+
.each_with_object({}) { |h, m| m[h["name"]] = h["status"] }
|
|
142
|
+
)
|
|
116
143
|
cmd = ->(name) {
|
|
117
144
|
{ "type" => "command",
|
|
118
145
|
"command" => "\"#{dispatcher_path}\" #{name}",
|
|
@@ -120,7 +147,12 @@ module HookRegistry
|
|
|
120
147
|
}
|
|
121
148
|
# Preserve the order these hook names appear across the PreToolUse groups in `events`.
|
|
122
149
|
pre_order = events["PreToolUse"].flat_map { |g| g["hooks"].map { |h| h["name"] } }
|
|
123
|
-
|
|
150
|
+
# Claude's edit-path groups collapsed to one hook, so the per-gate order can no
|
|
151
|
+
# longer be read out of `events` (spec D-l). CODEX_PRE_HOOKS is its own literal
|
|
152
|
+
# order (code-gate lock-gate savepoint-pre links-gate create-gate) and the
|
|
153
|
+
# intersection with GATE_TOOLS.keys still validates that every Codex gate name
|
|
154
|
+
# exists in the registry. The emitted JSON is unchanged.
|
|
155
|
+
pre = (CODEX_PRE_HOOKS & GATE_TOOLS.keys).map { |n| cmd.call(n) }
|
|
124
156
|
bash = (pre_order & CODEX_BASH_HOOKS).map { |n| cmd.call(n) }
|
|
125
157
|
post = CODEX_POST_HOOKS.map { |n| cmd.call(n) }
|
|
126
158
|
|