@zalom/plastic 1.11.0 → 1.13.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/scripts/doctor.rb CHANGED
@@ -13,6 +13,7 @@ require "date"
13
13
 
14
14
  require_relative "lib/doctor_core"
15
15
 
16
+ require_relative "lib/doctor_exclusions"
16
17
  require_relative "lib/qmd_sync"
17
18
  require_relative "lib/intent_validator"
18
19
  require_relative "lib/graph_rebuild"
@@ -531,17 +532,24 @@ class Doctor
531
532
  # check_done_signals's store-wide loop (211's territory, unchanged severities) and the new
532
533
  # per-intent check (check_intent_end) call, so the two surfaces can never independently
533
534
  # drift on what counts as a phantom line or a completeness gap. Returns
534
- # {conflict:, phantom:, gap:, stalled:} where conflict/phantom/stalled are nil or a finding
535
- # string, and gap is an ARRAY (0, 1, or 2 strings): the original inline code pushed the
536
- # "outcome missing" gap and the "audit echo missing" gap as two SEPARATE, independent `if`
537
- # blocks (never elsif), so a single terminal dir missing BOTH can legitimately contribute
538
- # two distinct gap strings in the same pass; collapsing that into one nilable field would
539
- # silently drop one of the two on a dir where both are true (verified against
540
- # test/doctor_done_signals_test.rb's fixtures before this extraction, per plan.md Step 3).
541
- def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, active:)
535
+ # {conflict:, phantom:, gap:, operational_gap:, excluded:, stalled:} where conflict/phantom/
536
+ # stalled are nil or a finding string, and gap/operational_gap/excluded are ARRAYs (0, 1, or 2
537
+ # strings): the original inline code pushed the "outcome missing" gap and the "audit echo
538
+ # missing" gap as two SEPARATE, independent `if` blocks (never elsif), so a single terminal
539
+ # dir missing BOTH can legitimately contribute two distinct gap strings in the same pass;
540
+ # collapsing that into one nilable field would silently drop one of the two on a dir where
541
+ # both are true (verified against test/doctor_done_signals_test.rb's fixtures before this
542
+ # extraction, per plan.md Step 3).
543
+ #
544
+ # `excluded_rules:` (intent 274) is the set of rule names the caller's doctor-exclusions file
545
+ # names for this one intent id. A savepoint_operational finding routes to :excluded instead of
546
+ # :operational_gap when "savepoint_operational" is in that set; the :gap bucket (signals_complete,
547
+ # the outcome.md check) never consults it, which is what keeps the exclusion key (intent_id,
548
+ # rule) rather than just intent_id (see test/doctor_done_signals_test.rb case 12).
549
+ def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, active:, excluded_rules: [])
542
550
  outcome = File.join(dir, "outcome.md")
543
551
  outcome_real = Bridge.stage_file_present?(outcome)
544
- findings = { conflict: nil, phantom: nil, gap: [], operational_gap: [], stalled: nil }
552
+ findings = { conflict: nil, phantom: nil, gap: [], operational_gap: [], excluded: [], stalled: nil }
545
553
 
546
554
  # HARD conflict: the deliverable exists but INDEX still says Active. This
547
555
  # is the one true INDEX-wins disagreement, so it stays a fail.
@@ -578,12 +586,13 @@ def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, activ
578
586
  # reconstructible via maintenance-run --tool rebuild-savepoint, so this is repairable and
579
587
  # reported as a fixable warn (savepoint_operational).
580
588
  savepoint = File.join(dir, "savepoint.md")
589
+ bucket = excluded_rules.include?("savepoint_operational") ? findings[:excluded] : findings[:operational_gap]
581
590
  if !File.exist?(savepoint)
582
- findings[:operational_gap] << "#{label}: terminal in INDEX but savepoint.md is missing " \
583
- "entirely (operational - reconstructible)"
591
+ bucket << "#{label}: terminal in INDEX but savepoint.md is missing " \
592
+ "entirely (operational - reconstructible)"
584
593
  elsif File.read(savepoint) !~ /\bDone\b.*\b(delivered|abandoned)\b/
585
- findings[:operational_gap] << "#{label}: terminal in INDEX but savepoint.md has no " \
586
- "`Done delivered|abandoned` line (operational - reconstructible)"
594
+ bucket << "#{label}: terminal in INDEX but savepoint.md has no " \
595
+ "`Done delivered|abandoned` line (operational - reconstructible)"
587
596
  end
588
597
 
589
598
  # Stalled completion: unchanged, never consulted amnesty.
@@ -601,10 +610,20 @@ def check_done_signals(scopes: nil)
601
610
  conflicts = []
602
611
  gaps = [] # delivery-claim (outcome.md) gaps only - legacy, informational
603
612
  operational_gaps = [] # savepoint gaps (missing file, or missing Done echo) - repairable
613
+ excluded = [] # savepoint gaps knowingly exempted via doctor-exclusions (intent 274)
614
+ exclusion_errors = [] # malformed doctor-exclusions lines, scope-tagged
615
+ exclusion_error_paths = []
616
+ exclusion_paths = [] # files that actually contributed a live exclusion
604
617
  stalled = []
605
618
  phantoms = []
606
619
 
607
620
  done_signal_stores(scopes).each do |store|
621
+ exclusions = DoctorExclusions.load(store[:index])
622
+ if exclusions[:errors].any?
623
+ exclusion_errors.concat(exclusions[:errors].map { |e| "#{store[:scope]}: #{e}" })
624
+ exclusion_error_paths << exclusions[:path]
625
+ end
626
+
608
627
  index_sections_by_dir(store[:index]).each do |dirname, in_sections|
609
628
  dir = File.join(store[:store_dir], dirname)
610
629
  next unless File.directory?(dir)
@@ -612,17 +631,26 @@ def check_done_signals(scopes: nil)
612
631
  terminal = (in_sections & ["Completed", "Abandoned"]).any?
613
632
  active = in_sections.include?("Active") && !terminal
614
633
  label = "#{store[:scope]} store/#{dirname}"
634
+ intent_id = dirname.split("--", 2).first
635
+ excluded_rules = DoctorExclusions.rules_for(exclusions, intent_id)
615
636
 
616
637
  findings = done_signal_findings_for_dir(
617
- dir, label: label, scope: store[:scope], dirname: dirname, terminal: terminal, active: active
638
+ dir, label: label, scope: store[:scope], dirname: dirname, terminal: terminal, active: active,
639
+ excluded_rules: excluded_rules
618
640
  )
619
641
  conflicts << findings[:conflict] if findings[:conflict]
620
642
  phantoms << findings[:phantom] if findings[:phantom]
621
643
  gaps.concat(findings[:gap])
622
644
  operational_gaps.concat(findings[:operational_gap])
645
+ if findings[:excluded].any?
646
+ excluded.concat(findings[:excluded])
647
+ exclusion_paths << exclusions[:path]
648
+ end
623
649
  stalled << findings[:stalled] if findings[:stalled]
624
650
  end
625
651
  end
652
+ exclusion_paths.uniq!
653
+ exclusion_error_paths.uniq!
626
654
 
627
655
  checks = []
628
656
 
@@ -662,18 +690,39 @@ def check_done_signals(scopes: nil)
662
690
  )
663
691
  end
664
692
 
665
- # savepoint_operational (intent 211, NEW): missing savepoint.md or missing Done echo -
666
- # repairable via maintenance-run --tool rebuild-savepoint, so this stays warn+fixable.
667
- if operational_gaps.empty?
693
+ # savepoint_operational (intent 211; intent 274 adds the per-store doctor-exclusions index):
694
+ # missing savepoint.md or missing Done echo - repairable via maintenance-run --tool
695
+ # rebuild-savepoint for most gaps, or knowingly excluded for the ones 219 D6 forbids ever
696
+ # repairing (no real outcome.md to echo a disposition from). Three branches (spec D4/D5):
697
+ # a malformed exclusion file can never report pass (loud), a clean remaining gap set reports
698
+ # pass with the exclusion count folded in, and a real remaining gap set stays warn, same as
699
+ # before intent 274, with the same count folded in when exclusions applied.
700
+ exclusion_suffix = excluded.empty? ? "" : " (#{excluded.size} excluded via #{exclusion_paths.join(", ")})"
701
+
702
+ if exclusion_errors.any?
703
+ checks << check(
704
+ category: "done_signals", name: "savepoint_operational", status: "warn",
705
+ message: "#{operational_gaps.size} terminal intent#{operational_gaps.size == 1 ? "" : "s"} " \
706
+ "missing an operational savepoint.md or its Done echo, and " \
707
+ "#{exclusion_errors.size} doctor-exclusions error#{exclusion_errors.size == 1 ? "" : "s"} " \
708
+ "(a malformed exclusion file never suppresses a finding)#{exclusion_suffix}",
709
+ details: operational_gaps + exclusion_errors, fixable: true,
710
+ fix_hint: "Fix the malformed doctor-exclusions file(s) (#{exclusion_error_paths.join(", ")}) - " \
711
+ "format `rule_name id id id`, blank lines and # comments ignored - then reconstruct " \
712
+ "any remaining real gap via `maintenance-run --tool rebuild-savepoint --intent <id> " \
713
+ "--apply` (197-conformant: receipt-before-write via RevisionsWriter, one intent per " \
714
+ "invocation, owner-approval-gated)."
715
+ )
716
+ elsif operational_gaps.empty?
668
717
  checks << check(
669
718
  category: "done_signals", name: "savepoint_operational", status: "pass",
670
- message: "No terminal intent is missing an operational savepoint.md or its Done echo"
719
+ message: "No terminal intent is missing an operational savepoint.md or its Done echo#{exclusion_suffix}"
671
720
  )
672
721
  else
673
722
  checks << check(
674
723
  category: "done_signals", name: "savepoint_operational", status: "warn",
675
724
  message: "#{operational_gaps.size} terminal intent#{operational_gaps.size == 1 ? "" : "s"} " \
676
- "missing an operational savepoint.md or its Done echo (reconstructible)",
725
+ "missing an operational savepoint.md or its Done echo (reconstructible)#{exclusion_suffix}",
677
726
  details: operational_gaps, fixable: true,
678
727
  fix_hint: "Reconstruct the minimal two-line started/Done echo via " \
679
728
  "`maintenance-run --tool rebuild-savepoint --intent <id> --apply` (197-conformant: " \
@@ -37,8 +37,9 @@ module Bridge
37
37
  # `## Active` block); such bridges are purged. An Active intent's bridge is kept
38
38
  # unconditionally, because while the intent is live the bridge is still load-
39
39
  # bearing: it is the continuation signal (a parked or interrupted run resumes
40
- # from it) and the anti-collision lock (it keys the per-session statusline so
41
- # parallel sessions do not overwrite each other). An age window was the wrong
40
+ # from it) and the anti-collision lock for parallel deliveries on one store,
41
+ # keeping each session's gate checks and locks from overwriting another's. An
42
+ # age window was the wrong
42
43
  # axis: it left dead bridges resident for ~2 days AND could reap bridges of
43
44
  # interrupted-but-still-active intents, which are exactly the ones to preserve.
44
45
 
@@ -121,7 +122,7 @@ module Bridge
121
122
  # The CLAUDE_CODE_SESSION_ID fallback (intent 79) carries the bg/headless real
122
123
  # session id (Claude Code passes session_id on stdin, not via an env var; the
123
124
  # headless id lives in CLAUDE_CODE_SESSION_ID). Keying by the real id (instead of
124
- # a derived hash) lets the statusline, which receives that same id on stdin, find
125
+ # a derived hash) lets the gate hooks, which receive that same id on stdin, find
125
126
  # the bridge by direct filename lookup.
126
127
  def self.resolve_session(explicit, intent_id:, store:)
127
128
  return explicit.to_s.strip unless blank?(explicit)
@@ -32,7 +32,9 @@ class Doctor
32
32
  # (intent 204): plastic-statusline is the settings["statusLine"] command, wired
33
33
  # outside HookRegistry.events entirely, so it must be excluded from the
34
34
  # orphan-launcher scan below or a correct install would report a false orphan.
35
- CLAUDE_NON_HOOK_LAUNCHERS = %w[plastic-statusline].freeze
35
+ # Defined in HookRegistry (intent 275) so the installer's purge can read it too;
36
+ # this is an alias, not a second source of truth.
37
+ CLAUDE_NON_HOOK_LAUNCHERS = HookRegistry::CLAUDE_NON_HOOK_LAUNCHERS
36
38
 
37
39
  REQUIRED_SCRIPTS = %w[
38
40
  folgezettel-id
@@ -105,6 +107,19 @@ class Doctor
105
107
  path.sub(Dir.home, "~")
106
108
  end
107
109
 
110
+ # Every hook command string registered under one settings.json event, flattened
111
+ # across that event's matcher groups. Shape-tolerant on purpose: settings.json is
112
+ # hand-editable and may carry anything at all under an event key.
113
+ def event_commands(groups)
114
+ return [] unless groups.is_a?(Array)
115
+
116
+ groups.flat_map do |group|
117
+ next [] unless group.is_a?(Hash) && group["hooks"].is_a?(Array)
118
+
119
+ group["hooks"].map { |h| h.is_a?(Hash) ? h["command"].to_s : "" }
120
+ end
121
+ end
122
+
108
123
  def check(category:, name:, status:, message:, details: [], fixable: false, fix_hint: nil)
109
124
  result = {
110
125
  category: category,
@@ -243,7 +258,10 @@ class Doctor
243
258
  message: "#{orphans.size} hook launcher(s) on disk are not registered in HookRegistry",
244
259
  details: orphans.map { |h| "#{tilde(hooks_dir)}/#{h}" },
245
260
  fixable: true,
246
- fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude (prunes stale launchers)"
261
+ fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude (prunes " \
262
+ "stale launchers). The plastic- prefix is reserved for Plastic's own hooks: " \
263
+ "if one of these is yours, rename it (for example to ~/.claude/hooks/" \
264
+ "writing-style) and re-register it in settings.json before re-running."
247
265
  )
248
266
  end
249
267
 
@@ -265,14 +283,30 @@ class Doctor
265
283
  )
266
284
  else
267
285
  hooks = settings["hooks"] || {}
286
+
287
+ # A live registration is a launcher Plastic ships TODAY (intent 277).
288
+ # claude_purge_command?, which this replaced, answers "was this ever ours":
289
+ # right for the installer's purge, wrong here, because a SessionStart
290
+ # carrying only the retired plastic-lock-gate satisfied the event while
291
+ # nothing shipped to run it.
268
292
  missing_events = CLAUDE_HOOK_EVENTS.reject do |event|
269
- groups = hooks[event]
270
- next false unless groups.is_a?(Array)
293
+ event_commands(hooks[event]).any? { |cmd| HookRegistry.claude_current_command?(cmd) }
294
+ end
271
295
 
272
- groups.any? do |group|
273
- group.is_a?(Hash) && group["hooks"].is_a?(Array) &&
274
- group["hooks"].any? { |h| h["command"].to_s.include?("plastic-") }
275
- end
296
+ # Name the launcher when a missing event still carries a Plastic-owned
297
+ # command. Inside a missing event every such command is by construction not
298
+ # a current one, and a bare "SessionStart" reads as "nothing registered" to
299
+ # someone looking at a settings.json that plainly holds a plastic- entry.
300
+ # Events with no Plastic entry keep the bare name: two tests compare details
301
+ # by element equality and by count.
302
+ missing_details = missing_events.map do |event|
303
+ stale = event_commands(hooks[event])
304
+ .flat_map { |cmd| HookRegistry.command_basenames(cmd) }
305
+ .select { |name| HookRegistry.claude_purgeable_launcher_names.include?(name) }
306
+ .uniq
307
+ next event if stale.empty?
308
+
309
+ "#{event} (registered command is not a current Plastic hook: #{stale.join(', ')})"
276
310
  end
277
311
 
278
312
  if missing_events.empty?
@@ -284,7 +318,7 @@ class Doctor
284
318
  checks << check(
285
319
  category: "agent_registration", name: "hooks_registered", status: "fail",
286
320
  message: "#{missing_events.size} hook event(s) not registered in settings.json",
287
- details: missing_events,
321
+ details: missing_details,
288
322
  fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
289
323
  )
290
324
  end
@@ -309,7 +343,7 @@ class Doctor
309
343
  live_plastic = (settings["hooks"] || {}).flat_map do |event, groups|
310
344
  Array(groups).flat_map do |g|
311
345
  next [] unless g.is_a?(Hash) && g["hooks"].is_a?(Array)
312
- g["hooks"].map { |h| h["command"].to_s }.select { |c| c.include?("plastic-") }
346
+ g["hooks"].map { |h| h["command"].to_s }.select { |c| HookRegistry.claude_purge_command?(c) }
313
347
  .map { |c| "#{event}: #{c}" }
314
348
  end
315
349
  end
@@ -0,0 +1,93 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "rule_catalog"
5
+
6
+ # DoctorExclusions - resolves, reads, and parses one store's per-store `doctor-exclusions`
7
+ # table (intent 274): the record of knowingly-exempt (intent_id, rule) pairs that lets doctor
8
+ # skip a finding it can never legitimately repair (219 D6 forbids inventing a disposition for
9
+ # an unrepairable gap).
10
+ #
11
+ # Location (spec D6): sibling to that store's INDEX.md, resolved via `path_for` from the same
12
+ # `store[:index]` Doctor#done_signal_stores already yields - zero new store-discovery logic.
13
+ # Deliberately no `.md` extension: this is a config table, not a markdown document indexed by
14
+ # QMD or walked by lifecycle machinery.
15
+ #
16
+ # Format (spec D6), `/etc/hosts`-shaped: `rule_name id id id`, one rule per line. Blank lines
17
+ # and `#`-comment lines are ignored. Duplicate rule lines union their ids.
18
+ #
19
+ # Error contract (spec D5): fail open, loud in doctor. A missing file is the normal case -
20
+ # zero exclusions, zero errors, identical to before this file existed. A malformed line never
21
+ # excludes anything (fail milder than the bug: a typo must not silently suppress a real
22
+ # regression) and contributes one error string naming its 1-based line number. An unreadable
23
+ # file contributes one error and zero exclusions. This module NEVER raises.
24
+ module DoctorExclusions
25
+ module_function
26
+
27
+ FILENAME = "doctor-exclusions"
28
+
29
+ # Same shape test/packaging_no_store_ids_test.rb already defines for a real Folgezettel id:
30
+ # digit-leading, then any mix of letters and digits.
31
+ FOLGEZETTEL_ID = /\A\d+[a-zA-Z0-9]*\z/
32
+
33
+ def path_for(index_path)
34
+ File.join(File.dirname(index_path), FILENAME)
35
+ end
36
+
37
+ # PURE. { rules: { rule_name => [ids] }, errors: [String] }. A line producing any error
38
+ # contributes nothing to rules; duplicate rule lines union their ids without an error.
39
+ #
40
+ # `scrub` (never raises) before any regex/String op: a hand-edited file can carry a byte
41
+ # sequence invalid in its declared encoding (e.g. a stray Latin-1 byte in a comment), and
42
+ # String#strip/split/=~ all raise Encoding::CompatibilityError on that input. Scrubbing
43
+ # replaces the invalid byte with U+FFFD and keeps this module's never-raises contract (D5)
44
+ # true for every input, not just well-formed UTF-8.
45
+ def parse(text)
46
+ rules = {}
47
+ errors = []
48
+
49
+ text.to_s.scrub.each_line.with_index(1) do |raw_line, n|
50
+ line = raw_line.strip
51
+ next if line.empty? || line.start_with?("#")
52
+
53
+ line = line.sub(/(?:\A|\s)#.*\z/, "").rstrip
54
+ tokens = line.split
55
+ rule = tokens.shift
56
+ line_errors = []
57
+
58
+ line_errors << "line #{n}: rule \"#{rule}\" lists no intent ids" if tokens.empty?
59
+ unless RuleCatalog.excludable_check?(rule)
60
+ line_errors << "line #{n}: unknown or non-excludable rule \"#{rule}\""
61
+ end
62
+ tokens.each do |tok|
63
+ line_errors << "line #{n}: \"#{tok}\" is not a Folgezettel intent id" unless tok =~ FOLGEZETTEL_ID
64
+ end
65
+
66
+ if line_errors.empty?
67
+ (rules[rule] ||= []).concat(tokens)
68
+ rules[rule].uniq!
69
+ else
70
+ errors.concat(line_errors)
71
+ end
72
+ end
73
+
74
+ { rules: rules, errors: errors }
75
+ end
76
+
77
+ # IO. `parse`'s shape plus `path:`. Never raises: a missing file is the normal case (zero
78
+ # exclusions, zero errors); an unreadable file (permission, is-a-directory, any
79
+ # SystemCallError) yields one error and zero exclusions.
80
+ def load(index_path)
81
+ path = path_for(index_path)
82
+ return { rules: {}, errors: [], path: path } unless File.exist?(path)
83
+
84
+ parse(File.read(path)).merge(path: path)
85
+ rescue SystemCallError => e
86
+ { rules: {}, errors: ["#{path}: unreadable (#{e.message})"], path: path }
87
+ end
88
+
89
+ # Rule names excluding `intent_id` in an already-`load`ed result. [] when none.
90
+ def rules_for(loaded, intent_id)
91
+ loaded[:rules].select { |_rule, ids| ids.include?(intent_id) }.keys
92
+ end
93
+ end
@@ -186,6 +186,110 @@ module HookRegistry
186
186
  .uniq.sort.map { |name| "plastic-#{name}" }
187
187
  end
188
188
 
189
+ # Launchers the installer places in the agent's hooks dir that `events` does not
190
+ # register (intent 204): plastic-statusline is the settings["statusLine"] command.
191
+ # Defined here rather than in doctor_core so the installer's purge can recognise it
192
+ # without depending on the doctor; Doctor::CLAUDE_NON_HOOK_LAUNCHERS aliases it.
193
+ CLAUDE_NON_HOOK_LAUNCHERS = %w[plastic-statusline].freeze
194
+
195
+ # Hook names Plastic HAS registered and no longer does (intent 275). Purge-only:
196
+ # an old install still carries these entries in settings.json / hooks.json, and
197
+ # nothing else can tell us they were ever ours.
198
+ #
199
+ # MAINTENANCE DUTY: renaming or removing a hook from `events` means adding its old
200
+ # name here in the SAME change. Skip it and every existing install keeps a dead
201
+ # registration no update will ever clean up.
202
+ #
203
+ # Never fold these into claude_launcher_names: that method is what doctor's
204
+ # hooks_exist demands be present on disk, so a retired name there makes a correct
205
+ # install report missing launchers.
206
+ RETIRED_HOOK_NAMES = %w[
207
+ code-gate create-gate links-gate lock-gate savepoint-pre
208
+ qmd-search retrieval-gate model-instructions opus-manual
209
+ ].freeze
210
+
211
+ RETIRED_CLAUDE_LAUNCHERS = RETIRED_HOOK_NAMES.map { |n| "plastic-#{n}" }.freeze
212
+
213
+ # Filenames of Plastic's Codex dispatcher, current and retired. Codex hooks are
214
+ # not per-hook launcher files: every command is `"<dispatcher>" <name>`, so the
215
+ # dispatcher's own filename is what identifies an entry as ours.
216
+ CODEX_DISPATCHER_BASENAMES = %w[codex-hook].freeze
217
+
218
+ # Every launcher name the installer may purge from settings.json: what we register
219
+ # now, the non-hook launchers we place, and what we used to register.
220
+ def claude_purgeable_launcher_names
221
+ (claude_launcher_names + CLAUDE_NON_HOOK_LAUNCHERS + RETIRED_CLAUDE_LAUNCHERS).uniq.sort
222
+ end
223
+
224
+ # Current Codex hook names, from the same sources codex_hooks_json builds from.
225
+ def codex_hook_names
226
+ live = CODEX_LIVE_STATE_EVENTS.flat_map do |event|
227
+ events[event].flat_map { |g| g["hooks"].map { |h| h["name"] } }
228
+ end
229
+ (CODEX_PRE_HOOKS + CODEX_POST_HOOKS + CODEX_BASH_HOOKS + live).uniq.sort
230
+ end
231
+
232
+ def codex_purgeable_hook_names
233
+ (codex_hook_names + RETIRED_HOOK_NAMES).uniq.sort
234
+ end
235
+
236
+ # Is this settings.json hook command one of OURS? (intent 275)
237
+ #
238
+ # Ownership is registry membership, never a substring: the substring test this
239
+ # replaced deleted a user's own ~/.claude/hooks/plastic-writing-style hook on
240
+ # update. Tokenised rather than first-token-only because legacy entries take the
241
+ # form `ruby <path>/plastic-<name>.rb`, and those must still be purged.
242
+ def claude_purge_command?(cmd)
243
+ known = claude_purgeable_launcher_names
244
+ command_basenames(cmd).any? { |name| known.include?(name) }
245
+ end
246
+
247
+ # Is this settings.json hook command one Plastic registers TODAY? (intent 277)
248
+ #
249
+ # The narrower twin of claude_purge_command?. The purge asks "was this ever
250
+ # ours", because it has to recognise an old entry in order to remove it. A
251
+ # liveness check asks "is there something here that still runs", and the two
252
+ # sets differ by exactly the entries that make the answers disagree: a
253
+ # SessionStart carrying only plastic-lock-gate satisfied doctor's
254
+ # hooks_registered while no such launcher ships anymore.
255
+ #
256
+ # Sourced from claude_launcher_names alone, so RETIRED_CLAUDE_LAUNCHERS is out
257
+ # (that is the bug) and CLAUDE_NON_HOOK_LAUNCHERS is out too: plastic-statusline
258
+ # is settings["statusLine"], not a hook of any event, so a group whose only
259
+ # Plastic entry is the statusline command has no hook registered in it.
260
+ # Tokenised through command_basenames like the purge predicate, so a quoted
261
+ # path or the legacy `ruby <path>/plastic-<name>.rb` form still resolves.
262
+ def claude_current_command?(cmd)
263
+ known = claude_launcher_names
264
+ command_basenames(cmd).any? { |name| known.include?(name) }
265
+ end
266
+
267
+ # Is this ~/.codex/hooks.json command one of ours? Every Plastic Codex entry
268
+ # invokes our dispatcher by path (`"<plastic_home>/scripts/codex-hook" <name>`),
269
+ # so the dispatcher's filename identifies it. Basename EQUALITY, so a user's
270
+ # ~/bin/codex-hook-wrapper is not ours; the argument is not filtered on, because
271
+ # a command that already runs our dispatcher is ours whatever gate it names, and
272
+ # filtering would strand any name we forgot to retire.
273
+ #
274
+ # Tokenised the same way claude_purge_command? is (command_basenames), NOT a
275
+ # naive cmd.split.first: a first-token split breaks whenever plastic_home
276
+ # contains a space, since the shell-quoted dispatcher path then splits across
277
+ # multiple whitespace tokens and the true first token is only half the path.
278
+ # command_basenames strips quote characters per token, so whichever token
279
+ # carries the dispatcher's trailing `/codex-hook"` still resolves to the bare
280
+ # basename "codex-hook" after its trailing quote is stripped.
281
+ def codex_purge_command?(cmd)
282
+ command_basenames(cmd).any? { |name| CODEX_DISPATCHER_BASENAMES.include?(name) }
283
+ end
284
+
285
+ # Each whitespace-separated token reduced to a comparable launcher name:
286
+ # quotes stripped, directories dropped, a trailing .rb removed.
287
+ def command_basenames(cmd)
288
+ cmd.to_s.split(/\s+/).reject(&:empty?).map do |token|
289
+ File.basename(token.delete("\"'")).sub(/\.rb\z/, "")
290
+ end
291
+ end
292
+
189
293
  # The settings.json shape merge_claude_hooks expects: single-group events map
190
294
  # to a Hash, multi-group events to an Array (the merge loop handles both).
191
295
  def claude_settings_hooks(hook_dir:)