@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.
Files changed (72) hide show
  1. package/PLASTIC.md +75 -535
  2. package/README.md +3 -2
  3. package/agents/plastic-intent-curator.md +2 -2
  4. package/deprecations.yml +10 -2
  5. package/hooks/{links-gate → edit-gates} +1 -1
  6. package/hooks/hooks.json +4 -44
  7. package/hooks/power-tools +8 -0
  8. package/package.json +1 -2
  9. package/scripts/codex-hook +17 -22
  10. package/scripts/doctor.rb +688 -186
  11. package/scripts/end-intent +65 -22
  12. package/scripts/hook-code-gate +13 -28
  13. package/scripts/hook-create-gate +10 -57
  14. package/scripts/hook-edit-gates +58 -0
  15. package/scripts/hook-links-gate +8 -31
  16. package/scripts/hook-lock-gate +13 -58
  17. package/scripts/hook-power-tools +38 -0
  18. package/scripts/hook-savepoint-pre +8 -11
  19. package/scripts/lib/bridge.rb +2 -2
  20. package/scripts/lib/edit_gates.rb +342 -0
  21. package/scripts/lib/hook_registry.rb +61 -29
  22. package/scripts/lib/installer_core.rb +100 -18
  23. package/scripts/lib/outcome_guard.rb +38 -0
  24. package/scripts/lib/qmd_hook.rb +21 -41
  25. package/scripts/lib/qmd_sync.rb +0 -15
  26. package/scripts/lib/revisions_writer.rb +1 -1
  27. package/scripts/maintenance-run +56 -2
  28. package/scripts/restore-intent-v1 +16 -11
  29. package/skills/auto/SKILL.md +15 -4
  30. package/skills/auto/evals/evals.json +2 -2
  31. package/skills/conventions/SKILL.md +31 -0
  32. package/skills/conventions/references/completion-and-done.md +40 -0
  33. package/skills/conventions/references/gates-and-enforcement.md +23 -0
  34. package/skills/conventions/references/knowledge-graph.md +47 -0
  35. package/skills/conventions/references/lifecycle-and-savepoints.md +11 -0
  36. package/skills/conventions/references/locks-and-worktrees.md +113 -0
  37. package/skills/conventions/references/maintenance-and-revisions.md +170 -0
  38. package/skills/conventions/references/roadmaps.md +44 -0
  39. package/skills/conventions/references/tiers-and-dispatch.md +134 -0
  40. package/skills/doctor/SKILL.md +59 -19
  41. package/skills/intent-brainstorming/SKILL.md +4 -0
  42. package/skills/intent-continuing/SKILL.md +4 -0
  43. package/skills/intent-creating/SKILL.md +7 -2
  44. package/skills/intent-ending/SKILL.md +20 -6
  45. package/skills/intent-executing/SKILL.md +10 -0
  46. package/skills/intent-linking/SKILL.md +4 -0
  47. package/skills/intent-locking/SKILL.md +4 -0
  48. package/skills/intent-planning/SKILL.md +7 -0
  49. package/skills/intent-savepoint/SKILL.md +4 -0
  50. package/skills/intent-speccing/SKILL.md +4 -0
  51. package/skills/intent-starting/SKILL.md +10 -0
  52. package/skills/project-creating/references/project-scaffolding.md +2 -2
  53. package/skills/roadmap/SKILL.md +4 -0
  54. package/skills/roadmap-continuing/SKILL.md +4 -0
  55. package/skills/skill-creating/SKILL.md +3 -0
  56. package/skills/skill-creating/references/defaults-first.md +23 -0
  57. package/skills/skill-creating/references/hooks.md +4 -1
  58. package/skills/store-curating/SKILL.md +8 -0
  59. package/skills/store-indexing/SKILL.md +8 -0
  60. package/skills/tutorial/references/track-2-auto.md +2 -3
  61. package/templates/agents.md +8 -0
  62. package/PLASTIC-reference.md +0 -138
  63. package/hooks/code-gate +0 -27
  64. package/hooks/create-gate +0 -3
  65. package/hooks/lock-gate +0 -21
  66. package/hooks/qmd-search +0 -8
  67. package/hooks/retrieval-gate +0 -10
  68. package/hooks/savepoint-pre +0 -10
  69. package/scripts/hook-qmd-search +0 -44
  70. package/scripts/hook-retrieval-gate +0 -148
  71. package/scripts/lib/legacy_bookend_amnesty.rb +0 -35
  72. 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
- CREATE_MATCHER = (%w[Write Edit] + SERENA_EDIT_TOOLS).join("|")
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" => "code-gate", "status" => "Checking lifecycle gate..." },
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" => "qmd-search", "status" => "Searching QMD..." },
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) and retrieval-gate (advisory, never denies) both
93
- # belong on the Bash matcher, and ONLY Bash: the official Codex hooks doc's
94
- # PreToolUse event catalog enumerates exactly Bash, apply_patch, and MCP tool
95
- # calls, and neither it nor the two prior Codex research passes (198's
96
- # official-docs research, 181's deep research) documents a discrete Read,
97
- # Grep, or Glob tool name (D3). So this does NOT copy Claude's four-name
98
- # "Bash|Read|Grep|Glob" retrieval-gate matcher; registering a tool name Codex
99
- # never reports would be dead weight that looks alive, the exact defect this
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
- # name => statusMessage, straight from the single `events` source (A8): the
113
- # guide Part 3 hooks.json format carries a per-hook statusMessage, so emit it.
114
- status_by_name = events.values.flatten.flat_map { |g| g["hooks"] }
115
- .each_with_object({}) { |h, m| m[h["name"]] = h["status"] }
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
- pre = (pre_order & CODEX_PRE_HOOKS).map { |n| cmd.call(n) }
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